From 49f326f17a1c52f46bdd4ad3168e5633c121bfd7 Mon Sep 17 00:00:00 2001 From: itsjoxdev Date: Sat, 25 Jul 2026 13:37:01 +0300 Subject: [PATCH 1/3] feat: initialize FastAPI application with CORS configuration and user module router --- apps/api/main.py | 18 ++++++++++++++++-- apps/api/modules/users/router.py | 2 +- apps/web/app/page.tsx | 8 -------- 3 files changed, 17 insertions(+), 11 deletions(-) delete mode 100644 apps/web/app/page.tsx diff --git a/apps/api/main.py b/apps/api/main.py index d258ad5..2a1cbe1 100644 --- a/apps/api/main.py +++ b/apps/api/main.py @@ -1,18 +1,32 @@ from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware app = FastAPI(title="TicketFlow API", version="1.0.0") -# TODO: Add CORS middleware -# TODO: Include routers from modules +# Configure CORS for potential direct frontend-to-backend communication +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:3000"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + # TODO: Add exception handlers +# pyrefly: ignore [missing-import] from apps.api.modules.auth.router import router as auth_router +# pyrefly: ignore [missing-import] from apps.api.modules.tickets.router import router as tickets_router +# pyrefly: ignore [missing-import] from apps.api.modules.dashboard.router import router as dashboard_router +# pyrefly: ignore [missing-import] +from apps.api.modules.users.router import router as users_router app.include_router(auth_router, prefix="/api/v1/auth") app.include_router(tickets_router, prefix="/api/v1/tickets") app.include_router(dashboard_router, prefix="/api/v1/dashboard") +app.include_router(users_router, prefix="/api/v1/users") @app.get("/") def read_root(): diff --git a/apps/api/modules/users/router.py b/apps/api/modules/users/router.py index b96e828..44acc1b 100644 --- a/apps/api/modules/users/router.py +++ b/apps/api/modules/users/router.py @@ -1,5 +1,5 @@ from fastapi import APIRouter -router = APIRouter(prefix="/users", tags=["users"]) +router = APIRouter(tags=["Users"]) # TODO: Implement users endpoints diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx deleted file mode 100644 index 06bd811..0000000 --- a/apps/web/app/page.tsx +++ /dev/null @@ -1,8 +0,0 @@ -export default function Home() { - return ( -
- {/* TODO: Implement home page */} -

Welcome to TicketFlow

-
- ); -} From a02335e850fe2e06fb1b0bcb9f2c0ea9ff8c066f Mon Sep 17 00:00:00 2001 From: itsjoxdev Date: Sat, 25 Jul 2026 15:09:01 +0300 Subject: [PATCH 2/3] refactor(web): migrate frontend to feature-based architecture --- apps/api/core/dependencies/auth.py | 10 + apps/api/modules/users/router.py | 121 +++++++++++- apps/api/modules/users/schemas.py | 5 + apps/api/modules/users/service.py | 130 +++++++++++- apps/api/scripts/seed.py | 52 +++++ apps/web/app/(auth)/login/page.tsx | 2 +- apps/web/app/(dashboard)/layout.tsx | 2 +- apps/web/app/(dashboard)/page.tsx | 4 +- .../web/app/(dashboard)/tickets/[id]/page.tsx | 4 +- apps/web/app/(dashboard)/tickets/page.tsx | 4 +- apps/web/app/(dashboard)/users/[id]/page.tsx | 44 +++++ apps/web/app/(dashboard)/users/error.tsx | 37 ++++ apps/web/app/(dashboard)/users/loading.tsx | 25 +++ apps/web/app/(dashboard)/users/new/page.tsx | 23 +++ apps/web/app/(dashboard)/users/page.tsx | 74 +++++++ apps/web/app/api/users/[id]/activate/route.ts | 34 ++++ .../app/api/users/[id]/deactivate/route.ts | 34 ++++ apps/web/app/api/users/[id]/role/route.ts | 41 ++++ apps/web/app/api/users/[id]/route.ts | 98 ++++++++++ apps/web/app/api/users/route.ts | 64 ++++++ apps/web/components/auth/AuthGuard.tsx | 25 +-- apps/web/components/auth/LoginForm.tsx | 106 +--------- apps/web/components/auth/LogoutButton.tsx | 34 +--- .../components/dashboard/DashboardCharts.tsx | 88 +-------- .../components/dashboard/DashboardHeader.tsx | 19 +- .../dashboard/DashboardHeaderWrapper.tsx | 10 +- .../components/dashboard/RecentActivity.tsx | 88 +-------- .../web/components/dashboard/SummaryCards.tsx | 60 +----- apps/web/components/dashboard/index.ts | 6 +- .../components/tickets/TicketAssignment.tsx | 90 +-------- apps/web/components/tickets/TicketFilters.tsx | 84 +------- .../components/tickets/TicketPagination.tsx | 89 +-------- .../tickets/TicketPriorityBadge.tsx | 32 +-- .../components/tickets/TicketStatusBadge.tsx | 38 +--- apps/web/components/tickets/TicketTable.tsx | 74 +------ .../web/components/tickets/TicketWorkflow.tsx | 74 +------ apps/web/components/tickets/index.ts | 8 +- apps/web/components/users/UserActions.tsx | 1 + apps/web/components/users/UserFilters.tsx | 1 + apps/web/components/users/UserForm.tsx | 1 + apps/web/components/users/UserPagination.tsx | 1 + apps/web/components/users/UserRoleBadge.tsx | 1 + apps/web/components/users/UserStatusBadge.tsx | 1 + apps/web/components/users/UserTable.tsx | 1 + apps/web/components/users/index.ts | 1 + apps/web/features/auth/api/auth.api.ts | 45 +++++ .../features/auth/components/AuthGuard.tsx | 24 +++ .../features/auth/components/LoginForm.tsx | 105 ++++++++++ .../features/auth/components/LogoutButton.tsx | 33 ++++ apps/web/features/auth/components/index.ts | 3 + apps/web/features/auth/index.ts | 3 + apps/web/features/auth/types.ts | 31 ++- .../features/dashboard/api/dashboard.api.ts | 102 ++++++++++ .../dashboard/components/DashboardCharts.tsx | 87 ++++++++ .../dashboard/components/DashboardHeader.tsx | 18 ++ .../components/DashboardHeaderWrapper.tsx | 9 + .../dashboard/components/RecentActivity.tsx | 87 ++++++++ .../dashboard/components/SummaryCards.tsx | 59 ++++++ .../features/dashboard/components/index.ts | 5 + apps/web/features/dashboard/index.ts | 3 + apps/web/features/dashboard/types.ts | 23 ++- apps/web/features/tickets/api/tickets.api.ts | 169 ++++++++++++++++ .../tickets/components/TicketAssignment.tsx | 89 +++++++++ .../tickets/components/TicketFilters.tsx | 83 ++++++++ .../tickets/components/TicketPagination.tsx | 88 +++++++++ .../components/TicketPriorityBadge.tsx | 31 +++ .../tickets/components/TicketStatusBadge.tsx | 37 ++++ .../tickets/components/TicketTable.tsx | 73 +++++++ .../tickets/components/TicketWorkflow.tsx | 73 +++++++ apps/web/features/tickets/components/index.ts | 7 + apps/web/features/tickets/index.ts | 6 + apps/web/features/tickets/types.ts | 83 +++++++- apps/web/features/users/api/users.api.ts | 135 +++++++++++++ .../features/users/components/UserActions.tsx | 88 +++++++++ .../features/users/components/UserFilters.tsx | 90 +++++++++ .../features/users/components/UserForm.tsx | 170 ++++++++++++++++ .../users/components/UserPagination.tsx | 81 ++++++++ .../users/components/UserRoleBadge.tsx | 20 ++ .../users/components/UserStatusBadge.tsx | 13 ++ .../features/users/components/UserTable.tsx | 61 ++++++ apps/web/features/users/components/index.ts | 7 + apps/web/features/users/index.ts | 6 + apps/web/features/users/types.ts | 51 ++++- apps/web/providers/AuthProvider.tsx | 2 +- apps/web/proxy.ts | 4 +- apps/web/services/auth.service.ts | 46 +---- apps/web/services/dashboard.service.ts | 103 +--------- apps/web/services/ticket.service.ts | 185 +----------------- apps/web/services/user.service.ts | 1 + apps/web/store/useAuthStore.ts | 2 +- apps/web/types/auth.ts | 31 +-- apps/web/types/dashboard.ts | 23 +-- apps/web/types/ticket.ts | 83 +------- apps/web/types/user.ts | 1 + 94 files changed, 2929 insertions(+), 1391 deletions(-) create mode 100644 apps/api/scripts/seed.py create mode 100644 apps/web/app/(dashboard)/users/[id]/page.tsx create mode 100644 apps/web/app/(dashboard)/users/error.tsx create mode 100644 apps/web/app/(dashboard)/users/loading.tsx create mode 100644 apps/web/app/(dashboard)/users/new/page.tsx create mode 100644 apps/web/app/(dashboard)/users/page.tsx create mode 100644 apps/web/app/api/users/[id]/activate/route.ts create mode 100644 apps/web/app/api/users/[id]/deactivate/route.ts create mode 100644 apps/web/app/api/users/[id]/role/route.ts create mode 100644 apps/web/app/api/users/[id]/route.ts create mode 100644 apps/web/app/api/users/route.ts create mode 100644 apps/web/components/users/UserActions.tsx create mode 100644 apps/web/components/users/UserFilters.tsx create mode 100644 apps/web/components/users/UserForm.tsx create mode 100644 apps/web/components/users/UserPagination.tsx create mode 100644 apps/web/components/users/UserRoleBadge.tsx create mode 100644 apps/web/components/users/UserStatusBadge.tsx create mode 100644 apps/web/components/users/UserTable.tsx create mode 100644 apps/web/components/users/index.ts create mode 100644 apps/web/features/auth/api/auth.api.ts create mode 100644 apps/web/features/auth/components/AuthGuard.tsx create mode 100644 apps/web/features/auth/components/LoginForm.tsx create mode 100644 apps/web/features/auth/components/LogoutButton.tsx create mode 100644 apps/web/features/auth/components/index.ts create mode 100644 apps/web/features/auth/index.ts create mode 100644 apps/web/features/dashboard/api/dashboard.api.ts create mode 100644 apps/web/features/dashboard/components/DashboardCharts.tsx create mode 100644 apps/web/features/dashboard/components/DashboardHeader.tsx create mode 100644 apps/web/features/dashboard/components/DashboardHeaderWrapper.tsx create mode 100644 apps/web/features/dashboard/components/RecentActivity.tsx create mode 100644 apps/web/features/dashboard/components/SummaryCards.tsx create mode 100644 apps/web/features/dashboard/components/index.ts create mode 100644 apps/web/features/dashboard/index.ts create mode 100644 apps/web/features/tickets/api/tickets.api.ts create mode 100644 apps/web/features/tickets/components/TicketAssignment.tsx create mode 100644 apps/web/features/tickets/components/TicketFilters.tsx create mode 100644 apps/web/features/tickets/components/TicketPagination.tsx create mode 100644 apps/web/features/tickets/components/TicketPriorityBadge.tsx create mode 100644 apps/web/features/tickets/components/TicketStatusBadge.tsx create mode 100644 apps/web/features/tickets/components/TicketTable.tsx create mode 100644 apps/web/features/tickets/components/TicketWorkflow.tsx create mode 100644 apps/web/features/tickets/components/index.ts create mode 100644 apps/web/features/tickets/index.ts create mode 100644 apps/web/features/users/api/users.api.ts create mode 100644 apps/web/features/users/components/UserActions.tsx create mode 100644 apps/web/features/users/components/UserFilters.tsx create mode 100644 apps/web/features/users/components/UserForm.tsx create mode 100644 apps/web/features/users/components/UserPagination.tsx create mode 100644 apps/web/features/users/components/UserRoleBadge.tsx create mode 100644 apps/web/features/users/components/UserStatusBadge.tsx create mode 100644 apps/web/features/users/components/UserTable.tsx create mode 100644 apps/web/features/users/components/index.ts create mode 100644 apps/web/features/users/index.ts create mode 100644 apps/web/services/user.service.ts create mode 100644 apps/web/types/user.ts diff --git a/apps/api/core/dependencies/auth.py b/apps/api/core/dependencies/auth.py index 5bbb0b2..2702bfa 100644 --- a/apps/api/core/dependencies/auth.py +++ b/apps/api/core/dependencies/auth.py @@ -49,3 +49,13 @@ async def get_current_active_user( detail="Inactive user" ) return current_user + +async def get_current_admin_user( + current_user: User = Depends(get_current_active_user) +) -> User: + if current_user.role.value != "ADMIN": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin privileges required" + ) + return current_user diff --git a/apps/api/modules/users/router.py b/apps/api/modules/users/router.py index 44acc1b..5af6e89 100644 --- a/apps/api/modules/users/router.py +++ b/apps/api/modules/users/router.py @@ -1,5 +1,122 @@ -from fastapi import APIRouter +import uuid +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from apps.api.infrastructure.db.session import get_db +from apps.api.core.dependencies.auth import get_current_admin_user +from apps.api.modules.users.models import User, Role +from apps.api.modules.users.schemas import ( + UserCreate, UserUpdate, UserRead, UserListResponse +) +from apps.api.modules.users.service import UserService router = APIRouter(tags=["Users"]) -# TODO: Implement users endpoints +@router.get("", response_model=UserListResponse) +async def list_users( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=100), + role: Optional[Role] = None, + user_status: Optional[bool] = Query(None, alias="status"), + search: Optional[str] = None, + db: AsyncSession = Depends(get_db), + current_admin: User = Depends(get_current_admin_user) +): + users, total = await UserService.list_users( + db=db, skip=skip, limit=limit, role=role, status=user_status, search=search + ) + return UserListResponse(data=users, total=total) + +@router.post("", response_model=UserRead, status_code=status.HTTP_201_CREATED) +async def create_user( + user_in: UserCreate, + db: AsyncSession = Depends(get_db), + current_admin: User = Depends(get_current_admin_user) +): + existing_user = await UserService.get_user_by_email(db, email=user_in.email) + if existing_user: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered") + return await UserService.create_user(db=db, user_in=user_in) + +@router.get("/{id}", response_model=UserRead) +async def get_user( + id: uuid.UUID, + db: AsyncSession = Depends(get_db), + current_admin: User = Depends(get_current_admin_user) +): + user = await UserService.get_user(db, id) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + return user + +@router.patch("/{id}", response_model=UserRead) +async def update_user( + id: uuid.UUID, + user_in: UserUpdate, + db: AsyncSession = Depends(get_db), + current_admin: User = Depends(get_current_admin_user) +): + try: + return await UserService.update_user(db, id, user_in, current_admin.id) + except ValueError as e: + if "not found" in str(e).lower(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + +@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_user( + id: uuid.UUID, + db: AsyncSession = Depends(get_db), + current_admin: User = Depends(get_current_admin_user) +): + try: + await UserService.delete_user(db, id, current_admin.id) + except ValueError as e: + if "not found" in str(e).lower(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + +@router.patch("/{id}/activate", response_model=UserRead) +async def activate_user( + id: uuid.UUID, + db: AsyncSession = Depends(get_db), + current_admin: User = Depends(get_current_admin_user) +): + update_schema = UserUpdate(is_active=True) + try: + return await UserService.update_user(db, id, update_schema, current_admin.id) + except ValueError as e: + if "not found" in str(e).lower(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + +@router.patch("/{id}/deactivate", response_model=UserRead) +async def deactivate_user( + id: uuid.UUID, + db: AsyncSession = Depends(get_db), + current_admin: User = Depends(get_current_admin_user) +): + update_schema = UserUpdate(is_active=False) + try: + return await UserService.update_user(db, id, update_schema, current_admin.id) + except ValueError as e: + if "not found" in str(e).lower(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + +@router.patch("/{id}/role", response_model=UserRead) +async def update_user_role( + id: uuid.UUID, + role: Role = Query(..., description="The new role to assign"), + db: AsyncSession = Depends(get_db), + current_admin: User = Depends(get_current_admin_user) +): + update_schema = UserUpdate(role=role) + try: + return await UserService.update_user(db, id, update_schema, current_admin.id) + except ValueError as e: + if "not found" in str(e).lower(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) diff --git a/apps/api/modules/users/schemas.py b/apps/api/modules/users/schemas.py index b9e9f04..d655f77 100644 --- a/apps/api/modules/users/schemas.py +++ b/apps/api/modules/users/schemas.py @@ -27,3 +27,8 @@ class UserUpdate(BaseModel): role: Role | None = None is_active: bool | None = None password: str | None = Field(None, min_length=8) + +class UserListResponse(BaseModel): + data: list[UserRead] + total: int + diff --git a/apps/api/modules/users/service.py b/apps/api/modules/users/service.py index 80060a9..818923a 100644 --- a/apps/api/modules/users/service.py +++ b/apps/api/modules/users/service.py @@ -1 +1,129 @@ -# TODO: Implement users business logic +import uuid +from typing import List, Optional, Tuple +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, or_, func + +from apps.api.modules.users.models import User, Role +from apps.api.modules.users.schemas import UserCreate, UserUpdate +from apps.api.modules.auth.auth_service import AuthService + +class UserService: + @staticmethod + async def get_user(db: AsyncSession, user_id: uuid.UUID) -> Optional[User]: + result = await db.execute(select(User).where(User.id == user_id)) + return result.scalar_one_or_none() + + @staticmethod + async def get_user_by_email(db: AsyncSession, email: str) -> Optional[User]: + result = await db.execute(select(User).where(User.email == email)) + return result.scalar_one_or_none() + + @staticmethod + async def list_users( + db: AsyncSession, + skip: int = 0, + limit: int = 100, + role: Optional[Role] = None, + status: Optional[bool] = None, + search: Optional[str] = None + ) -> Tuple[List[User], int]: + query = select(User) + + if role: + query = query.where(User.role == role) + if status is not None: + query = query.where(User.is_active == status) + if search: + search_pattern = f"%{search}%" + query = query.where( + or_( + User.full_name.ilike(search_pattern), + User.email.ilike(search_pattern) + ) + ) + + # Get total count + count_query = select(func.count()).select_from(query.subquery()) + total_result = await db.execute(count_query) + total = total_result.scalar_one() + + query = query.order_by(User.created_at.desc()).offset(skip).limit(limit) + result = await db.execute(query) + return list(result.scalars().all()), total + + @staticmethod + async def create_user(db: AsyncSession, user_in: UserCreate) -> User: + hashed_password = AuthService.hash_new_password(user_in.password) + new_user = User( + full_name=user_in.full_name, + email=user_in.email, + password_hash=hashed_password, + role=user_in.role, + is_active=user_in.is_active + ) + db.add(new_user) + await db.commit() + await db.refresh(new_user) + return new_user + + @staticmethod + async def update_user( + db: AsyncSession, + user_id: uuid.UUID, + user_in: UserUpdate, + current_user_id: uuid.UUID + ) -> User: + user = await UserService.get_user(db, user_id) + if not user: + raise ValueError("User not found") + + update_data = user_in.model_dump(exclude_unset=True) + + # Safety checks + if "role" in update_data and update_data["role"] != Role.ADMIN and user.id == current_user_id: + raise ValueError("Cannot demote yourself from ADMIN role") + + if "is_active" in update_data and update_data["is_active"] is False and user.id == current_user_id: + raise ValueError("Cannot deactivate yourself") + + if ("role" in update_data and update_data["role"] != Role.ADMIN) or \ + ("is_active" in update_data and update_data["is_active"] is False): + # Check if this is the last admin + if user.role == Role.ADMIN: + admin_count = await UserService._get_active_admin_count(db) + if admin_count <= 1: + raise ValueError("Cannot modify or deactivate the last active ADMIN user") + + if "password" in update_data and update_data["password"]: + update_data["password_hash"] = AuthService.hash_new_password(update_data.pop("password")) + + for key, value in update_data.items(): + setattr(user, key, value) + + await db.commit() + await db.refresh(user) + return user + + @staticmethod + async def delete_user(db: AsyncSession, user_id: uuid.UUID, current_user_id: uuid.UUID) -> bool: + user = await UserService.get_user(db, user_id) + if not user: + raise ValueError("User not found") + + if user.id == current_user_id: + raise ValueError("Cannot delete yourself") + + if user.role == Role.ADMIN: + admin_count = await UserService._get_active_admin_count(db) + if admin_count <= 1: + raise ValueError("Cannot delete the last active ADMIN user") + + await db.delete(user) + await db.commit() + return True + + @staticmethod + async def _get_active_admin_count(db: AsyncSession) -> int: + query = select(func.count()).select_from(User).where(User.role == Role.ADMIN, User.is_active == True) + result = await db.execute(query) + return result.scalar_one() diff --git a/apps/api/scripts/seed.py b/apps/api/scripts/seed.py new file mode 100644 index 0000000..89c4688 --- /dev/null +++ b/apps/api/scripts/seed.py @@ -0,0 +1,52 @@ +import asyncio +import sys +import os + +# Add the project root to the python path so imports work correctly when run directly +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))) + +from sqlalchemy import select +from apps.api.infrastructure.db.session import SessionLocal +from apps.api.modules.users.models import User, Role +from apps.api.modules.auth.auth_service import AuthService + +async def seed_users(): + async with SessionLocal() as session: + # Default Admin User + admin_email = "admin@ticketflow.com" + admin_exists = await session.execute(select(User).where(User.email == admin_email)) + if not admin_exists.scalar_one_or_none(): + admin_user = User( + full_name="System Admin", + email=admin_email, + password_hash=AuthService.hash_new_password("Admin@123456"), + role=Role.ADMIN, + is_active=True + ) + session.add(admin_user) + print(f"Created Admin user: {admin_email}") + else: + print(f"Admin user already exists: {admin_email}") + + # Default Regular User (Mapped to EMPLOYEE role) + regular_email = "user@ticketflow.com" + regular_exists = await session.execute(select(User).where(User.email == regular_email)) + if not regular_exists.scalar_one_or_none(): + regular_user = User( + full_name="Regular User", + email=regular_email, + password_hash=AuthService.hash_new_password("User@123456"), + role=Role.EMPLOYEE, + is_active=True + ) + session.add(regular_user) + print(f"Created Regular user: {regular_email}") + else: + print(f"Regular user already exists: {regular_email}") + + await session.commit() + print("Database seed completed successfully.") + +if __name__ == "__main__": + print("Starting database seed...") + asyncio.run(seed_users()) diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index 4e4c7f9..b830186 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -1,4 +1,4 @@ -import { LoginForm } from '@/components/auth/LoginForm'; +import { LoginForm } from '@/features/auth'; export const metadata = { title: 'Login - TicketFlow', diff --git a/apps/web/app/(dashboard)/layout.tsx b/apps/web/app/(dashboard)/layout.tsx index f3da4e2..e00892b 100644 --- a/apps/web/app/(dashboard)/layout.tsx +++ b/apps/web/app/(dashboard)/layout.tsx @@ -1,4 +1,4 @@ -import { AuthGuard } from '@/components/auth/AuthGuard'; +import { AuthGuard } from '@/features/auth'; export default function DashboardLayout({ children, diff --git a/apps/web/app/(dashboard)/page.tsx b/apps/web/app/(dashboard)/page.tsx index f716d5d..85b35ed 100644 --- a/apps/web/app/(dashboard)/page.tsx +++ b/apps/web/app/(dashboard)/page.tsx @@ -1,10 +1,10 @@ -import { dashboardService } from '@/services/dashboard.service'; +import { dashboardService } from '@/features/dashboard'; import { DashboardHeaderWrapper, SummaryCards, DashboardCharts, RecentActivity, -} from '@/components/dashboard'; +} from '@/features/dashboard'; export const metadata = { title: 'Dashboard - TicketFlow', diff --git a/apps/web/app/(dashboard)/tickets/[id]/page.tsx b/apps/web/app/(dashboard)/tickets/[id]/page.tsx index 925b1d0..269f97a 100644 --- a/apps/web/app/(dashboard)/tickets/[id]/page.tsx +++ b/apps/web/app/(dashboard)/tickets/[id]/page.tsx @@ -1,5 +1,5 @@ -import { ticketService } from '@/services/ticket.service'; -import { TicketStatusBadge, TicketPriorityBadge, TicketAssignment, TicketWorkflow } from '@/components/tickets'; +import { ticketService } from '@/features/tickets'; +import { TicketStatusBadge, TicketPriorityBadge, TicketAssignment, TicketWorkflow } from '@/features/tickets'; import { cookies } from 'next/headers'; import Link from 'next/link'; diff --git a/apps/web/app/(dashboard)/tickets/page.tsx b/apps/web/app/(dashboard)/tickets/page.tsx index 74df610..df95bde 100644 --- a/apps/web/app/(dashboard)/tickets/page.tsx +++ b/apps/web/app/(dashboard)/tickets/page.tsx @@ -1,5 +1,5 @@ -import { ticketService } from '@/services/ticket.service'; -import { TicketTable, TicketFilters, TicketPagination } from '@/components/tickets'; +import { ticketService } from '@/features/tickets'; +import { TicketTable, TicketFilters, TicketPagination } from '@/features/tickets'; import { cookies } from 'next/headers'; export const metadata = { diff --git a/apps/web/app/(dashboard)/users/[id]/page.tsx b/apps/web/app/(dashboard)/users/[id]/page.tsx new file mode 100644 index 0000000..6e1cb92 --- /dev/null +++ b/apps/web/app/(dashboard)/users/[id]/page.tsx @@ -0,0 +1,44 @@ +import { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import { UserService, UserForm, UserActions } from '@/features/users'; + +export const metadata: Metadata = { + title: 'Edit User - TicketFlow', +}; + +export default async function EditUserPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + + try { + const user = await UserService.getUser(id); + + return ( +
+
+

+ Manage User: {user.full_name} +

+

+ Update user details, change roles, or modify account status. +

+
+ +
+
+ +
+
+ +
+
+
+ ); + } catch (error) { + console.error('Error fetching user for edit:', error); + notFound(); + } +} diff --git a/apps/web/app/(dashboard)/users/error.tsx b/apps/web/app/(dashboard)/users/error.tsx new file mode 100644 index 0000000..6c5b9a9 --- /dev/null +++ b/apps/web/app/(dashboard)/users/error.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { useEffect } from 'react'; + +export default function UsersError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error('Users module error:', error); + }, [error]); + + return ( +
+
+
+ + + +
+

Failed to load users

+

+ {error.message || 'An unexpected error occurred while loading the user list.'} +

+ +
+
+ ); +} diff --git a/apps/web/app/(dashboard)/users/loading.tsx b/apps/web/app/(dashboard)/users/loading.tsx new file mode 100644 index 0000000..2d0984a --- /dev/null +++ b/apps/web/app/(dashboard)/users/loading.tsx @@ -0,0 +1,25 @@ +export default function UsersLoading() { + return ( +
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+ ); +} diff --git a/apps/web/app/(dashboard)/users/new/page.tsx b/apps/web/app/(dashboard)/users/new/page.tsx new file mode 100644 index 0000000..cde3ff9 --- /dev/null +++ b/apps/web/app/(dashboard)/users/new/page.tsx @@ -0,0 +1,23 @@ +import { Metadata } from 'next'; +import { UserForm } from '@/features/users'; + +export const metadata: Metadata = { + title: 'Create User - TicketFlow', +}; + +export default function NewUserPage() { + return ( +
+
+

+ Create New User +

+

+ Add a new user to the system and assign their role. +

+
+ + +
+ ); +} diff --git a/apps/web/app/(dashboard)/users/page.tsx b/apps/web/app/(dashboard)/users/page.tsx new file mode 100644 index 0000000..e40fbe7 --- /dev/null +++ b/apps/web/app/(dashboard)/users/page.tsx @@ -0,0 +1,74 @@ +import { Metadata } from 'next'; +import Link from 'next/link'; +import { UserService, UserRole, UserTable, UserFilters, UserPagination } from '@/features/users'; + +export const metadata: Metadata = { + title: 'User Management - TicketFlow', + description: 'Manage users and roles', +}; + +// Must be async Server Component +export default async function UsersPage({ + searchParams, +}: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}) { + const resolvedParams = await searchParams; + + // Extract pagination and filters safely + const skip = typeof resolvedParams.skip === 'string' ? parseInt(resolvedParams.skip, 10) : 0; + const limit = typeof resolvedParams.limit === 'string' ? parseInt(resolvedParams.limit, 10) : 10; + + const role = typeof resolvedParams.role === 'string' ? resolvedParams.role as UserRole : undefined; + + let status: boolean | undefined = undefined; + if (typeof resolvedParams.status === 'string') { + status = resolvedParams.status === 'true'; + } + + const search = typeof resolvedParams.search === 'string' ? resolvedParams.search : undefined; + + // Fetch data on the server + const response = await UserService.getUsers({ + skip, + limit, + role, + status, + search, + }); + + return ( +
+
+
+

+ User Management +

+

+ A list of all users including their names, email addresses, roles, and status. +

+
+
+ + Add user + +
+
+ + + + + + +
+ ); +} diff --git a/apps/web/app/api/users/[id]/activate/route.ts b/apps/web/app/api/users/[id]/activate/route.ts new file mode 100644 index 0000000..f053e4d --- /dev/null +++ b/apps/web/app/api/users/[id]/activate/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; + +const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1'; +const AUTH_COOKIE_NAME = 'auth_token'; + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const cookieStore = await cookies(); + const tokenCookie = cookieStore.get(AUTH_COOKIE_NAME); + + if (!tokenCookie || !tokenCookie.value) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const backendRes = await fetch(`${BACKEND_URL}/users/${id}/activate`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${tokenCookie.value}` + } + }); + + const data = await backendRes.json(); + return NextResponse.json(data, { status: backendRes.status }); + } catch (error) { + console.error(`BFF Error (PATCH /users/[id]/activate):`, error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} diff --git a/apps/web/app/api/users/[id]/deactivate/route.ts b/apps/web/app/api/users/[id]/deactivate/route.ts new file mode 100644 index 0000000..a2b18e5 --- /dev/null +++ b/apps/web/app/api/users/[id]/deactivate/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; + +const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1'; +const AUTH_COOKIE_NAME = 'auth_token'; + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const cookieStore = await cookies(); + const tokenCookie = cookieStore.get(AUTH_COOKIE_NAME); + + if (!tokenCookie || !tokenCookie.value) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const backendRes = await fetch(`${BACKEND_URL}/users/${id}/deactivate`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${tokenCookie.value}` + } + }); + + const data = await backendRes.json(); + return NextResponse.json(data, { status: backendRes.status }); + } catch (error) { + console.error(`BFF Error (PATCH /users/[id]/deactivate):`, error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} diff --git a/apps/web/app/api/users/[id]/role/route.ts b/apps/web/app/api/users/[id]/role/route.ts new file mode 100644 index 0000000..93b2998 --- /dev/null +++ b/apps/web/app/api/users/[id]/role/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; + +const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1'; +const AUTH_COOKIE_NAME = 'auth_token'; + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const cookieStore = await cookies(); + const tokenCookie = cookieStore.get(AUTH_COOKIE_NAME); + + if (!tokenCookie || !tokenCookie.value) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const url = new URL(request.url); + const role = url.searchParams.get('role'); + + if (!role) { + return NextResponse.json({ error: 'Role query parameter is required' }, { status: 400 }); + } + + const backendRes = await fetch(`${BACKEND_URL}/users/${id}/role?role=${role}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${tokenCookie.value}` + } + }); + + const data = await backendRes.json(); + return NextResponse.json(data, { status: backendRes.status }); + } catch (error) { + console.error(`BFF Error (PATCH /users/[id]/role):`, error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} diff --git a/apps/web/app/api/users/[id]/route.ts b/apps/web/app/api/users/[id]/route.ts new file mode 100644 index 0000000..dbdd7b2 --- /dev/null +++ b/apps/web/app/api/users/[id]/route.ts @@ -0,0 +1,98 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; + +const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1'; +const AUTH_COOKIE_NAME = 'auth_token'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const cookieStore = await cookies(); + const tokenCookie = cookieStore.get(AUTH_COOKIE_NAME); + + if (!tokenCookie || !tokenCookie.value) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const backendRes = await fetch(`${BACKEND_URL}/users/${id}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${tokenCookie.value}` + }, + cache: 'no-store' + }); + + const data = await backendRes.json(); + return NextResponse.json(data, { status: backendRes.status }); + } catch (error) { + console.error(`BFF Error (GET /users/[id]):`, error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const cookieStore = await cookies(); + const tokenCookie = cookieStore.get(AUTH_COOKIE_NAME); + + if (!tokenCookie || !tokenCookie.value) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const body = await request.json(); + const backendRes = await fetch(`${BACKEND_URL}/users/${id}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${tokenCookie.value}` + }, + body: JSON.stringify(body) + }); + + const data = await backendRes.json(); + return NextResponse.json(data, { status: backendRes.status }); + } catch (error) { + console.error(`BFF Error (PATCH /users/[id]):`, error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const cookieStore = await cookies(); + const tokenCookie = cookieStore.get(AUTH_COOKIE_NAME); + + if (!tokenCookie || !tokenCookie.value) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const backendRes = await fetch(`${BACKEND_URL}/users/${id}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${tokenCookie.value}` + } + }); + + if (backendRes.status === 204) { + return new NextResponse(null, { status: 204 }); + } + + const data = await backendRes.json(); + return NextResponse.json(data, { status: backendRes.status }); + } catch (error) { + console.error(`BFF Error (DELETE /users/[id]):`, error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} diff --git a/apps/web/app/api/users/route.ts b/apps/web/app/api/users/route.ts new file mode 100644 index 0000000..13c48a0 --- /dev/null +++ b/apps/web/app/api/users/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; + +const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1'; +const AUTH_COOKIE_NAME = 'auth_token'; + +export async function GET(request: NextRequest) { + try { + const cookieStore = await cookies(); + const tokenCookie = cookieStore.get(AUTH_COOKIE_NAME); + + if (!tokenCookie || !tokenCookie.value) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const url = new URL(request.url); + const searchParams = url.searchParams.toString(); + const backendUrl = searchParams + ? `${BACKEND_URL}/users?${searchParams}` + : `${BACKEND_URL}/users`; + + const backendRes = await fetch(backendUrl, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${tokenCookie.value}` + }, + cache: 'no-store' + }); + + const data = await backendRes.json(); + return NextResponse.json(data, { status: backendRes.status }); + } catch (error) { + console.error('BFF Error (GET /users):', error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const cookieStore = await cookies(); + const tokenCookie = cookieStore.get(AUTH_COOKIE_NAME); + + if (!tokenCookie || !tokenCookie.value) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const body = await request.json(); + const backendRes = await fetch(`${BACKEND_URL}/users`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${tokenCookie.value}` + }, + body: JSON.stringify(body) + }); + + const data = await backendRes.json(); + return NextResponse.json(data, { status: backendRes.status }); + } catch (error) { + console.error('BFF Error (POST /users):', error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} diff --git a/apps/web/components/auth/AuthGuard.tsx b/apps/web/components/auth/AuthGuard.tsx index 8ee134c..13a8c86 100644 --- a/apps/web/components/auth/AuthGuard.tsx +++ b/apps/web/components/auth/AuthGuard.tsx @@ -1,24 +1 @@ -'use client'; - -import * as React from 'react'; -import { useAuthStore } from '@/store/useAuthStore'; - -export function AuthGuard({ children }: { children: React.ReactNode }) { - const { isAuthenticated, isLoading } = useAuthStore(); - - if (isLoading) { - return ( -
-

Loading application...

-
- ); - } - - // The primary route protection is handled by proxy.ts, so this component only needs - // to ensure we don't render authenticated UI until the client session state is hydrated. - if (!isAuthenticated) { - return null; - } - - return <>{children}; -} +// Deprecated - moved to features/auth/components/AuthGuard.tsx diff --git a/apps/web/components/auth/LoginForm.tsx b/apps/web/components/auth/LoginForm.tsx index 27a09ff..ac90e15 100644 --- a/apps/web/components/auth/LoginForm.tsx +++ b/apps/web/components/auth/LoginForm.tsx @@ -1,105 +1 @@ -'use client'; - -import * as React from 'react'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import * as z from 'zod'; -import { useRouter } from 'next/navigation'; -import { authService } from '@/services/auth.service'; -import { useAuthStore } from '@/store/useAuthStore'; -import { AUTH_ROUTES } from '@/lib/auth/constants'; - -const loginSchema = z.object({ - email: z.string().email({ message: 'Invalid email address' }), - password: z.string().min(1, { message: 'Password is required' }), -}); - -type LoginFormData = z.infer; - -export function LoginForm() { - const router = useRouter(); - const setUser = useAuthStore((state) => state.setUser); - const [error, setError] = React.useState(null); - - const { - register, - handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ - resolver: zodResolver(loginSchema), - defaultValues: { - email: '', - password: '', - }, - }); - - const onSubmit = async (data: LoginFormData) => { - try { - setError(null); - // Backend expects 'password_string' for password inside LoginCredentials if it matches my type, - // wait, the API type is password_string but standard OpenAPI might expect password. - // Let's check apps/web/types/auth.ts - I defined password_string based on an earlier snippet. - // But Pydantic LoginRequest actually has `password`. Let's stick to `password` and fix if needed. - // Wait, let's use `password_string` since that's what I defined in `types/auth.ts`. Actually, let's map it. - - const res = await authService.login({ - email: data.email, - password: data.password, // FastAPI expects `password` usually. - } as any); - - setUser(res.user); - router.push(AUTH_ROUTES.DASHBOARD); - } catch (err: any) { - setError(err.message || 'Failed to login'); - } - }; - - return ( -
- {error && ( -
- {error} -
- )} - -
- - - {errors.email && ( -

{errors.email.message}

- )} -
- -
- - - {errors.password && ( -

{errors.password.message}

- )} -
- - -
- ); -} +// Deprecated - moved to features/auth/components/LoginForm.tsx diff --git a/apps/web/components/auth/LogoutButton.tsx b/apps/web/components/auth/LogoutButton.tsx index be34430..978563a 100644 --- a/apps/web/components/auth/LogoutButton.tsx +++ b/apps/web/components/auth/LogoutButton.tsx @@ -1,33 +1 @@ -'use client'; - -import * as React from 'react'; -import { useRouter } from 'next/navigation'; -import { authService } from '@/services/auth.service'; -import { useAuthStore } from '@/store/useAuthStore'; -import { AUTH_ROUTES } from '@/lib/auth/constants'; - -export function LogoutButton() { - const router = useRouter(); - const clearAuth = useAuthStore((state) => state.clearAuth); - - const handleLogout = async () => { - try { - await authService.logout(); - } catch (error) { - console.error('Logout failed', error); - } finally { - clearAuth(); - router.refresh(); - router.push(AUTH_ROUTES.LOGIN); - } - }; - - return ( - - ); -} +// Deprecated - moved to features/auth/components/LogoutButton.tsx diff --git a/apps/web/components/dashboard/DashboardCharts.tsx b/apps/web/components/dashboard/DashboardCharts.tsx index ba053d0..0b9e62b 100644 --- a/apps/web/components/dashboard/DashboardCharts.tsx +++ b/apps/web/components/dashboard/DashboardCharts.tsx @@ -1,87 +1 @@ -interface ChartData { - label: string; - value: number; - colorClass?: string; -} - -interface DashboardChartsProps { - ticketsData?: ChartData[]; - activityData?: ChartData[]; - isLoading?: boolean; -} - -export function DashboardCharts({ ticketsData, activityData, isLoading }: DashboardChartsProps) { - if (isLoading) { - return ( -
-
-
-
- ); - } - - // Fallback mock data if none provided (for presentation purposes) - const defaultTicketsData = ticketsData || [ - { label: 'Open', value: 342, colorClass: 'bg-blue-500' }, - { label: 'In Progress', value: 120, colorClass: 'bg-yellow-500' }, - { label: 'Resolved', value: 890, colorClass: 'bg-green-500' }, - { label: 'Closed', value: 245, colorClass: 'bg-gray-500' }, - ]; - - const defaultActivityData = activityData || [ - { label: 'Mon', value: 45, colorClass: 'bg-indigo-500' }, - { label: 'Tue', value: 52, colorClass: 'bg-indigo-500' }, - { label: 'Wed', value: 38, colorClass: 'bg-indigo-500' }, - { label: 'Thu', value: 65, colorClass: 'bg-indigo-500' }, - { label: 'Fri', value: 48, colorClass: 'bg-indigo-500' }, - { label: 'Sat', value: 12, colorClass: 'bg-indigo-400' }, - { label: 'Sun', value: 8, colorClass: 'bg-indigo-400' }, - ]; - - const maxTicketValue = Math.max(...defaultTicketsData.map(d => d.value), 1); - const maxActivityValue = Math.max(...defaultActivityData.map(d => d.value), 1); - - return ( -
- - {/* Ticket Status Distribution - Horizontal Bar Chart */} -
-

Ticket Status

-
- {defaultTicketsData.map((item, i) => ( -
-
- {item.label} - {item.value} -
-
-
-
-
- ))} -
-
- - {/* Activity Trends - Vertical Bar Chart */} -
-

Activity Trends

-
- {defaultActivityData.map((item, i) => ( -
-
- {item.label} -
- ))} -
-
- -
- ); -} +// Deprecated diff --git a/apps/web/components/dashboard/DashboardHeader.tsx b/apps/web/components/dashboard/DashboardHeader.tsx index 2d9f852..0b9e62b 100644 --- a/apps/web/components/dashboard/DashboardHeader.tsx +++ b/apps/web/components/dashboard/DashboardHeader.tsx @@ -1,18 +1 @@ -import { User } from '@/types/auth'; - -interface DashboardHeaderProps { - user: User | null; -} - -export function DashboardHeader({ user }: DashboardHeaderProps) { - const greeting = user?.full_name ? `Welcome back, ${user.full_name}` : 'Welcome back'; - - return ( -
-

{greeting}

-

- Here is what's happening with your tickets today. -

-
- ); -} +// Deprecated diff --git a/apps/web/components/dashboard/DashboardHeaderWrapper.tsx b/apps/web/components/dashboard/DashboardHeaderWrapper.tsx index bf52f2b..0b9e62b 100644 --- a/apps/web/components/dashboard/DashboardHeaderWrapper.tsx +++ b/apps/web/components/dashboard/DashboardHeaderWrapper.tsx @@ -1,9 +1 @@ -'use client'; - -import { useAuthStore } from '@/store/useAuthStore'; -import { DashboardHeader } from './DashboardHeader'; - -export function DashboardHeaderWrapper() { - const user = useAuthStore((state) => state.user); - return ; -} +// Deprecated diff --git a/apps/web/components/dashboard/RecentActivity.tsx b/apps/web/components/dashboard/RecentActivity.tsx index 8e365df..0b9e62b 100644 --- a/apps/web/components/dashboard/RecentActivity.tsx +++ b/apps/web/components/dashboard/RecentActivity.tsx @@ -1,87 +1 @@ -import { ActivityItem } from '@/types/dashboard'; - -interface RecentActivityProps { - activities?: ActivityItem[]; - isLoading?: boolean; -} - -export function RecentActivity({ activities, isLoading }: RecentActivityProps) { - if (isLoading) { - return ( -
-
-
-
-
- {[1, 2, 3].map((i) => ( -
-
-
-
-
-
-
-
-
- ))} -
-
- ); - } - - if (!activities || activities.length === 0) { - return ( -
-

Recent Activity

-

No recent activity found.

-
- ); - } - - return ( -
-
-

Recent Activity

-
-
    - {activities.map((activity) => ( -
  • -
    -
    - - {activity.user.charAt(0).toUpperCase()} - -
    -

    - {activity.action} on {activity.entity} -

    -

    by {activity.user}

    -
    -
    -
    - - {activity.status && ( - - {activity.status} - - )} -
    -
    -
  • - ))} -
-
- ); -} +// Deprecated diff --git a/apps/web/components/dashboard/SummaryCards.tsx b/apps/web/components/dashboard/SummaryCards.tsx index 2b8ea7f..0b9e62b 100644 --- a/apps/web/components/dashboard/SummaryCards.tsx +++ b/apps/web/components/dashboard/SummaryCards.tsx @@ -1,59 +1 @@ -import { DashboardMetric } from '@/types/dashboard'; - -interface SummaryCardsProps { - metrics?: DashboardMetric[]; - isLoading?: boolean; -} - -export function SummaryCards({ metrics, isLoading }: SummaryCardsProps) { - if (isLoading) { - return ( -
- {[1, 2, 3, 4].map((i) => ( -
-
-
-
-
- ))} -
- ); - } - - if (!metrics || metrics.length === 0) { - return ( -
-

No summary metrics available.

-
- ); - } - - return ( -
- {metrics.map((metric, index) => ( -
-

{metric.title}

-
-

{metric.value}

- {metric.trend && ( - - {metric.trend} - - )} -
- {metric.description && ( -

{metric.description}

- )} -
- ))} -
- ); -} +// Deprecated diff --git a/apps/web/components/dashboard/index.ts b/apps/web/components/dashboard/index.ts index 168b4f1..0b9e62b 100644 --- a/apps/web/components/dashboard/index.ts +++ b/apps/web/components/dashboard/index.ts @@ -1,5 +1 @@ -export { DashboardHeader } from './DashboardHeader'; -export { DashboardHeaderWrapper } from './DashboardHeaderWrapper'; -export { SummaryCards } from './SummaryCards'; -export { DashboardCharts } from './DashboardCharts'; -export { RecentActivity } from './RecentActivity'; +// Deprecated diff --git a/apps/web/components/tickets/TicketAssignment.tsx b/apps/web/components/tickets/TicketAssignment.tsx index 6f6b915..0b9e62b 100644 --- a/apps/web/components/tickets/TicketAssignment.tsx +++ b/apps/web/components/tickets/TicketAssignment.tsx @@ -1,89 +1 @@ -'use client'; - -import { useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { ticketService } from '@/services/ticket.service'; - -interface Props { - ticketId: string; - currentAssignee?: string; -} - -export function TicketAssignment({ ticketId, currentAssignee }: Props) { - const router = useRouter(); - const [isAssigning, setIsAssigning] = useState(false); - const [technicianId, setTechnicianId] = useState(''); - const [error, setError] = useState(null); - - const handleAssign = async () => { - if (!technicianId) return; - setIsAssigning(true); - setError(null); - try { - await ticketService.assignTicket(ticketId, technicianId); - setTechnicianId(''); - router.refresh(); - } catch (err: any) { - setError(err.message || 'Failed to assign ticket'); - } finally { - setIsAssigning(false); - } - }; - - const handleUnassign = async () => { - setIsAssigning(true); - setError(null); - try { - await ticketService.unassignTicket(ticketId); - router.refresh(); - } catch (err: any) { - setError(err.message || 'Failed to unassign ticket'); - } finally { - setIsAssigning(false); - } - }; - - return ( -
-

Assignment

- - {error && ( -
- {error} -
- )} - - {currentAssignee ? ( -
-

- Assigned to: {currentAssignee} -

- -
- ) : ( -
- setTechnicianId(e.target.value)} - className="block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6" - /> - -
- )} -
- ); -} +// Deprecated diff --git a/apps/web/components/tickets/TicketFilters.tsx b/apps/web/components/tickets/TicketFilters.tsx index 17ecd4b..0b9e62b 100644 --- a/apps/web/components/tickets/TicketFilters.tsx +++ b/apps/web/components/tickets/TicketFilters.tsx @@ -1,83 +1 @@ -'use client'; - -import { useRouter, useSearchParams, usePathname } from 'next/navigation'; -import { useCallback, useState } from 'react'; -import { TicketStatus, TicketPriority } from '@/types/ticket'; - -export function TicketFilters() { - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); - - const [search, setSearch] = useState(searchParams.get('search') || ''); - - const createQueryString = useCallback( - (name: string, value: string) => { - const params = new URLSearchParams(searchParams.toString()); - if (value) { - params.set(name, value); - } else { - params.delete(name); - } - params.delete('page'); // Reset page on filter change - return params.toString(); - }, - [searchParams] - ); - - const handleFilterChange = (name: string, value: string) => { - router.push(pathname + '?' + createQueryString(name, value)); - }; - - const handleSearchSubmit = (e: React.FormEvent) => { - e.preventDefault(); - handleFilterChange('search', search); - }; - - return ( -
-
- -
-
- -
- setSearch(e.target.value)} - className="block w-full rounded-md border-0 py-1.5 pl-10 pr-3 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6" - /> -
-
- -
- - - -
-
- ); -} +// Deprecated diff --git a/apps/web/components/tickets/TicketPagination.tsx b/apps/web/components/tickets/TicketPagination.tsx index 165ab60..0b9e62b 100644 --- a/apps/web/components/tickets/TicketPagination.tsx +++ b/apps/web/components/tickets/TicketPagination.tsx @@ -1,88 +1 @@ -'use client'; - -import { useRouter, useSearchParams, usePathname } from 'next/navigation'; -import { useCallback } from 'react'; - -interface Props { - page: number; - limit: number; - total?: number; - pages?: number; - hasMore?: boolean; -} - -export function TicketPagination({ page, limit, total, pages, hasMore }: Props) { - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); - - const createQueryString = useCallback( - (name: string, value: string) => { - const params = new URLSearchParams(searchParams.toString()); - params.set(name, value); - return params.toString(); - }, - [searchParams] - ); - - const handlePageChange = (newPage: number) => { - router.push(pathname + '?' + createQueryString('page', newPage.toString())); - }; - - const isPreviousDisabled = page <= 1; - const isNextDisabled = pages ? page >= pages : !hasMore; - - return ( -
-
- - -
-
-
-

- Showing page {page} - {pages && of {pages}} - {total && ({total} total tickets)} -

-
-
- -
-
-
- ); -} +// Deprecated diff --git a/apps/web/components/tickets/TicketPriorityBadge.tsx b/apps/web/components/tickets/TicketPriorityBadge.tsx index 00ccb9d..0b9e62b 100644 --- a/apps/web/components/tickets/TicketPriorityBadge.tsx +++ b/apps/web/components/tickets/TicketPriorityBadge.tsx @@ -1,31 +1 @@ -import { TicketPriority } from '@/types/ticket'; - -interface Props { - priority: TicketPriority; - className?: string; -} - -export function TicketPriorityBadge({ priority, className = '' }: Props) { - const getStyles = () => { - switch (priority) { - case TicketPriority.LOW: - return 'bg-gray-100 text-gray-800 border-gray-200'; - case TicketPriority.MEDIUM: - return 'bg-blue-100 text-blue-800 border-blue-200'; - case TicketPriority.HIGH: - return 'bg-orange-100 text-orange-800 border-orange-200'; - case TicketPriority.URGENT: - return 'bg-red-100 text-red-800 border-red-200'; - default: - return 'bg-gray-100 text-gray-800 border-gray-200'; - } - }; - - return ( - - {priority} - - ); -} +// Deprecated diff --git a/apps/web/components/tickets/TicketStatusBadge.tsx b/apps/web/components/tickets/TicketStatusBadge.tsx index 37131a3..0b9e62b 100644 --- a/apps/web/components/tickets/TicketStatusBadge.tsx +++ b/apps/web/components/tickets/TicketStatusBadge.tsx @@ -1,37 +1 @@ -import { TicketStatus } from '@/types/ticket'; - -interface Props { - status: TicketStatus; - className?: string; -} - -export function TicketStatusBadge({ status, className = '' }: Props) { - const getStyles = () => { - switch (status) { - case TicketStatus.OPEN: - return 'bg-blue-100 text-blue-800 border-blue-200'; - case TicketStatus.IN_PROGRESS: - return 'bg-yellow-100 text-yellow-800 border-yellow-200'; - case TicketStatus.PENDING: - return 'bg-orange-100 text-orange-800 border-orange-200'; - case TicketStatus.RESOLVED: - return 'bg-green-100 text-green-800 border-green-200'; - case TicketStatus.CLOSED: - return 'bg-gray-100 text-gray-800 border-gray-200'; - default: - return 'bg-gray-100 text-gray-800 border-gray-200'; - } - }; - - const getLabel = () => { - return status.replace('_', ' '); - }; - - return ( - - {getLabel()} - - ); -} +// Deprecated diff --git a/apps/web/components/tickets/TicketTable.tsx b/apps/web/components/tickets/TicketTable.tsx index 4a67c62..0b9e62b 100644 --- a/apps/web/components/tickets/TicketTable.tsx +++ b/apps/web/components/tickets/TicketTable.tsx @@ -1,73 +1 @@ -import { Ticket } from '@/types/ticket'; -import { TicketStatusBadge } from './TicketStatusBadge'; -import { TicketPriorityBadge } from './TicketPriorityBadge'; -import Link from 'next/link'; - -interface Props { - tickets: Ticket[]; -} - -export function TicketTable({ tickets }: Props) { - return ( -
- - - - - - - - - - - - - {tickets.length === 0 ? ( - - - - ) : ( - tickets.map((ticket) => ( - - - - - - - - - )) - )} - -
- Title - - Status - - Priority - - Category - - Created - - Actions -
- No tickets found. -
- {ticket.title} - - - - - - {ticket.category || '-'} - - {new Date(ticket.created_at).toLocaleDateString()} - - - View, {ticket.title} - -
-
- ); -} +// Deprecated diff --git a/apps/web/components/tickets/TicketWorkflow.tsx b/apps/web/components/tickets/TicketWorkflow.tsx index f658e7b..0b9e62b 100644 --- a/apps/web/components/tickets/TicketWorkflow.tsx +++ b/apps/web/components/tickets/TicketWorkflow.tsx @@ -1,73 +1 @@ -'use client'; - -import { useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { ticketService } from '@/services/ticket.service'; -import { TicketStatus } from '@/types/ticket'; - -interface Props { - ticketId: string; - currentStatus: TicketStatus; -} - -export function TicketWorkflow({ ticketId, currentStatus }: Props) { - const router = useRouter(); - const [isUpdating, setIsUpdating] = useState(false); - const [error, setError] = useState(null); - - const getAvailableTransitions = () => { - // This replicates the backend ALLOWED_TRANSITIONS logic temporarily for UI purposes - const transitions: Record = { - [TicketStatus.OPEN]: [TicketStatus.IN_PROGRESS], - [TicketStatus.IN_PROGRESS]: [TicketStatus.PENDING, TicketStatus.RESOLVED, TicketStatus.OPEN], - [TicketStatus.PENDING]: [TicketStatus.IN_PROGRESS, TicketStatus.RESOLVED], - [TicketStatus.RESOLVED]: [TicketStatus.CLOSED, TicketStatus.IN_PROGRESS], - [TicketStatus.CLOSED]: [], - }; - return transitions[currentStatus] || []; - }; - - const availableTransitions = getAvailableTransitions(); - - const handleStatusChange = async (newStatus: TicketStatus) => { - setIsUpdating(true); - setError(null); - try { - await ticketService.updateTicketStatus(ticketId, newStatus); - router.refresh(); - } catch (err: any) { - setError(err.message || 'Failed to update status'); - } finally { - setIsUpdating(false); - } - }; - - if (availableTransitions.length === 0) { - return null; - } - - return ( -
-

Workflow Actions

- - {error && ( -
- {error} -
- )} - -
- {availableTransitions.map((status) => ( - - ))} -
-
- ); -} +// Deprecated diff --git a/apps/web/components/tickets/index.ts b/apps/web/components/tickets/index.ts index 3257956..0b9e62b 100644 --- a/apps/web/components/tickets/index.ts +++ b/apps/web/components/tickets/index.ts @@ -1,7 +1 @@ -export * from './TicketStatusBadge'; -export * from './TicketPriorityBadge'; -export * from './TicketTable'; -export * from './TicketFilters'; -export * from './TicketPagination'; -export * from './TicketAssignment'; -export * from './TicketWorkflow'; +// Deprecated diff --git a/apps/web/components/users/UserActions.tsx b/apps/web/components/users/UserActions.tsx new file mode 100644 index 0000000..0b9e62b --- /dev/null +++ b/apps/web/components/users/UserActions.tsx @@ -0,0 +1 @@ +// Deprecated diff --git a/apps/web/components/users/UserFilters.tsx b/apps/web/components/users/UserFilters.tsx new file mode 100644 index 0000000..0b9e62b --- /dev/null +++ b/apps/web/components/users/UserFilters.tsx @@ -0,0 +1 @@ +// Deprecated diff --git a/apps/web/components/users/UserForm.tsx b/apps/web/components/users/UserForm.tsx new file mode 100644 index 0000000..0b9e62b --- /dev/null +++ b/apps/web/components/users/UserForm.tsx @@ -0,0 +1 @@ +// Deprecated diff --git a/apps/web/components/users/UserPagination.tsx b/apps/web/components/users/UserPagination.tsx new file mode 100644 index 0000000..0b9e62b --- /dev/null +++ b/apps/web/components/users/UserPagination.tsx @@ -0,0 +1 @@ +// Deprecated diff --git a/apps/web/components/users/UserRoleBadge.tsx b/apps/web/components/users/UserRoleBadge.tsx new file mode 100644 index 0000000..0b9e62b --- /dev/null +++ b/apps/web/components/users/UserRoleBadge.tsx @@ -0,0 +1 @@ +// Deprecated diff --git a/apps/web/components/users/UserStatusBadge.tsx b/apps/web/components/users/UserStatusBadge.tsx new file mode 100644 index 0000000..0b9e62b --- /dev/null +++ b/apps/web/components/users/UserStatusBadge.tsx @@ -0,0 +1 @@ +// Deprecated diff --git a/apps/web/components/users/UserTable.tsx b/apps/web/components/users/UserTable.tsx new file mode 100644 index 0000000..0b9e62b --- /dev/null +++ b/apps/web/components/users/UserTable.tsx @@ -0,0 +1 @@ +// Deprecated diff --git a/apps/web/components/users/index.ts b/apps/web/components/users/index.ts new file mode 100644 index 0000000..0b9e62b --- /dev/null +++ b/apps/web/components/users/index.ts @@ -0,0 +1 @@ +// Deprecated diff --git a/apps/web/features/auth/api/auth.api.ts b/apps/web/features/auth/api/auth.api.ts new file mode 100644 index 0000000..7689c27 --- /dev/null +++ b/apps/web/features/auth/api/auth.api.ts @@ -0,0 +1,45 @@ +import { LoginCredentials, User } from '../types'; +import { API_ROUTES } from '@/lib/auth/constants'; + +class AuthService { + async login(credentials: LoginCredentials): Promise<{ user: User }> { + const response = await fetch(API_ROUTES.LOGIN, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(credentials), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || 'Login failed'); + } + + return response.json(); + } + + async logout(): Promise { + const response = await fetch(API_ROUTES.LOGOUT, { + method: 'POST', + }); + + if (!response.ok) { + throw new Error('Logout failed'); + } + } + + async getMe(): Promise<{ user: User }> { + const response = await fetch(API_ROUTES.ME, { + method: 'GET', + }); + + if (!response.ok) { + throw new Error('Failed to retrieve user session'); + } + + return response.json(); + } +} + +export const authService = new AuthService(); diff --git a/apps/web/features/auth/components/AuthGuard.tsx b/apps/web/features/auth/components/AuthGuard.tsx new file mode 100644 index 0000000..8ee134c --- /dev/null +++ b/apps/web/features/auth/components/AuthGuard.tsx @@ -0,0 +1,24 @@ +'use client'; + +import * as React from 'react'; +import { useAuthStore } from '@/store/useAuthStore'; + +export function AuthGuard({ children }: { children: React.ReactNode }) { + const { isAuthenticated, isLoading } = useAuthStore(); + + if (isLoading) { + return ( +
+

Loading application...

+
+ ); + } + + // The primary route protection is handled by proxy.ts, so this component only needs + // to ensure we don't render authenticated UI until the client session state is hydrated. + if (!isAuthenticated) { + return null; + } + + return <>{children}; +} diff --git a/apps/web/features/auth/components/LoginForm.tsx b/apps/web/features/auth/components/LoginForm.tsx new file mode 100644 index 0000000..267adc6 --- /dev/null +++ b/apps/web/features/auth/components/LoginForm.tsx @@ -0,0 +1,105 @@ +'use client'; + +import * as React from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { useRouter } from 'next/navigation'; +import { authService } from '../api/auth.api'; +import { useAuthStore } from '@/store/useAuthStore'; +import { AUTH_ROUTES } from '@/lib/auth/constants'; + +const loginSchema = z.object({ + email: z.string().email({ message: 'Invalid email address' }), + password: z.string().min(1, { message: 'Password is required' }), +}); + +type LoginFormData = z.infer; + +export function LoginForm() { + const router = useRouter(); + const setUser = useAuthStore((state) => state.setUser); + const [error, setError] = React.useState(null); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(loginSchema), + defaultValues: { + email: '', + password: '', + }, + }); + + const onSubmit = async (data: LoginFormData) => { + try { + setError(null); + // Backend expects 'password_string' for password inside LoginCredentials if it matches my type, + // wait, the API type is password_string but standard OpenAPI might expect password. + // Let's check apps/web/types/auth.ts - I defined password_string based on an earlier snippet. + // But Pydantic LoginRequest actually has `password`. Let's stick to `password` and fix if needed. + // Wait, let's use `password_string` since that's what I defined in `types/auth.ts`. Actually, let's map it. + + const res = await authService.login({ + email: data.email, + password: data.password, // FastAPI expects `password` usually. + } as any); + + setUser(res.user); + router.push(AUTH_ROUTES.DASHBOARD); + } catch (err: any) { + setError(err.message || 'Failed to login'); + } + }; + + return ( +
+ {error && ( +
+ {error} +
+ )} + +
+ + + {errors.email && ( +

{errors.email.message}

+ )} +
+ +
+ + + {errors.password && ( +

{errors.password.message}

+ )} +
+ + +
+ ); +} diff --git a/apps/web/features/auth/components/LogoutButton.tsx b/apps/web/features/auth/components/LogoutButton.tsx new file mode 100644 index 0000000..23d2520 --- /dev/null +++ b/apps/web/features/auth/components/LogoutButton.tsx @@ -0,0 +1,33 @@ +'use client'; + +import * as React from 'react'; +import { useRouter } from 'next/navigation'; +import { authService } from '../api/auth.api'; +import { useAuthStore } from '@/store/useAuthStore'; +import { AUTH_ROUTES } from '@/lib/auth/constants'; + +export function LogoutButton() { + const router = useRouter(); + const clearAuth = useAuthStore((state) => state.clearAuth); + + const handleLogout = async () => { + try { + await authService.logout(); + } catch (error) { + console.error('Logout failed', error); + } finally { + clearAuth(); + router.refresh(); + router.push(AUTH_ROUTES.LOGIN); + } + }; + + return ( + + ); +} diff --git a/apps/web/features/auth/components/index.ts b/apps/web/features/auth/components/index.ts new file mode 100644 index 0000000..3f09974 --- /dev/null +++ b/apps/web/features/auth/components/index.ts @@ -0,0 +1,3 @@ +export * from './AuthGuard'; +export * from './LoginForm'; +export * from './LogoutButton'; diff --git a/apps/web/features/auth/index.ts b/apps/web/features/auth/index.ts new file mode 100644 index 0000000..d750922 --- /dev/null +++ b/apps/web/features/auth/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './api/auth.api'; +export * from './components'; diff --git a/apps/web/features/auth/types.ts b/apps/web/features/auth/types.ts index c164fe9..5e60791 100644 --- a/apps/web/features/auth/types.ts +++ b/apps/web/features/auth/types.ts @@ -1 +1,30 @@ -// TODO: Define auth feature types +export type Role = "ADMIN" | "TECHNICIAN" | "EMPLOYEE"; + +export interface User { + id: string; + full_name: string; + email: string; + role: Role; + is_active: boolean; + created_at: string; + updated_at: string; +} + +export interface LoginCredentials { + email: string; + password: string; +} + +// TokenResponse represents backend response only. +export interface TokenResponse { + access_token: string; + refresh_token: string; + token_type: string; +} + +// AuthState must NOT contain tokens. +export interface AuthState { + user: User | null; + isAuthenticated: boolean; + isLoading: boolean; +} diff --git a/apps/web/features/dashboard/api/dashboard.api.ts b/apps/web/features/dashboard/api/dashboard.api.ts new file mode 100644 index 0000000..347797d --- /dev/null +++ b/apps/web/features/dashboard/api/dashboard.api.ts @@ -0,0 +1,102 @@ +import { DashboardMetric, ActivityItem, DashboardSummary } from '../types'; + +class DashboardService { + /** + * Temporary mock data for dashboard metrics. + * Future implementation will fetch from FastAPI through server-side fetch with HttpOnly cookie forwarding. + */ + async getSummary(): Promise { + try { + // MOCK DATA - To be replaced with real backend fetch + const metrics: DashboardMetric[] = [ + { + title: 'Total Tickets', + value: 1245, + trend: '+12%', + trendDirection: 'up', + description: 'From last month', + }, + { + title: 'Open Tickets', + value: 342, + trend: '-5%', + trendDirection: 'down', + description: 'Requires attention', + }, + { + title: 'Resolved Tickets', + value: 890, + trend: '+18%', + trendDirection: 'up', + description: 'From last month', + }, + { + title: 'Active Users', + value: 56, + trend: 'Stable', + trendDirection: 'neutral', + description: 'Currently online', + }, + ]; + + const recentActivity: ActivityItem[] = [ + { + id: '1', + action: 'Ticket Resolved', + entity: 'TKT-1042', + user: 'Alice Smith', + timestamp: new Date(Date.now() - 1000 * 60 * 30).toISOString(), // 30 mins ago + status: 'success', + }, + { + id: '2', + action: 'New Ticket Created', + entity: 'TKT-1043', + user: 'Bob Jones', + timestamp: new Date(Date.now() - 1000 * 60 * 120).toISOString(), // 2 hours ago + status: 'info', + }, + { + id: '3', + action: 'Priority Escalated', + entity: 'TKT-1040', + user: 'System', + timestamp: new Date(Date.now() - 1000 * 60 * 60 * 5).toISOString(), // 5 hours ago + status: 'warning', + }, + ]; + + return { + metrics, + recentActivity, + }; + } catch (error) { + console.error('DashboardService error:', error); + throw new Error('Failed to load dashboard summary data.'); + } + } + + /** + * Temporary mock data for detailed activity list. + */ + async getActivity(): Promise { + try { + // MOCK DATA - To be replaced with real backend fetch + return [ + { + id: '1', + action: 'Ticket Resolved', + entity: 'TKT-1042', + user: 'Alice Smith', + timestamp: new Date().toISOString(), + status: 'success', + }, + ]; + } catch (error) { + console.error('DashboardService error:', error); + throw new Error('Failed to load recent activity data.'); + } + } +} + +export const dashboardService = new DashboardService(); diff --git a/apps/web/features/dashboard/components/DashboardCharts.tsx b/apps/web/features/dashboard/components/DashboardCharts.tsx new file mode 100644 index 0000000..ba053d0 --- /dev/null +++ b/apps/web/features/dashboard/components/DashboardCharts.tsx @@ -0,0 +1,87 @@ +interface ChartData { + label: string; + value: number; + colorClass?: string; +} + +interface DashboardChartsProps { + ticketsData?: ChartData[]; + activityData?: ChartData[]; + isLoading?: boolean; +} + +export function DashboardCharts({ ticketsData, activityData, isLoading }: DashboardChartsProps) { + if (isLoading) { + return ( +
+
+
+
+ ); + } + + // Fallback mock data if none provided (for presentation purposes) + const defaultTicketsData = ticketsData || [ + { label: 'Open', value: 342, colorClass: 'bg-blue-500' }, + { label: 'In Progress', value: 120, colorClass: 'bg-yellow-500' }, + { label: 'Resolved', value: 890, colorClass: 'bg-green-500' }, + { label: 'Closed', value: 245, colorClass: 'bg-gray-500' }, + ]; + + const defaultActivityData = activityData || [ + { label: 'Mon', value: 45, colorClass: 'bg-indigo-500' }, + { label: 'Tue', value: 52, colorClass: 'bg-indigo-500' }, + { label: 'Wed', value: 38, colorClass: 'bg-indigo-500' }, + { label: 'Thu', value: 65, colorClass: 'bg-indigo-500' }, + { label: 'Fri', value: 48, colorClass: 'bg-indigo-500' }, + { label: 'Sat', value: 12, colorClass: 'bg-indigo-400' }, + { label: 'Sun', value: 8, colorClass: 'bg-indigo-400' }, + ]; + + const maxTicketValue = Math.max(...defaultTicketsData.map(d => d.value), 1); + const maxActivityValue = Math.max(...defaultActivityData.map(d => d.value), 1); + + return ( +
+ + {/* Ticket Status Distribution - Horizontal Bar Chart */} +
+

Ticket Status

+
+ {defaultTicketsData.map((item, i) => ( +
+
+ {item.label} + {item.value} +
+
+
+
+
+ ))} +
+
+ + {/* Activity Trends - Vertical Bar Chart */} +
+

Activity Trends

+
+ {defaultActivityData.map((item, i) => ( +
+
+ {item.label} +
+ ))} +
+
+ +
+ ); +} diff --git a/apps/web/features/dashboard/components/DashboardHeader.tsx b/apps/web/features/dashboard/components/DashboardHeader.tsx new file mode 100644 index 0000000..74dadb0 --- /dev/null +++ b/apps/web/features/dashboard/components/DashboardHeader.tsx @@ -0,0 +1,18 @@ +import { User } from '@/features/auth'; + +interface DashboardHeaderProps { + user: User | null; +} + +export function DashboardHeader({ user }: DashboardHeaderProps) { + const greeting = user?.full_name ? `Welcome back, ${user.full_name}` : 'Welcome back'; + + return ( +
+

{greeting}

+

+ Here is what's happening with your tickets today. +

+
+ ); +} diff --git a/apps/web/features/dashboard/components/DashboardHeaderWrapper.tsx b/apps/web/features/dashboard/components/DashboardHeaderWrapper.tsx new file mode 100644 index 0000000..bf52f2b --- /dev/null +++ b/apps/web/features/dashboard/components/DashboardHeaderWrapper.tsx @@ -0,0 +1,9 @@ +'use client'; + +import { useAuthStore } from '@/store/useAuthStore'; +import { DashboardHeader } from './DashboardHeader'; + +export function DashboardHeaderWrapper() { + const user = useAuthStore((state) => state.user); + return ; +} diff --git a/apps/web/features/dashboard/components/RecentActivity.tsx b/apps/web/features/dashboard/components/RecentActivity.tsx new file mode 100644 index 0000000..a06bec7 --- /dev/null +++ b/apps/web/features/dashboard/components/RecentActivity.tsx @@ -0,0 +1,87 @@ +import { ActivityItem } from '../types'; + +interface RecentActivityProps { + activities?: ActivityItem[]; + isLoading?: boolean; +} + +export function RecentActivity({ activities, isLoading }: RecentActivityProps) { + if (isLoading) { + return ( +
+
+
+
+
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+
+ ); + } + + if (!activities || activities.length === 0) { + return ( +
+

Recent Activity

+

No recent activity found.

+
+ ); + } + + return ( +
+
+

Recent Activity

+
+
    + {activities.map((activity) => ( +
  • +
    +
    + + {activity.user.charAt(0).toUpperCase()} + +
    +

    + {activity.action} on {activity.entity} +

    +

    by {activity.user}

    +
    +
    +
    + + {activity.status && ( + + {activity.status} + + )} +
    +
    +
  • + ))} +
+
+ ); +} diff --git a/apps/web/features/dashboard/components/SummaryCards.tsx b/apps/web/features/dashboard/components/SummaryCards.tsx new file mode 100644 index 0000000..2227bd6 --- /dev/null +++ b/apps/web/features/dashboard/components/SummaryCards.tsx @@ -0,0 +1,59 @@ +import { DashboardMetric } from '../types'; + +interface SummaryCardsProps { + metrics?: DashboardMetric[]; + isLoading?: boolean; +} + +export function SummaryCards({ metrics, isLoading }: SummaryCardsProps) { + if (isLoading) { + return ( +
+ {[1, 2, 3, 4].map((i) => ( +
+
+
+
+
+ ))} +
+ ); + } + + if (!metrics || metrics.length === 0) { + return ( +
+

No summary metrics available.

+
+ ); + } + + return ( +
+ {metrics.map((metric, index) => ( +
+

{metric.title}

+
+

{metric.value}

+ {metric.trend && ( + + {metric.trend} + + )} +
+ {metric.description && ( +

{metric.description}

+ )} +
+ ))} +
+ ); +} diff --git a/apps/web/features/dashboard/components/index.ts b/apps/web/features/dashboard/components/index.ts new file mode 100644 index 0000000..8a3dcd2 --- /dev/null +++ b/apps/web/features/dashboard/components/index.ts @@ -0,0 +1,5 @@ +export * from './DashboardCharts'; +export * from './DashboardHeader'; +export * from './DashboardHeaderWrapper'; +export * from './RecentActivity'; +export * from './SummaryCards'; diff --git a/apps/web/features/dashboard/index.ts b/apps/web/features/dashboard/index.ts new file mode 100644 index 0000000..601aa10 --- /dev/null +++ b/apps/web/features/dashboard/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './api/dashboard.api'; +export * from './components'; diff --git a/apps/web/features/dashboard/types.ts b/apps/web/features/dashboard/types.ts index 58d46c6..d6fb305 100644 --- a/apps/web/features/dashboard/types.ts +++ b/apps/web/features/dashboard/types.ts @@ -1 +1,22 @@ -// TODO: Define dashboard feature types +export interface DashboardMetric { + title: string; + value: string | number; + trend?: string; + trendDirection?: 'up' | 'down' | 'neutral'; + description?: string; + icon?: string; +} + +export interface ActivityItem { + id: string; + action: string; + entity: string; + user: string; + timestamp: string; + status?: 'success' | 'warning' | 'error' | 'info'; +} + +export interface DashboardSummary { + metrics: DashboardMetric[]; + recentActivity: ActivityItem[]; +} diff --git a/apps/web/features/tickets/api/tickets.api.ts b/apps/web/features/tickets/api/tickets.api.ts new file mode 100644 index 0000000..ddee495 --- /dev/null +++ b/apps/web/features/tickets/api/tickets.api.ts @@ -0,0 +1,169 @@ +import { + Ticket, + TicketCreate, + TicketUpdate, + TicketResponse, + TicketStatus, + TicketAssignmentHistory, +} from '../types'; + +const getBaseUrl = () => { + if (typeof window !== 'undefined') { + return ''; // Client side, use relative + } + return process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; +}; + +class TicketService { + private async handleResponse(response: Response): Promise { + if (!response.ok) { + let errorData; + try { + errorData = await response.json(); + } catch { + errorData = {}; + } + throw new Error(errorData.error || errorData.detail || 'An error occurred during the request.'); + } + + if (response.status === 204) { + return {} as T; + } + + return response.json(); + } + + async getTickets( + params: { + page?: number; + limit?: number; + status?: string; + priority?: string; + search?: string; + } = {}, + headers?: HeadersInit + ): Promise { + const searchParams = new URLSearchParams(); + + if (params.page !== undefined) searchParams.set('page', params.page.toString()); + if (params.limit !== undefined) searchParams.set('limit', params.limit.toString()); + if (params.status) searchParams.set('status', params.status); + if (params.priority) searchParams.set('priority', params.priority); + if (params.search) searchParams.set('search', params.search); + + const queryString = searchParams.toString(); + const basePath = `${getBaseUrl()}/api/tickets`; + const url = queryString ? `${basePath}?${queryString}` : basePath; + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + }); + + return this.handleResponse(response); + } + + async getTicket(id: string, headers?: HeadersInit): Promise { + const response = await fetch(`${getBaseUrl()}/api/tickets/${id}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + }); + + return this.handleResponse(response); + } + + async createTicket(ticket: TicketCreate): Promise { + const response = await fetch('/api/tickets', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(ticket), + }); + + return this.handleResponse(response); + } + + async updateTicket(id: string, ticket: TicketUpdate): Promise { + const response = await fetch(`/api/tickets/${id}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(ticket), + }); + + return this.handleResponse(response); + } + + async deleteTicket(id: string): Promise { + const response = await fetch(`/api/tickets/${id}`, { + method: 'DELETE', + }); + + if (!response.ok) { + let errorData; + try { + errorData = await response.json(); + } catch { + errorData = {}; + } + throw new Error(errorData.error || errorData.detail || 'Failed to delete ticket.'); + } + } + + async assignTicket(id: string, technician_id: string): Promise { + const response = await fetch(`/api/tickets/${id}/assign`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ technician_id }), + }); + + return this.handleResponse(response); + } + + async unassignTicket(id: string): Promise { + const response = await fetch(`/api/tickets/${id}/unassign`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + }); + + return this.handleResponse(response); + } + + async getAssignmentHistory(id: string, headers?: HeadersInit): Promise { + const response = await fetch(`${getBaseUrl()}/api/tickets/${id}/assignment-history`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + }); + + return this.handleResponse(response); + } + + async updateTicketStatus(id: string, status: TicketStatus): Promise { + const response = await fetch(`/api/tickets/${id}/status`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ status }), + }); + + return this.handleResponse(response); + } +} + +export const ticketService = new TicketService(); diff --git a/apps/web/features/tickets/components/TicketAssignment.tsx b/apps/web/features/tickets/components/TicketAssignment.tsx new file mode 100644 index 0000000..399057c --- /dev/null +++ b/apps/web/features/tickets/components/TicketAssignment.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { ticketService } from '../api/tickets.api'; + +interface Props { + ticketId: string; + currentAssignee?: string; +} + +export function TicketAssignment({ ticketId, currentAssignee }: Props) { + const router = useRouter(); + const [isAssigning, setIsAssigning] = useState(false); + const [technicianId, setTechnicianId] = useState(''); + const [error, setError] = useState(null); + + const handleAssign = async () => { + if (!technicianId) return; + setIsAssigning(true); + setError(null); + try { + await ticketService.assignTicket(ticketId, technicianId); + setTechnicianId(''); + router.refresh(); + } catch (err: any) { + setError(err.message || 'Failed to assign ticket'); + } finally { + setIsAssigning(false); + } + }; + + const handleUnassign = async () => { + setIsAssigning(true); + setError(null); + try { + await ticketService.unassignTicket(ticketId); + router.refresh(); + } catch (err: any) { + setError(err.message || 'Failed to unassign ticket'); + } finally { + setIsAssigning(false); + } + }; + + return ( +
+

Assignment

+ + {error && ( +
+ {error} +
+ )} + + {currentAssignee ? ( +
+

+ Assigned to: {currentAssignee} +

+ +
+ ) : ( +
+ setTechnicianId(e.target.value)} + className="block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6" + /> + +
+ )} +
+ ); +} diff --git a/apps/web/features/tickets/components/TicketFilters.tsx b/apps/web/features/tickets/components/TicketFilters.tsx new file mode 100644 index 0000000..8618a77 --- /dev/null +++ b/apps/web/features/tickets/components/TicketFilters.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { useRouter, useSearchParams, usePathname } from 'next/navigation'; +import { useCallback, useState } from 'react'; +import { TicketStatus, TicketPriority } from '../types'; + +export function TicketFilters() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const [search, setSearch] = useState(searchParams.get('search') || ''); + + const createQueryString = useCallback( + (name: string, value: string) => { + const params = new URLSearchParams(searchParams.toString()); + if (value) { + params.set(name, value); + } else { + params.delete(name); + } + params.delete('page'); // Reset page on filter change + return params.toString(); + }, + [searchParams] + ); + + const handleFilterChange = (name: string, value: string) => { + router.push(pathname + '?' + createQueryString(name, value)); + }; + + const handleSearchSubmit = (e: React.FormEvent) => { + e.preventDefault(); + handleFilterChange('search', search); + }; + + return ( +
+
+ +
+
+ +
+ setSearch(e.target.value)} + className="block w-full rounded-md border-0 py-1.5 pl-10 pr-3 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6" + /> +
+
+ +
+ + + +
+
+ ); +} diff --git a/apps/web/features/tickets/components/TicketPagination.tsx b/apps/web/features/tickets/components/TicketPagination.tsx new file mode 100644 index 0000000..165ab60 --- /dev/null +++ b/apps/web/features/tickets/components/TicketPagination.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { useRouter, useSearchParams, usePathname } from 'next/navigation'; +import { useCallback } from 'react'; + +interface Props { + page: number; + limit: number; + total?: number; + pages?: number; + hasMore?: boolean; +} + +export function TicketPagination({ page, limit, total, pages, hasMore }: Props) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const createQueryString = useCallback( + (name: string, value: string) => { + const params = new URLSearchParams(searchParams.toString()); + params.set(name, value); + return params.toString(); + }, + [searchParams] + ); + + const handlePageChange = (newPage: number) => { + router.push(pathname + '?' + createQueryString('page', newPage.toString())); + }; + + const isPreviousDisabled = page <= 1; + const isNextDisabled = pages ? page >= pages : !hasMore; + + return ( +
+
+ + +
+
+
+

+ Showing page {page} + {pages && of {pages}} + {total && ({total} total tickets)} +

+
+
+ +
+
+
+ ); +} diff --git a/apps/web/features/tickets/components/TicketPriorityBadge.tsx b/apps/web/features/tickets/components/TicketPriorityBadge.tsx new file mode 100644 index 0000000..fef4343 --- /dev/null +++ b/apps/web/features/tickets/components/TicketPriorityBadge.tsx @@ -0,0 +1,31 @@ +import { TicketPriority } from '../types'; + +interface Props { + priority: TicketPriority; + className?: string; +} + +export function TicketPriorityBadge({ priority, className = '' }: Props) { + const getStyles = () => { + switch (priority) { + case TicketPriority.LOW: + return 'bg-gray-100 text-gray-800 border-gray-200'; + case TicketPriority.MEDIUM: + return 'bg-blue-100 text-blue-800 border-blue-200'; + case TicketPriority.HIGH: + return 'bg-orange-100 text-orange-800 border-orange-200'; + case TicketPriority.URGENT: + return 'bg-red-100 text-red-800 border-red-200'; + default: + return 'bg-gray-100 text-gray-800 border-gray-200'; + } + }; + + return ( + + {priority} + + ); +} diff --git a/apps/web/features/tickets/components/TicketStatusBadge.tsx b/apps/web/features/tickets/components/TicketStatusBadge.tsx new file mode 100644 index 0000000..2bb0ce1 --- /dev/null +++ b/apps/web/features/tickets/components/TicketStatusBadge.tsx @@ -0,0 +1,37 @@ +import { TicketStatus } from '../types'; + +interface Props { + status: TicketStatus; + className?: string; +} + +export function TicketStatusBadge({ status, className = '' }: Props) { + const getStyles = () => { + switch (status) { + case TicketStatus.OPEN: + return 'bg-blue-100 text-blue-800 border-blue-200'; + case TicketStatus.IN_PROGRESS: + return 'bg-yellow-100 text-yellow-800 border-yellow-200'; + case TicketStatus.PENDING: + return 'bg-orange-100 text-orange-800 border-orange-200'; + case TicketStatus.RESOLVED: + return 'bg-green-100 text-green-800 border-green-200'; + case TicketStatus.CLOSED: + return 'bg-gray-100 text-gray-800 border-gray-200'; + default: + return 'bg-gray-100 text-gray-800 border-gray-200'; + } + }; + + const getLabel = () => { + return status.replace('_', ' '); + }; + + return ( + + {getLabel()} + + ); +} diff --git a/apps/web/features/tickets/components/TicketTable.tsx b/apps/web/features/tickets/components/TicketTable.tsx new file mode 100644 index 0000000..dd1469a --- /dev/null +++ b/apps/web/features/tickets/components/TicketTable.tsx @@ -0,0 +1,73 @@ +import { Ticket } from '../types'; +import { TicketStatusBadge } from './TicketStatusBadge'; +import { TicketPriorityBadge } from './TicketPriorityBadge'; +import Link from 'next/link'; + +interface Props { + tickets: Ticket[]; +} + +export function TicketTable({ tickets }: Props) { + return ( +
+ + + + + + + + + + + + + {tickets.length === 0 ? ( + + + + ) : ( + tickets.map((ticket) => ( + + + + + + + + + )) + )} + +
+ Title + + Status + + Priority + + Category + + Created + + Actions +
+ No tickets found. +
+ {ticket.title} + + + + + + {ticket.category || '-'} + + {new Date(ticket.created_at).toLocaleDateString()} + + + View, {ticket.title} + +
+
+ ); +} diff --git a/apps/web/features/tickets/components/TicketWorkflow.tsx b/apps/web/features/tickets/components/TicketWorkflow.tsx new file mode 100644 index 0000000..06e902b --- /dev/null +++ b/apps/web/features/tickets/components/TicketWorkflow.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { ticketService } from '../api/tickets.api'; +import { TicketStatus } from '../types'; + +interface Props { + ticketId: string; + currentStatus: TicketStatus; +} + +export function TicketWorkflow({ ticketId, currentStatus }: Props) { + const router = useRouter(); + const [isUpdating, setIsUpdating] = useState(false); + const [error, setError] = useState(null); + + const getAvailableTransitions = () => { + // This replicates the backend ALLOWED_TRANSITIONS logic temporarily for UI purposes + const transitions: Record = { + [TicketStatus.OPEN]: [TicketStatus.IN_PROGRESS], + [TicketStatus.IN_PROGRESS]: [TicketStatus.PENDING, TicketStatus.RESOLVED, TicketStatus.OPEN], + [TicketStatus.PENDING]: [TicketStatus.IN_PROGRESS, TicketStatus.RESOLVED], + [TicketStatus.RESOLVED]: [TicketStatus.CLOSED, TicketStatus.IN_PROGRESS], + [TicketStatus.CLOSED]: [], + }; + return transitions[currentStatus] || []; + }; + + const availableTransitions = getAvailableTransitions(); + + const handleStatusChange = async (newStatus: TicketStatus) => { + setIsUpdating(true); + setError(null); + try { + await ticketService.updateTicketStatus(ticketId, newStatus); + router.refresh(); + } catch (err: any) { + setError(err.message || 'Failed to update status'); + } finally { + setIsUpdating(false); + } + }; + + if (availableTransitions.length === 0) { + return null; + } + + return ( +
+

Workflow Actions

+ + {error && ( +
+ {error} +
+ )} + +
+ {availableTransitions.map((status) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/features/tickets/components/index.ts b/apps/web/features/tickets/components/index.ts new file mode 100644 index 0000000..bd25ddf --- /dev/null +++ b/apps/web/features/tickets/components/index.ts @@ -0,0 +1,7 @@ +export * from './TicketAssignment'; +export * from './TicketFilters'; +export * from './TicketPagination'; +export * from './TicketPriorityBadge'; +export * from './TicketStatusBadge'; +export * from './TicketTable'; +export * from './TicketWorkflow'; diff --git a/apps/web/features/tickets/index.ts b/apps/web/features/tickets/index.ts new file mode 100644 index 0000000..03e167f --- /dev/null +++ b/apps/web/features/tickets/index.ts @@ -0,0 +1,6 @@ +export * from './types'; +export * from './api/tickets.api'; +export * from './components'; + +// Explicitly export components to resolve ambiguity with interfaces of the same name in types.ts +export { TicketAssignment, TicketFilters, TicketPagination } from './components'; diff --git a/apps/web/features/tickets/types.ts b/apps/web/features/tickets/types.ts index c4bd849..0d10ee7 100644 --- a/apps/web/features/tickets/types.ts +++ b/apps/web/features/tickets/types.ts @@ -1 +1,82 @@ -// TODO: Define tickets feature types +export enum TicketStatus { + OPEN = 'OPEN', + IN_PROGRESS = 'IN_PROGRESS', + PENDING = 'PENDING', + RESOLVED = 'RESOLVED', + CLOSED = 'CLOSED', +} + +export enum TicketPriority { + LOW = 'LOW', + MEDIUM = 'MEDIUM', + HIGH = 'HIGH', + URGENT = 'URGENT', +} + +export enum AssignmentActionType { + ASSIGNED = 'ASSIGNED', + REASSIGNED = 'REASSIGNED', + UNASSIGNED = 'UNASSIGNED', +} + +export interface Ticket { + title: string; + description: string; + category?: string; + priority: TicketPriority; + id: string; + status: TicketStatus; + created_by: string; + assigned_to?: string; + created_at: string; + updated_at: string; +} + +export interface TicketCreate { + title: string; + description: string; + category?: string; + priority: TicketPriority; +} + +export interface TicketUpdate { + title?: string; + description?: string; + category?: string; + priority?: TicketPriority; + status?: TicketStatus; + assigned_to?: string; +} + +export interface TicketAssignment { + technician_id: string; +} + +export interface TicketAssignmentHistory { + id: string; + ticket_id: string; + assigned_from: string | null; + assigned_to: string | null; + action_type: AssignmentActionType; + created_at: string; + updated_at: string; +} + +export interface TicketFilters { + status?: TicketStatus; + priority?: TicketPriority; + search?: string; +} + +export interface TicketPagination { + page?: number; + limit?: number; +} + +export interface TicketResponse { + data: Ticket[]; + page: number; + limit: number; + total?: number; + pages?: number; +} diff --git a/apps/web/features/users/api/users.api.ts b/apps/web/features/users/api/users.api.ts new file mode 100644 index 0000000..f92943a --- /dev/null +++ b/apps/web/features/users/api/users.api.ts @@ -0,0 +1,135 @@ +import { + User, + UserResponse, + UserFilters, + CreateUserPayload, + UpdateUserPayload, + UserRole +} from '../types'; + +const API_BASE_URL = '/api/users'; + +export class UserService { + static async getUsers(filters?: UserFilters): Promise { + const params = new URLSearchParams(); + + if (filters) { + if (filters.skip !== undefined) params.append('skip', filters.skip.toString()); + if (filters.limit !== undefined) params.append('limit', filters.limit.toString()); + if (filters.role) params.append('role', filters.role); + if (filters.status !== undefined) params.append('status', filters.status.toString()); + if (filters.search) params.append('search', filters.search); + } + + const queryString = params.toString(); + const url = queryString ? `${API_BASE_URL}?${queryString}` : API_BASE_URL; + + const response = await fetch(url, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + cache: 'no-store' + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.error || error.detail || 'Failed to fetch users'); + } + + return response.json(); + } + + static async getUser(id: string): Promise { + const response = await fetch(`${API_BASE_URL}/${id}`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + cache: 'no-store' + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.error || error.detail || 'Failed to fetch user'); + } + + return response.json(); + } + + static async createUser(payload: CreateUserPayload): Promise { + const response = await fetch(API_BASE_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.error || error.detail || 'Failed to create user'); + } + + return response.json(); + } + + static async updateUser(id: string, payload: UpdateUserPayload): Promise { + const response = await fetch(`${API_BASE_URL}/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.error || error.detail || 'Failed to update user'); + } + + return response.json(); + } + + static async deleteUser(id: string): Promise { + const response = await fetch(`${API_BASE_URL}/${id}`, { + method: 'DELETE' + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.error || error.detail || 'Failed to delete user'); + } + } + + static async activateUser(id: string): Promise { + const response = await fetch(`${API_BASE_URL}/${id}/activate`, { + method: 'PATCH' + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.error || error.detail || 'Failed to activate user'); + } + + return response.json(); + } + + static async deactivateUser(id: string): Promise { + const response = await fetch(`${API_BASE_URL}/${id}/deactivate`, { + method: 'PATCH' + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.error || error.detail || 'Failed to deactivate user'); + } + + return response.json(); + } + + static async updateUserRole(id: string, role: UserRole): Promise { + const response = await fetch(`${API_BASE_URL}/${id}/role?role=${role}`, { + method: 'PATCH' + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.error || error.detail || 'Failed to update user role'); + } + + return response.json(); + } +} diff --git a/apps/web/features/users/components/UserActions.tsx b/apps/web/features/users/components/UserActions.tsx new file mode 100644 index 0000000..51c67de --- /dev/null +++ b/apps/web/features/users/components/UserActions.tsx @@ -0,0 +1,88 @@ +'use client'; + +import React, { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { User } from '../types'; +import { UserService } from '../api/users.api'; + +interface UserActionsProps { + user: User; +} + +export function UserActions({ user }: UserActionsProps) { + const router = useRouter(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleToggleStatus = async () => { + if (!confirm(`Are you sure you want to ${user.is_active ? 'deactivate' : 'activate'} this user?`)) { + return; + } + + setLoading(true); + setError(null); + try { + if (user.is_active) { + await UserService.deactivateUser(user.id); + } else { + await UserService.activateUser(user.id); + } + router.refresh(); + } catch (err: any) { + setError(err.message || 'Action failed'); + } finally { + setLoading(false); + } + }; + + const handleDelete = async () => { + if (!confirm('Are you sure you want to completely delete this user? This action cannot be undone.')) { + return; + } + + setLoading(true); + setError(null); + try { + await UserService.deleteUser(user.id); + router.push('/users'); + router.refresh(); + } catch (err: any) { + setError(err.message || 'Failed to delete user'); + setLoading(false); + } + }; + + return ( +
+

User Actions

+ + {error && ( +
+ {error} +
+ )} + +
+ + + +
+
+ ); +} diff --git a/apps/web/features/users/components/UserFilters.tsx b/apps/web/features/users/components/UserFilters.tsx new file mode 100644 index 0000000..0f1c91f --- /dev/null +++ b/apps/web/features/users/components/UserFilters.tsx @@ -0,0 +1,90 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { UserRole } from '../types'; + +export function UserFilters() { + const router = useRouter(); + const searchParams = useSearchParams(); + + const [search, setSearch] = useState(searchParams.get('search') || ''); + + // Handle debounced search + useEffect(() => { + const timer = setTimeout(() => { + const params = new URLSearchParams(searchParams.toString()); + if (search) { + params.set('search', search); + } else { + params.delete('search'); + } + // Only push if the search actually changed + if (params.get('search') !== searchParams.get('search')) { + params.set('skip', '0'); // Reset pagination on search + router.push(`?${params.toString()}`); + } + }, 500); + + return () => clearTimeout(timer); + }, [search, router, searchParams]); + + const handleRoleChange = (e: React.ChangeEvent) => { + const params = new URLSearchParams(searchParams.toString()); + if (e.target.value) { + params.set('role', e.target.value); + } else { + params.delete('role'); + } + params.set('skip', '0'); + router.push(`?${params.toString()}`); + }; + + const handleStatusChange = (e: React.ChangeEvent) => { + const params = new URLSearchParams(searchParams.toString()); + if (e.target.value) { + params.set('status', e.target.value); + } else { + params.delete('status'); + } + params.set('skip', '0'); + router.push(`?${params.toString()}`); + }; + + return ( +
+
+ setSearch(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500" + /> +
+
+ +
+
+ +
+
+ ); +} diff --git a/apps/web/features/users/components/UserForm.tsx b/apps/web/features/users/components/UserForm.tsx new file mode 100644 index 0000000..eba5f16 --- /dev/null +++ b/apps/web/features/users/components/UserForm.tsx @@ -0,0 +1,170 @@ +'use client'; + +import React, { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { UserService } from '../api/users.api'; +import { User, UserRole, CreateUserPayload, UpdateUserPayload } from '../types'; + +interface UserFormProps { + user?: User; // If provided, we are editing. If not, creating. +} + +export function UserForm({ user }: UserFormProps) { + const router = useRouter(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const [formData, setFormData] = useState({ + full_name: user?.full_name || '', + email: user?.email || '', + password: '', + role: user?.role || UserRole.EMPLOYEE, + is_active: user !== undefined ? user.is_active : true, + }); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value, type } = e.target; + setFormData(prev => ({ + ...prev, + [name]: type === 'checkbox' ? (e.target as HTMLInputElement).checked : value + })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(null); + + try { + if (user) { + const updatePayload: UpdateUserPayload = { + full_name: formData.full_name, + email: formData.email, + role: formData.role as UserRole, + }; + // Only send password if it was typed + if (formData.password) { + updatePayload.password = formData.password; + } + await UserService.updateUser(user.id, updatePayload); + } else { + if (!formData.password) { + throw new Error('Password is required for new users'); + } + const createPayload: CreateUserPayload = { + full_name: formData.full_name, + email: formData.email, + password: formData.password, + role: formData.role as UserRole, + is_active: formData.is_active, + }; + await UserService.createUser(createPayload); + } + + router.push('/users'); + router.refresh(); + } catch (err: any) { + setError(err.message || 'An error occurred'); + setLoading(false); + } + }; + + return ( +
+ {error && ( +
+ {error} +
+ )} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {!user && ( +
+ + +
+ )} +
+ +
+ + +
+
+ ); +} diff --git a/apps/web/features/users/components/UserPagination.tsx b/apps/web/features/users/components/UserPagination.tsx new file mode 100644 index 0000000..cf1a1f7 --- /dev/null +++ b/apps/web/features/users/components/UserPagination.tsx @@ -0,0 +1,81 @@ +'use client'; + +import React from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { UserPagination as UserPaginationType } from '../types'; + +interface UserPaginationProps { + pagination: UserPaginationType; +} + +export function UserPagination({ pagination }: UserPaginationProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + + const { skip, limit, total } = pagination; + const currentPage = Math.floor(skip / limit) + 1; + const totalPages = Math.ceil(total / limit); + + if (totalPages <= 1) return null; + + const handlePageChange = (newPage: number) => { + const params = new URLSearchParams(searchParams.toString()); + params.set('skip', ((newPage - 1) * limit).toString()); + router.push(`?${params.toString()}`); + }; + + return ( +
+
+ + +
+
+
+

+ Showing {Math.min(skip + 1, total)} to{' '} + {Math.min(skip + limit, total)} of{' '} + {total} results +

+
+
+ +
+
+
+ ); +} diff --git a/apps/web/features/users/components/UserRoleBadge.tsx b/apps/web/features/users/components/UserRoleBadge.tsx new file mode 100644 index 0000000..5a4c07a --- /dev/null +++ b/apps/web/features/users/components/UserRoleBadge.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { UserRole } from '../types'; + +interface UserRoleBadgeProps { + role: UserRole; +} + +export function UserRoleBadge({ role }: UserRoleBadgeProps) { + const styles = { + [UserRole.ADMIN]: 'bg-purple-100 text-purple-800 border-purple-200', + [UserRole.TECHNICIAN]: 'bg-blue-100 text-blue-800 border-blue-200', + [UserRole.EMPLOYEE]: 'bg-gray-100 text-gray-800 border-gray-200', + }; + + return ( + + {role} + + ); +} diff --git a/apps/web/features/users/components/UserStatusBadge.tsx b/apps/web/features/users/components/UserStatusBadge.tsx new file mode 100644 index 0000000..59aafc4 --- /dev/null +++ b/apps/web/features/users/components/UserStatusBadge.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +interface UserStatusBadgeProps { + isActive: boolean; +} + +export function UserStatusBadge({ isActive }: UserStatusBadgeProps) { + return ( + + {isActive ? 'Active' : 'Inactive'} + + ); +} diff --git a/apps/web/features/users/components/UserTable.tsx b/apps/web/features/users/components/UserTable.tsx new file mode 100644 index 0000000..5067d57 --- /dev/null +++ b/apps/web/features/users/components/UserTable.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import Link from 'next/link'; +import { User } from '../types'; +import { UserRoleBadge } from './UserRoleBadge'; +import { UserStatusBadge } from './UserStatusBadge'; + +interface UserTableProps { + users: User[]; +} + +export function UserTable({ users }: UserTableProps) { + if (!users || users.length === 0) { + return ( +
+ No users found. +
+ ); + } + + return ( +
+ + + + + + + + + + + + {users.map((user) => ( + + + + + + + + ))} + +
NameRoleStatusJoinedActions
+
+ {user.full_name} + {user.email} +
+
+ + + + + {new Date(user.created_at).toLocaleDateString()} + + + Manage + +
+
+ ); +} diff --git a/apps/web/features/users/components/index.ts b/apps/web/features/users/components/index.ts new file mode 100644 index 0000000..3962eef --- /dev/null +++ b/apps/web/features/users/components/index.ts @@ -0,0 +1,7 @@ +export * from './UserActions'; +export * from './UserFilters'; +export * from './UserForm'; +export * from './UserPagination'; +export * from './UserRoleBadge'; +export * from './UserStatusBadge'; +export * from './UserTable'; diff --git a/apps/web/features/users/index.ts b/apps/web/features/users/index.ts new file mode 100644 index 0000000..b18dc77 --- /dev/null +++ b/apps/web/features/users/index.ts @@ -0,0 +1,6 @@ +export * from './types'; +export * from './api/users.api'; +export * from './components'; + +// Explicitly export components to resolve ambiguity with interfaces of the same name in types.ts +export { UserFilters, UserPagination } from './components'; diff --git a/apps/web/features/users/types.ts b/apps/web/features/users/types.ts index 1e39937..63da9c3 100644 --- a/apps/web/features/users/types.ts +++ b/apps/web/features/users/types.ts @@ -1 +1,50 @@ -// TODO: Define users feature types +export enum UserRole { + ADMIN = 'ADMIN', + TECHNICIAN = 'TECHNICIAN', + EMPLOYEE = 'EMPLOYEE' +} + +export interface User { + id: string; + full_name: string; + email: string; + role: UserRole; + is_active: boolean; + created_at: string; + updated_at: string; +} + +export interface UserFilters { + role?: UserRole; + status?: boolean; + search?: string; + skip?: number; + limit?: number; +} + +export interface UserPagination { + skip: number; + limit: number; + total: number; +} + +export interface UserResponse { + data: User[]; + total: number; +} + +export interface CreateUserPayload { + full_name: string; + email: string; + password?: string; + role: UserRole; + is_active: boolean; +} + +export interface UpdateUserPayload { + full_name?: string; + email?: string; + password?: string; + role?: UserRole; + is_active?: boolean; +} diff --git a/apps/web/providers/AuthProvider.tsx b/apps/web/providers/AuthProvider.tsx index 3c7224e..b7f3c6c 100644 --- a/apps/web/providers/AuthProvider.tsx +++ b/apps/web/providers/AuthProvider.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { useAuthStore } from '@/store/useAuthStore'; -import { authService } from '@/services/auth.service'; +import { authService } from '@/features/auth'; export function AuthProvider({ children }: { children: React.ReactNode }) { const setUser = useAuthStore((state) => state.setUser); diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index 6363d86..5de3561 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -8,7 +8,9 @@ export function proxy(request: NextRequest) { const isAuthenticated = !!token && !!token.value; - const isProtectedRoute = PROTECTED_ROUTES.some((route) => pathname.startsWith(route)); + const isProtectedRoute = PROTECTED_ROUTES.some((route) => + route === '/' ? pathname === '/' : pathname.startsWith(route) + ); const isAuthRoute = pathname === AUTH_ROUTES.LOGIN; if (isProtectedRoute && !isAuthenticated) { diff --git a/apps/web/services/auth.service.ts b/apps/web/services/auth.service.ts index a73461a..5e793fc 100644 --- a/apps/web/services/auth.service.ts +++ b/apps/web/services/auth.service.ts @@ -1,45 +1 @@ -import { LoginCredentials, User } from '@/types/auth'; -import { API_ROUTES } from '@/lib/auth/constants'; - -class AuthService { - async login(credentials: LoginCredentials): Promise<{ user: User }> { - const response = await fetch(API_ROUTES.LOGIN, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(credentials), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error || 'Login failed'); - } - - return response.json(); - } - - async logout(): Promise { - const response = await fetch(API_ROUTES.LOGOUT, { - method: 'POST', - }); - - if (!response.ok) { - throw new Error('Logout failed'); - } - } - - async getMe(): Promise<{ user: User }> { - const response = await fetch(API_ROUTES.ME, { - method: 'GET', - }); - - if (!response.ok) { - throw new Error('Failed to retrieve user session'); - } - - return response.json(); - } -} - -export const authService = new AuthService(); +// Deprecated - moved to features/auth/api/auth.api.ts diff --git a/apps/web/services/dashboard.service.ts b/apps/web/services/dashboard.service.ts index 8b29c4f..7d65228 100644 --- a/apps/web/services/dashboard.service.ts +++ b/apps/web/services/dashboard.service.ts @@ -1,102 +1 @@ -import { DashboardMetric, ActivityItem, DashboardSummary } from '@/types/dashboard'; - -class DashboardService { - /** - * Temporary mock data for dashboard metrics. - * Future implementation will fetch from FastAPI through server-side fetch with HttpOnly cookie forwarding. - */ - async getSummary(): Promise { - try { - // MOCK DATA - To be replaced with real backend fetch - const metrics: DashboardMetric[] = [ - { - title: 'Total Tickets', - value: 1245, - trend: '+12%', - trendDirection: 'up', - description: 'From last month', - }, - { - title: 'Open Tickets', - value: 342, - trend: '-5%', - trendDirection: 'down', - description: 'Requires attention', - }, - { - title: 'Resolved Tickets', - value: 890, - trend: '+18%', - trendDirection: 'up', - description: 'From last month', - }, - { - title: 'Active Users', - value: 56, - trend: 'Stable', - trendDirection: 'neutral', - description: 'Currently online', - }, - ]; - - const recentActivity: ActivityItem[] = [ - { - id: '1', - action: 'Ticket Resolved', - entity: 'TKT-1042', - user: 'Alice Smith', - timestamp: new Date(Date.now() - 1000 * 60 * 30).toISOString(), // 30 mins ago - status: 'success', - }, - { - id: '2', - action: 'New Ticket Created', - entity: 'TKT-1043', - user: 'Bob Jones', - timestamp: new Date(Date.now() - 1000 * 60 * 120).toISOString(), // 2 hours ago - status: 'info', - }, - { - id: '3', - action: 'Priority Escalated', - entity: 'TKT-1040', - user: 'System', - timestamp: new Date(Date.now() - 1000 * 60 * 60 * 5).toISOString(), // 5 hours ago - status: 'warning', - }, - ]; - - return { - metrics, - recentActivity, - }; - } catch (error) { - console.error('DashboardService error:', error); - throw new Error('Failed to load dashboard summary data.'); - } - } - - /** - * Temporary mock data for detailed activity list. - */ - async getActivity(): Promise { - try { - // MOCK DATA - To be replaced with real backend fetch - return [ - { - id: '1', - action: 'Ticket Resolved', - entity: 'TKT-1042', - user: 'Alice Smith', - timestamp: new Date().toISOString(), - status: 'success', - }, - ]; - } catch (error) { - console.error('DashboardService error:', error); - throw new Error('Failed to load recent activity data.'); - } - } -} - -export const dashboardService = new DashboardService(); +// Deprecated - moved to features/dashboard/api/dashboard.api.ts diff --git a/apps/web/services/ticket.service.ts b/apps/web/services/ticket.service.ts index d6bc5ae..53ca881 100644 --- a/apps/web/services/ticket.service.ts +++ b/apps/web/services/ticket.service.ts @@ -1,184 +1 @@ -import { - Ticket, - TicketCreate, - TicketUpdate, - TicketResponse, - TicketStatus, - TicketAssignmentHistory, -} from '@/types/ticket'; - -// In Server Components, we must use absolute URLs if fetch is called directly, -// but ticket.service.ts is designed to be used in Client Components or API Routes where relative works, -// OR if used in Server Component page, we actually should not use relative URLs if it's called during SSR. -// Wait, the plan says: "Initial ticket data fetching will happen server-side in the page component." -// If `page.tsx` is a Server Component and it calls `ticketService.getTickets()`, a relative URL `fetch('/api/tickets')` will FAIL in Next.js SSR. -// To fix this, we can either: -// 1. Pass the absolute URL in SSR. -// 2. Let Server Components call the FastAPI backend directly, BUT the plan explicitly states: -// "ticket.service.ts should communicate only with Next.js API routes." -// "Next.js API Routes remain the only frontend communication boundary." -// If we must call our own API route from a Server Component, we need the base URL. -// I will provide an optional baseUrl parameter or read from env. -// Let's use `process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'` for SSR safely. - -const getBaseUrl = () => { - if (typeof window !== 'undefined') { - return ''; // Client side, use relative - } - return process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; -}; - -class TicketService { - private async handleResponse(response: Response): Promise { - if (!response.ok) { - let errorData; - try { - errorData = await response.json(); - } catch { - errorData = {}; - } - throw new Error(errorData.error || errorData.detail || 'An error occurred during the request.'); - } - - // For 204 No Content - if (response.status === 204) { - return {} as T; - } - - return response.json(); - } - - async getTickets( - params: { - page?: number; - limit?: number; - status?: string; - priority?: string; - search?: string; - } = {}, - headers?: HeadersInit - ): Promise { - const searchParams = new URLSearchParams(); - - if (params.page !== undefined) searchParams.set('page', params.page.toString()); - if (params.limit !== undefined) searchParams.set('limit', params.limit.toString()); - if (params.status) searchParams.set('status', params.status); - if (params.priority) searchParams.set('priority', params.priority); - if (params.search) searchParams.set('search', params.search); - - const queryString = searchParams.toString(); - const basePath = `${getBaseUrl()}/api/tickets`; - const url = queryString ? `${basePath}?${queryString}` : basePath; - - const response = await fetch(url, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - ...headers, - }, - }); - - return this.handleResponse(response); - } - - async getTicket(id: string, headers?: HeadersInit): Promise { - const response = await fetch(`${getBaseUrl()}/api/tickets/${id}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - ...headers, - }, - }); - - return this.handleResponse(response); - } - - async createTicket(ticket: TicketCreate): Promise { - const response = await fetch('/api/tickets', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(ticket), - }); - - return this.handleResponse(response); - } - - async updateTicket(id: string, ticket: TicketUpdate): Promise { - const response = await fetch(`/api/tickets/${id}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(ticket), - }); - - return this.handleResponse(response); - } - - async deleteTicket(id: string): Promise { - const response = await fetch(`/api/tickets/${id}`, { - method: 'DELETE', - }); - - if (!response.ok) { - let errorData; - try { - errorData = await response.json(); - } catch { - errorData = {}; - } - throw new Error(errorData.error || errorData.detail || 'Failed to delete ticket.'); - } - } - - async assignTicket(id: string, technician_id: string): Promise { - const response = await fetch(`/api/tickets/${id}/assign`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ technician_id }), - }); - - return this.handleResponse(response); - } - - async unassignTicket(id: string): Promise { - const response = await fetch(`/api/tickets/${id}/unassign`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - }, - }); - - return this.handleResponse(response); - } - - async getAssignmentHistory(id: string, headers?: HeadersInit): Promise { - const response = await fetch(`${getBaseUrl()}/api/tickets/${id}/assignment-history`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - ...headers, - }, - }); - - return this.handleResponse(response); - } - - async updateTicketStatus(id: string, status: TicketStatus): Promise { - const response = await fetch(`/api/tickets/${id}/status`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ status }), - }); - - return this.handleResponse(response); - } -} - -export const ticketService = new TicketService(); +// Deprecated - moved to features/tickets/api/tickets.api.ts diff --git a/apps/web/services/user.service.ts b/apps/web/services/user.service.ts new file mode 100644 index 0000000..4efbb3f --- /dev/null +++ b/apps/web/services/user.service.ts @@ -0,0 +1 @@ +// Deprecated - moved to features/users/api/users.api.ts diff --git a/apps/web/store/useAuthStore.ts b/apps/web/store/useAuthStore.ts index 041fcb1..089bdac 100644 --- a/apps/web/store/useAuthStore.ts +++ b/apps/web/store/useAuthStore.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import type { User, AuthState as AuthStateType } from '../types/auth'; +import type { User, AuthState as AuthStateType } from '@/features/auth'; interface AuthStore extends AuthStateType { setUser: (user: User | null) => void; diff --git a/apps/web/types/auth.ts b/apps/web/types/auth.ts index 5e60791..1379425 100644 --- a/apps/web/types/auth.ts +++ b/apps/web/types/auth.ts @@ -1,30 +1 @@ -export type Role = "ADMIN" | "TECHNICIAN" | "EMPLOYEE"; - -export interface User { - id: string; - full_name: string; - email: string; - role: Role; - is_active: boolean; - created_at: string; - updated_at: string; -} - -export interface LoginCredentials { - email: string; - password: string; -} - -// TokenResponse represents backend response only. -export interface TokenResponse { - access_token: string; - refresh_token: string; - token_type: string; -} - -// AuthState must NOT contain tokens. -export interface AuthState { - user: User | null; - isAuthenticated: boolean; - isLoading: boolean; -} +// Deprecated - moved to features/auth/types.ts diff --git a/apps/web/types/dashboard.ts b/apps/web/types/dashboard.ts index d6fb305..70cbc4d 100644 --- a/apps/web/types/dashboard.ts +++ b/apps/web/types/dashboard.ts @@ -1,22 +1 @@ -export interface DashboardMetric { - title: string; - value: string | number; - trend?: string; - trendDirection?: 'up' | 'down' | 'neutral'; - description?: string; - icon?: string; -} - -export interface ActivityItem { - id: string; - action: string; - entity: string; - user: string; - timestamp: string; - status?: 'success' | 'warning' | 'error' | 'info'; -} - -export interface DashboardSummary { - metrics: DashboardMetric[]; - recentActivity: ActivityItem[]; -} +// Deprecated - moved to features/dashboard/types.ts diff --git a/apps/web/types/ticket.ts b/apps/web/types/ticket.ts index 0d10ee7..f47cbdd 100644 --- a/apps/web/types/ticket.ts +++ b/apps/web/types/ticket.ts @@ -1,82 +1 @@ -export enum TicketStatus { - OPEN = 'OPEN', - IN_PROGRESS = 'IN_PROGRESS', - PENDING = 'PENDING', - RESOLVED = 'RESOLVED', - CLOSED = 'CLOSED', -} - -export enum TicketPriority { - LOW = 'LOW', - MEDIUM = 'MEDIUM', - HIGH = 'HIGH', - URGENT = 'URGENT', -} - -export enum AssignmentActionType { - ASSIGNED = 'ASSIGNED', - REASSIGNED = 'REASSIGNED', - UNASSIGNED = 'UNASSIGNED', -} - -export interface Ticket { - title: string; - description: string; - category?: string; - priority: TicketPriority; - id: string; - status: TicketStatus; - created_by: string; - assigned_to?: string; - created_at: string; - updated_at: string; -} - -export interface TicketCreate { - title: string; - description: string; - category?: string; - priority: TicketPriority; -} - -export interface TicketUpdate { - title?: string; - description?: string; - category?: string; - priority?: TicketPriority; - status?: TicketStatus; - assigned_to?: string; -} - -export interface TicketAssignment { - technician_id: string; -} - -export interface TicketAssignmentHistory { - id: string; - ticket_id: string; - assigned_from: string | null; - assigned_to: string | null; - action_type: AssignmentActionType; - created_at: string; - updated_at: string; -} - -export interface TicketFilters { - status?: TicketStatus; - priority?: TicketPriority; - search?: string; -} - -export interface TicketPagination { - page?: number; - limit?: number; -} - -export interface TicketResponse { - data: Ticket[]; - page: number; - limit: number; - total?: number; - pages?: number; -} +// Deprecated - moved to features/tickets/types.ts diff --git a/apps/web/types/user.ts b/apps/web/types/user.ts new file mode 100644 index 0000000..e9dd2d6 --- /dev/null +++ b/apps/web/types/user.ts @@ -0,0 +1 @@ +// Deprecated - moved to features/users/types.ts From 029dada9f073984c71edbae421928ca18674e586 Mon Sep 17 00:00:00 2001 From: itsjoxdev Date: Sat, 25 Jul 2026 15:21:58 +0300 Subject: [PATCH 3/3] refactor: remove unused project configuration files --- apps/web/components/auth/AuthGuard.tsx | 1 - apps/web/components/auth/LoginForm.tsx | 1 - apps/web/components/auth/LogoutButton.tsx | 1 - apps/web/components/dashboard/DashboardCharts.tsx | 1 - apps/web/components/dashboard/DashboardHeader.tsx | 1 - apps/web/components/dashboard/DashboardHeaderWrapper.tsx | 1 - apps/web/components/dashboard/RecentActivity.tsx | 1 - apps/web/components/dashboard/SummaryCards.tsx | 1 - apps/web/components/dashboard/index.ts | 1 - apps/web/components/tickets/TicketAssignment.tsx | 1 - apps/web/components/tickets/TicketFilters.tsx | 1 - apps/web/components/tickets/TicketPagination.tsx | 1 - apps/web/components/tickets/TicketPriorityBadge.tsx | 1 - apps/web/components/tickets/TicketStatusBadge.tsx | 1 - apps/web/components/tickets/TicketTable.tsx | 1 - apps/web/components/tickets/TicketWorkflow.tsx | 1 - apps/web/components/tickets/index.ts | 1 - apps/web/components/users/UserActions.tsx | 1 - apps/web/components/users/UserFilters.tsx | 1 - apps/web/components/users/UserForm.tsx | 1 - apps/web/components/users/UserPagination.tsx | 1 - apps/web/components/users/UserRoleBadge.tsx | 1 - apps/web/components/users/UserStatusBadge.tsx | 1 - apps/web/components/users/UserTable.tsx | 1 - apps/web/components/users/index.ts | 1 - apps/web/services/auth.service.ts | 1 - apps/web/services/dashboard.service.ts | 1 - apps/web/services/ticket.service.ts | 1 - apps/web/services/user.service.ts | 1 - apps/web/types/auth.ts | 1 - apps/web/types/dashboard.ts | 1 - apps/web/types/ticket.ts | 1 - apps/web/types/user.ts | 1 - 33 files changed, 33 deletions(-) delete mode 100644 apps/web/components/auth/AuthGuard.tsx delete mode 100644 apps/web/components/auth/LoginForm.tsx delete mode 100644 apps/web/components/auth/LogoutButton.tsx delete mode 100644 apps/web/components/dashboard/DashboardCharts.tsx delete mode 100644 apps/web/components/dashboard/DashboardHeader.tsx delete mode 100644 apps/web/components/dashboard/DashboardHeaderWrapper.tsx delete mode 100644 apps/web/components/dashboard/RecentActivity.tsx delete mode 100644 apps/web/components/dashboard/SummaryCards.tsx delete mode 100644 apps/web/components/dashboard/index.ts delete mode 100644 apps/web/components/tickets/TicketAssignment.tsx delete mode 100644 apps/web/components/tickets/TicketFilters.tsx delete mode 100644 apps/web/components/tickets/TicketPagination.tsx delete mode 100644 apps/web/components/tickets/TicketPriorityBadge.tsx delete mode 100644 apps/web/components/tickets/TicketStatusBadge.tsx delete mode 100644 apps/web/components/tickets/TicketTable.tsx delete mode 100644 apps/web/components/tickets/TicketWorkflow.tsx delete mode 100644 apps/web/components/tickets/index.ts delete mode 100644 apps/web/components/users/UserActions.tsx delete mode 100644 apps/web/components/users/UserFilters.tsx delete mode 100644 apps/web/components/users/UserForm.tsx delete mode 100644 apps/web/components/users/UserPagination.tsx delete mode 100644 apps/web/components/users/UserRoleBadge.tsx delete mode 100644 apps/web/components/users/UserStatusBadge.tsx delete mode 100644 apps/web/components/users/UserTable.tsx delete mode 100644 apps/web/components/users/index.ts delete mode 100644 apps/web/services/auth.service.ts delete mode 100644 apps/web/services/dashboard.service.ts delete mode 100644 apps/web/services/ticket.service.ts delete mode 100644 apps/web/services/user.service.ts delete mode 100644 apps/web/types/auth.ts delete mode 100644 apps/web/types/dashboard.ts delete mode 100644 apps/web/types/ticket.ts delete mode 100644 apps/web/types/user.ts diff --git a/apps/web/components/auth/AuthGuard.tsx b/apps/web/components/auth/AuthGuard.tsx deleted file mode 100644 index 13a8c86..0000000 --- a/apps/web/components/auth/AuthGuard.tsx +++ /dev/null @@ -1 +0,0 @@ -// Deprecated - moved to features/auth/components/AuthGuard.tsx diff --git a/apps/web/components/auth/LoginForm.tsx b/apps/web/components/auth/LoginForm.tsx deleted file mode 100644 index ac90e15..0000000 --- a/apps/web/components/auth/LoginForm.tsx +++ /dev/null @@ -1 +0,0 @@ -// Deprecated - moved to features/auth/components/LoginForm.tsx diff --git a/apps/web/components/auth/LogoutButton.tsx b/apps/web/components/auth/LogoutButton.tsx deleted file mode 100644 index 978563a..0000000 --- a/apps/web/components/auth/LogoutButton.tsx +++ /dev/null @@ -1 +0,0 @@ -// Deprecated - moved to features/auth/components/LogoutButton.tsx diff --git a/apps/web/components/dashboard/DashboardCharts.tsx b/apps/web/components/dashboard/DashboardCharts.tsx deleted file mode 100644 index 0b9e62b..0000000 --- a/apps/web/components/dashboard/DashboardCharts.tsx +++ /dev/null @@ -1 +0,0 @@ -// Deprecated diff --git a/apps/web/components/dashboard/DashboardHeader.tsx b/apps/web/components/dashboard/DashboardHeader.tsx deleted file mode 100644 index 0b9e62b..0000000 --- a/apps/web/components/dashboard/DashboardHeader.tsx +++ /dev/null @@ -1 +0,0 @@ -// Deprecated diff --git a/apps/web/components/dashboard/DashboardHeaderWrapper.tsx b/apps/web/components/dashboard/DashboardHeaderWrapper.tsx deleted file mode 100644 index 0b9e62b..0000000 --- a/apps/web/components/dashboard/DashboardHeaderWrapper.tsx +++ /dev/null @@ -1 +0,0 @@ -// Deprecated diff --git a/apps/web/components/dashboard/RecentActivity.tsx b/apps/web/components/dashboard/RecentActivity.tsx deleted file mode 100644 index 0b9e62b..0000000 --- a/apps/web/components/dashboard/RecentActivity.tsx +++ /dev/null @@ -1 +0,0 @@ -// Deprecated diff --git a/apps/web/components/dashboard/SummaryCards.tsx b/apps/web/components/dashboard/SummaryCards.tsx deleted file mode 100644 index 0b9e62b..0000000 --- a/apps/web/components/dashboard/SummaryCards.tsx +++ /dev/null @@ -1 +0,0 @@ -// Deprecated diff --git a/apps/web/components/dashboard/index.ts b/apps/web/components/dashboard/index.ts deleted file mode 100644 index 0b9e62b..0000000 --- a/apps/web/components/dashboard/index.ts +++ /dev/null @@ -1 +0,0 @@ -// Deprecated diff --git a/apps/web/components/tickets/TicketAssignment.tsx b/apps/web/components/tickets/TicketAssignment.tsx deleted file mode 100644 index 0b9e62b..0000000 --- a/apps/web/components/tickets/TicketAssignment.tsx +++ /dev/null @@ -1 +0,0 @@ -// Deprecated diff --git a/apps/web/components/tickets/TicketFilters.tsx b/apps/web/components/tickets/TicketFilters.tsx deleted file mode 100644 index 0b9e62b..0000000 --- a/apps/web/components/tickets/TicketFilters.tsx +++ /dev/null @@ -1 +0,0 @@ -// Deprecated diff --git a/apps/web/components/tickets/TicketPagination.tsx b/apps/web/components/tickets/TicketPagination.tsx deleted file mode 100644 index 0b9e62b..0000000 --- a/apps/web/components/tickets/TicketPagination.tsx +++ /dev/null @@ -1 +0,0 @@ -// Deprecated diff --git a/apps/web/components/tickets/TicketPriorityBadge.tsx b/apps/web/components/tickets/TicketPriorityBadge.tsx deleted file mode 100644 index 0b9e62b..0000000 --- a/apps/web/components/tickets/TicketPriorityBadge.tsx +++ /dev/null @@ -1 +0,0 @@ -// Deprecated diff --git a/apps/web/components/tickets/TicketStatusBadge.tsx b/apps/web/components/tickets/TicketStatusBadge.tsx deleted file mode 100644 index 0b9e62b..0000000 --- a/apps/web/components/tickets/TicketStatusBadge.tsx +++ /dev/null @@ -1 +0,0 @@ -// Deprecated diff --git a/apps/web/components/tickets/TicketTable.tsx b/apps/web/components/tickets/TicketTable.tsx deleted file mode 100644 index 0b9e62b..0000000 --- a/apps/web/components/tickets/TicketTable.tsx +++ /dev/null @@ -1 +0,0 @@ -// Deprecated diff --git a/apps/web/components/tickets/TicketWorkflow.tsx b/apps/web/components/tickets/TicketWorkflow.tsx deleted file mode 100644 index 0b9e62b..0000000 --- a/apps/web/components/tickets/TicketWorkflow.tsx +++ /dev/null @@ -1 +0,0 @@ -// Deprecated diff --git a/apps/web/components/tickets/index.ts b/apps/web/components/tickets/index.ts deleted file mode 100644 index 0b9e62b..0000000 --- a/apps/web/components/tickets/index.ts +++ /dev/null @@ -1 +0,0 @@ -// Deprecated diff --git a/apps/web/components/users/UserActions.tsx b/apps/web/components/users/UserActions.tsx deleted file mode 100644 index 0b9e62b..0000000 --- a/apps/web/components/users/UserActions.tsx +++ /dev/null @@ -1 +0,0 @@ -// Deprecated diff --git a/apps/web/components/users/UserFilters.tsx b/apps/web/components/users/UserFilters.tsx deleted file mode 100644 index 0b9e62b..0000000 --- a/apps/web/components/users/UserFilters.tsx +++ /dev/null @@ -1 +0,0 @@ -// Deprecated diff --git a/apps/web/components/users/UserForm.tsx b/apps/web/components/users/UserForm.tsx deleted file mode 100644 index 0b9e62b..0000000 --- a/apps/web/components/users/UserForm.tsx +++ /dev/null @@ -1 +0,0 @@ -// Deprecated diff --git a/apps/web/components/users/UserPagination.tsx b/apps/web/components/users/UserPagination.tsx deleted file mode 100644 index 0b9e62b..0000000 --- a/apps/web/components/users/UserPagination.tsx +++ /dev/null @@ -1 +0,0 @@ -// Deprecated diff --git a/apps/web/components/users/UserRoleBadge.tsx b/apps/web/components/users/UserRoleBadge.tsx deleted file mode 100644 index 0b9e62b..0000000 --- a/apps/web/components/users/UserRoleBadge.tsx +++ /dev/null @@ -1 +0,0 @@ -// Deprecated diff --git a/apps/web/components/users/UserStatusBadge.tsx b/apps/web/components/users/UserStatusBadge.tsx deleted file mode 100644 index 0b9e62b..0000000 --- a/apps/web/components/users/UserStatusBadge.tsx +++ /dev/null @@ -1 +0,0 @@ -// Deprecated diff --git a/apps/web/components/users/UserTable.tsx b/apps/web/components/users/UserTable.tsx deleted file mode 100644 index 0b9e62b..0000000 --- a/apps/web/components/users/UserTable.tsx +++ /dev/null @@ -1 +0,0 @@ -// Deprecated diff --git a/apps/web/components/users/index.ts b/apps/web/components/users/index.ts deleted file mode 100644 index 0b9e62b..0000000 --- a/apps/web/components/users/index.ts +++ /dev/null @@ -1 +0,0 @@ -// Deprecated diff --git a/apps/web/services/auth.service.ts b/apps/web/services/auth.service.ts deleted file mode 100644 index 5e793fc..0000000 --- a/apps/web/services/auth.service.ts +++ /dev/null @@ -1 +0,0 @@ -// Deprecated - moved to features/auth/api/auth.api.ts diff --git a/apps/web/services/dashboard.service.ts b/apps/web/services/dashboard.service.ts deleted file mode 100644 index 7d65228..0000000 --- a/apps/web/services/dashboard.service.ts +++ /dev/null @@ -1 +0,0 @@ -// Deprecated - moved to features/dashboard/api/dashboard.api.ts diff --git a/apps/web/services/ticket.service.ts b/apps/web/services/ticket.service.ts deleted file mode 100644 index 53ca881..0000000 --- a/apps/web/services/ticket.service.ts +++ /dev/null @@ -1 +0,0 @@ -// Deprecated - moved to features/tickets/api/tickets.api.ts diff --git a/apps/web/services/user.service.ts b/apps/web/services/user.service.ts deleted file mode 100644 index 4efbb3f..0000000 --- a/apps/web/services/user.service.ts +++ /dev/null @@ -1 +0,0 @@ -// Deprecated - moved to features/users/api/users.api.ts diff --git a/apps/web/types/auth.ts b/apps/web/types/auth.ts deleted file mode 100644 index 1379425..0000000 --- a/apps/web/types/auth.ts +++ /dev/null @@ -1 +0,0 @@ -// Deprecated - moved to features/auth/types.ts diff --git a/apps/web/types/dashboard.ts b/apps/web/types/dashboard.ts deleted file mode 100644 index 70cbc4d..0000000 --- a/apps/web/types/dashboard.ts +++ /dev/null @@ -1 +0,0 @@ -// Deprecated - moved to features/dashboard/types.ts diff --git a/apps/web/types/ticket.ts b/apps/web/types/ticket.ts deleted file mode 100644 index f47cbdd..0000000 --- a/apps/web/types/ticket.ts +++ /dev/null @@ -1 +0,0 @@ -// Deprecated - moved to features/tickets/types.ts diff --git a/apps/web/types/user.ts b/apps/web/types/user.ts deleted file mode 100644 index e9dd2d6..0000000 --- a/apps/web/types/user.ts +++ /dev/null @@ -1 +0,0 @@ -// Deprecated - moved to features/users/types.ts