From 3a39d484d84a3026f1f0f20009857d972adef0a4 Mon Sep 17 00:00:00 2001 From: wumibals Date: Sat, 28 Feb 2026 17:47:38 +0100 Subject: [PATCH 1/4] fix: minor changes --- .github/workflows/CI.yaml | 78 ++- .gitignore | 1 - backend/package.json | 1 + .../categories/categories.controller.spec.ts | 97 ---- .../src/categories/categories.service.spec.ts | 123 ----- .../src/categories/dto/create-category.dto.ts | 2 - backend/src/departments/department.entity.ts | 25 + .../src/departments/departments.controller.ts | 37 ++ backend/src/departments/departments.module.ts | 13 + .../src/departments/departments.service.ts | 45 ++ .../departments/dto/create-department.dto.ts | 16 + frontend/app/(auth)/layout.tsx | 35 +- frontend/app/(auth)/login/page.tsx | 192 +++---- frontend/app/(auth)/register/page.tsx | 306 ++++------- frontend/app/(dashboard)/assets/[id]/page.tsx | 131 +---- frontend/app/(dashboard)/assets/page.tsx | 471 ++++++---------- frontend/app/(dashboard)/dashboard/page.tsx | 83 +++ frontend/app/(dashboard)/departments/page.tsx | 512 +++++++++++------- frontend/app/(dashboard)/layout.tsx | 19 + frontend/app/(dashboard)/reports/page.tsx | 53 +- frontend/app/(dashboard)/settings/page.tsx | 5 +- frontend/app/(dashboard)/users/page.tsx | 227 ++++++++ frontend/app/dashboard-layout.ts | 166 ------ frontend/app/hooks/useUsers.ts | 43 -- frontend/app/providers.tsx | 12 +- frontend/app/users/page.tsx | 108 ---- .../components/assets/condition-badge.tsx | 51 +- .../components/assets/create-asset-modal.tsx | 163 ++++-- frontend/components/assets/status-badge.tsx | 46 +- .../components/assets/transfer-dialog.tsx | 141 ----- ...thInitializer.tsx => auth-initializer.tsx} | 6 +- frontend/components/layout/sidebar.tsx | 88 +++ frontend/components/layout/topbar.tsx | 63 +++ frontend/components/ui/Avatar.tsx | 22 - frontend/components/ui/button-simple.tsx | 42 -- frontend/components/ui/button.tsx | 101 ++-- frontend/components/ui/confirm-dialog.tsx | 176 ++---- frontend/components/ui/input.tsx | 52 +- frontend/eslint.config.mjs | 1 + frontend/lib/api.ts | 56 ++ frontend/lib/api/assets.ts | 222 ++++---- frontend/lib/api/client.ts | 150 +---- frontend/lib/api/reports.ts | 23 + frontend/lib/api/reportsApi.ts | 7 - frontend/lib/api/users.ts | 30 + frontend/lib/api/usersApi.ts | 17 - frontend/lib/auth-api.ts | 39 ++ frontend/lib/query/hooks/query.hook.ts | 161 ------ frontend/lib/query/hooks/useAsset.ts | 31 -- frontend/lib/query/hooks/useAssets.ts | 121 +++++ frontend/lib/query/hooks/useReports.ts | 10 + frontend/lib/query/hooks/useUsers.ts | 40 ++ frontend/lib/query/keys.ts | 4 + frontend/lib/query/mutations/auth.ts | 6 +- frontend/lib/query/types/asset.ts | 13 +- frontend/lib/users.ts | 13 - frontend/lib/utils.ts | 6 - frontend/middleware.ts | 54 +- frontend/package.json | 3 +- frontend/public/window.svg | 1 - frontend/store/auth.store.ts | 171 +++--- package-lock.json | 48 -- package.json | 5 - 63 files changed, 2203 insertions(+), 2781 deletions(-) delete mode 100644 .gitignore delete mode 100644 backend/src/categories/categories.controller.spec.ts delete mode 100644 backend/src/categories/categories.service.spec.ts create mode 100644 backend/src/departments/department.entity.ts create mode 100644 backend/src/departments/departments.controller.ts create mode 100644 backend/src/departments/departments.module.ts create mode 100644 backend/src/departments/departments.service.ts create mode 100644 backend/src/departments/dto/create-department.dto.ts create mode 100644 frontend/app/(dashboard)/dashboard/page.tsx create mode 100644 frontend/app/(dashboard)/layout.tsx create mode 100644 frontend/app/(dashboard)/users/page.tsx delete mode 100644 frontend/app/dashboard-layout.ts delete mode 100644 frontend/app/hooks/useUsers.ts delete mode 100644 frontend/app/users/page.tsx delete mode 100644 frontend/components/assets/transfer-dialog.tsx rename frontend/components/{auth/AuthInitializer.tsx => auth-initializer.tsx} (66%) create mode 100644 frontend/components/layout/sidebar.tsx create mode 100644 frontend/components/layout/topbar.tsx delete mode 100644 frontend/components/ui/Avatar.tsx delete mode 100644 frontend/components/ui/button-simple.tsx create mode 100644 frontend/lib/api.ts create mode 100644 frontend/lib/api/reports.ts delete mode 100644 frontend/lib/api/reportsApi.ts create mode 100644 frontend/lib/api/users.ts delete mode 100644 frontend/lib/api/usersApi.ts create mode 100644 frontend/lib/auth-api.ts delete mode 100644 frontend/lib/query/hooks/query.hook.ts create mode 100644 frontend/lib/query/hooks/useAssets.ts create mode 100644 frontend/lib/query/hooks/useReports.ts create mode 100644 frontend/lib/query/hooks/useUsers.ts delete mode 100644 frontend/lib/users.ts delete mode 100644 frontend/lib/utils.ts delete mode 100644 frontend/public/window.svg delete mode 100644 package-lock.json delete mode 100644 package.json diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index 4c8bf632..a483b29b 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -2,16 +2,16 @@ name: CI on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] env: CARGO_TERM_COLOR: always defaults: run: - working-directory: contracts # ๐Ÿ‘ˆ all cargo commands run in contracts/ + working-directory: contracts # ๐Ÿ‘ˆ all cargo commands run in contracts/ jobs: format: @@ -94,4 +94,74 @@ jobs: ${{ runner.os }}-cargo-build- ${{ runner.os }}-cargo- - name: Build all crates - run: cargo build --all --verbose \ No newline at end of file + run: cargo build --all --verbose + + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # BACKEND โ€” NestJS + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + backend: + name: Backend (NestJS) + runs-on: ubuntu-latest + + defaults: + run: + working-directory: backend + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: backend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Build (TypeScript check) + run: npm run build + + - name: Unit tests + run: npm run test -- --passWithNoTests + + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # FRONTEND โ€” Next.js + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + frontend: + name: Frontend (Next.js) + runs-on: ubuntu-latest + + defaults: + run: + working-directory: frontend + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Build + run: npm run build + env: + NEXT_PUBLIC_API_URL: http://localhost:3001 + + - name: Unit tests + run: npm run test -- --passWithNoTests diff --git a/.gitignore b/.gitignore deleted file mode 100644 index c2658d7d..00000000 --- a/.gitignore +++ /dev/null @@ -1 +0,0 @@ -node_modules/ diff --git a/backend/package.json b/backend/package.json index 3e47de34..cc9473e2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -36,6 +36,7 @@ "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^10.0.2", "@nestjs/websockets": "^10.4.15", + "@stellar/stellar-sdk": "^14.5.0", "@types/multer": "^2.0.0", "@types/speakeasy": "^2.0.10", "@types/uuid": "^10.0.0", diff --git a/backend/src/categories/categories.controller.spec.ts b/backend/src/categories/categories.controller.spec.ts deleted file mode 100644 index c59972a3..00000000 --- a/backend/src/categories/categories.controller.spec.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { CategoriesController } from './categories.controller'; -import { CategoriesService } from './categories.service'; -import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; - -const mockCategory = { id: 'uuid-1', name: 'Electronics' }; -const mockCategoryWithCount = { ...mockCategory, assetCount: 5 }; - -const mockService = { - findAll: jest.fn(), - findOne: jest.fn(), - create: jest.fn(), - remove: jest.fn(), -}; - -describe('CategoriesController', () => { - let controller: CategoriesController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [CategoriesController], - providers: [{ provide: CategoriesService, useValue: mockService }], - }) - .overrideGuard(JwtAuthGuard) - .useValue({ canActivate: () => true }) - .compile(); - - controller = module.get(CategoriesController); - jest.clearAllMocks(); - }); - - describe('findAll', () => { - it('should return all categories with asset counts', async () => { - mockService.findAll.mockResolvedValue([mockCategoryWithCount]); - - const result = await controller.findAll(); - - expect(mockService.findAll).toHaveBeenCalledTimes(1); - expect(result).toEqual([mockCategoryWithCount]); - }); - }); - - describe('findOne', () => { - it('should return a single category by id', async () => { - mockService.findOne.mockResolvedValue(mockCategory); - - const result = await controller.findOne('uuid-1'); - - expect(mockService.findOne).toHaveBeenCalledWith('uuid-1'); - expect(result).toEqual(mockCategory); - }); - - it('should propagate exceptions from the service', async () => { - mockService.findOne.mockRejectedValue(new Error('Category not found')); - - await expect(controller.findOne('missing-id')).rejects.toThrow('Category not found'); - }); - }); - - describe('create', () => { - const dto = { name: 'Electronics' }; - - it('should create and return a new category', async () => { - mockService.create.mockResolvedValue(mockCategory); - - const result = await controller.create(dto); - - expect(mockService.create).toHaveBeenCalledWith(dto); - expect(result).toEqual(mockCategory); - }); - - it('should propagate conflict exceptions from the service', async () => { - mockService.create.mockRejectedValue(new Error('A category with this name already exists')); - - await expect(controller.create(dto)).rejects.toThrow( - 'A category with this name already exists', - ); - }); - }); - - describe('remove', () => { - it('should call service.remove with the correct id', async () => { - mockService.remove.mockResolvedValue(undefined); - - const result = await controller.remove('uuid-1'); - - expect(mockService.remove).toHaveBeenCalledWith('uuid-1'); - expect(result).toBeUndefined(); - }); - - it('should propagate exceptions from the service', async () => { - mockService.remove.mockRejectedValue(new Error('Category not found')); - - await expect(controller.remove('missing-id')).rejects.toThrow('Category not found'); - }); - }); -}); \ No newline at end of file diff --git a/backend/src/categories/categories.service.spec.ts b/backend/src/categories/categories.service.spec.ts deleted file mode 100644 index fe50a500..00000000 --- a/backend/src/categories/categories.service.spec.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { NotFoundException, ConflictException } from '@nestjs/common'; -import { CategoriesService } from './categories.service'; -import { Category } from './category.entity'; - -const mockCategory: Category = { - id: 'uuid-1', - name: 'Electronics', -} as Category; - -const mockRepo = { - query: jest.fn(), - findOne: jest.fn(), - create: jest.fn(), - save: jest.fn(), - remove: jest.fn(), -}; - -describe('CategoriesService', () => { - let service: CategoriesService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - CategoriesService, - { provide: getRepositoryToken(Category), useValue: mockRepo }, - ], - }).compile(); - - service = module.get(CategoriesService); - jest.clearAllMocks(); - }); - - describe('findAll', () => { - it('should return categories with numeric assetCount', async () => { - const rawRows = [ - { ...mockCategory, assetCount: '3' }, - { id: 'uuid-2', name: 'Furniture', assetCount: '0' }, - ]; - mockRepo.query.mockResolvedValue(rawRows); - - const result = await service.findAll(); - - expect(mockRepo.query).toHaveBeenCalledTimes(1); - expect(result).toEqual([ - { ...mockCategory, assetCount: 3 }, - { id: 'uuid-2', name: 'Furniture', assetCount: 0 }, - ]); - expect(typeof result[0].assetCount).toBe('number'); - }); - - it('should return an empty array when no categories exist', async () => { - mockRepo.query.mockResolvedValue([]); - const result = await service.findAll(); - expect(result).toEqual([]); - }); - }); - - describe('findOne', () => { - it('should return a category when found', async () => { - mockRepo.findOne.mockResolvedValue(mockCategory); - - const result = await service.findOne('uuid-1'); - - expect(mockRepo.findOne).toHaveBeenCalledWith({ where: { id: 'uuid-1' } }); - expect(result).toEqual(mockCategory); - }); - - it('should throw NotFoundException when category does not exist', async () => { - mockRepo.findOne.mockResolvedValue(null); - - await expect(service.findOne('missing-id')).rejects.toThrow(NotFoundException); - await expect(service.findOne('missing-id')).rejects.toThrow('Category not found'); - }); - }); - - describe('create', () => { - const dto = { name: 'Electronics' }; - - it('should create and return a new category', async () => { - mockRepo.findOne.mockResolvedValue(null); - mockRepo.create.mockReturnValue(mockCategory); - mockRepo.save.mockResolvedValue(mockCategory); - - const result = await service.create(dto); - - expect(mockRepo.findOne).toHaveBeenCalledWith({ where: { name: dto.name } }); - expect(mockRepo.create).toHaveBeenCalledWith(dto); - expect(mockRepo.save).toHaveBeenCalledWith(mockCategory); - expect(result).toEqual(mockCategory); - }); - - it('should throw ConflictException when a category with the same name exists', async () => { - mockRepo.findOne.mockResolvedValue(mockCategory); - - await expect(service.create(dto)).rejects.toThrow(ConflictException); - await expect(service.create(dto)).rejects.toThrow( - 'A category with this name already exists', - ); - expect(mockRepo.save).not.toHaveBeenCalled(); - }); - }); - - describe('remove', () => { - it('should remove the category successfully', async () => { - mockRepo.findOne.mockResolvedValue(mockCategory); - mockRepo.remove.mockResolvedValue(undefined); - - await service.remove('uuid-1'); - - expect(mockRepo.findOne).toHaveBeenCalledWith({ where: { id: 'uuid-1' } }); - expect(mockRepo.remove).toHaveBeenCalledWith(mockCategory); - }); - - it('should throw NotFoundException if category does not exist', async () => { - mockRepo.findOne.mockResolvedValue(null); - - await expect(service.remove('missing-id')).rejects.toThrow(NotFoundException); - expect(mockRepo.remove).not.toHaveBeenCalled(); - }); - }); -}); \ No newline at end of file diff --git a/backend/src/categories/dto/create-category.dto.ts b/backend/src/categories/dto/create-category.dto.ts index 9501e61c..a1692d84 100644 --- a/backend/src/categories/dto/create-category.dto.ts +++ b/backend/src/categories/dto/create-category.dto.ts @@ -3,14 +3,12 @@ import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator'; export class CreateCategoryDto { @ApiProperty({ example: 'Laptop' }) - @ApiDescription('The name of the category') @IsString() @IsNotEmpty() @MaxLength(100) name: string; @ApiPropertyOptional({ example: 'Portable computing devices' }) - @ApiDescription('A brief description of the category') @IsString() @IsOptional() @MaxLength(500) diff --git a/backend/src/departments/department.entity.ts b/backend/src/departments/department.entity.ts new file mode 100644 index 00000000..9fc3cbef --- /dev/null +++ b/backend/src/departments/department.entity.ts @@ -0,0 +1,25 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity('departments') +export class Department { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + name: string; + + @Column({ nullable: true }) + description: string | null; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/departments/departments.controller.ts b/backend/src/departments/departments.controller.ts new file mode 100644 index 00000000..5fb1642a --- /dev/null +++ b/backend/src/departments/departments.controller.ts @@ -0,0 +1,37 @@ +import { Controller, Get, Post, Delete, Body, Param, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { DepartmentsService } from './departments.service'; +import { CreateDepartmentDto } from './dto/create-department.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; + +@ApiTags('Departments') +@ApiBearerAuth('JWT-auth') +@UseGuards(JwtAuthGuard) +@Controller('departments') +export class DepartmentsController { + constructor(private readonly service: DepartmentsService) {} + + @Get() + @ApiOperation({ summary: 'List all departments' }) + findAll() { + return this.service.findAll(); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a department by ID' }) + findOne(@Param('id') id: string) { + return this.service.findOne(id); + } + + @Post() + @ApiOperation({ summary: 'Create a new department' }) + create(@Body() dto: CreateDepartmentDto) { + return this.service.create(dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a department' }) + remove(@Param('id') id: string) { + return this.service.remove(id); + } +} diff --git a/backend/src/departments/departments.module.ts b/backend/src/departments/departments.module.ts new file mode 100644 index 00000000..a8cbe033 --- /dev/null +++ b/backend/src/departments/departments.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Department } from './department.entity'; +import { DepartmentsService } from './departments.service'; +import { DepartmentsController } from './departments.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Department])], + controllers: [DepartmentsController], + providers: [DepartmentsService], + exports: [DepartmentsService], +}) +export class DepartmentsModule {} diff --git a/backend/src/departments/departments.service.ts b/backend/src/departments/departments.service.ts new file mode 100644 index 00000000..c28343de --- /dev/null +++ b/backend/src/departments/departments.service.ts @@ -0,0 +1,45 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Department } from './department.entity'; +import { CreateDepartmentDto } from './dto/create-department.dto'; + +export interface DepartmentWithCount extends Department { + assetCount: number; +} + +@Injectable() +export class DepartmentsService { + constructor( + @InjectRepository(Department) + private readonly repo: Repository, + ) {} + + async findAll(): Promise { + const rows: (Department & { assetCount: string })[] = await this.repo.query(` + SELECT d.*, COALESCE(COUNT(a.id), 0)::int AS "assetCount" + FROM departments d + LEFT JOIN assets a ON a."departmentId" = d.id + GROUP BY d.id + ORDER BY d.name ASC + `); + return rows.map((r) => ({ ...r, assetCount: Number(r.assetCount) })); + } + + async findOne(id: string): Promise { + const dept = await this.repo.findOne({ where: { id } }); + if (!dept) throw new NotFoundException('Department not found'); + return dept; + } + + async create(dto: CreateDepartmentDto): Promise { + const existing = await this.repo.findOne({ where: { name: dto.name } }); + if (existing) throw new ConflictException('A department with this name already exists'); + return this.repo.save(this.repo.create(dto)); + } + + async remove(id: string): Promise { + const dept = await this.findOne(id); + await this.repo.remove(dept); + } +} diff --git a/backend/src/departments/dto/create-department.dto.ts b/backend/src/departments/dto/create-department.dto.ts new file mode 100644 index 00000000..d01cf4f5 --- /dev/null +++ b/backend/src/departments/dto/create-department.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator'; + +export class CreateDepartmentDto { + @ApiProperty({ example: 'Engineering' }) + @IsString() + @IsNotEmpty() + @MaxLength(100) + name: string; + + @ApiPropertyOptional({ example: 'Software engineering department' }) + @IsString() + @IsOptional() + @MaxLength(500) + description?: string; +} diff --git a/frontend/app/(auth)/layout.tsx b/frontend/app/(auth)/layout.tsx index b1d459a6..dd073cd4 100644 --- a/frontend/app/(auth)/layout.tsx +++ b/frontend/app/(auth)/layout.tsx @@ -1,24 +1,25 @@ -import { ReactNode } from 'react'; - -export default function AuthLayout({ - children, -}: { - children: ReactNode; -}) { +export default function AuthLayout({ children }: { children: React.ReactNode }) { return ( -
-
- {/* Logo/Wordmark */} -
-
-

AssetsUp

-

Asset Management System

+
+
+ {/* Logo / Brand */} +
+
+ + +
+

AssetsUp

+

Asset & Inventory Management

-
-
-
+ {/* Card */} +
{children}
diff --git a/frontend/app/(auth)/login/page.tsx b/frontend/app/(auth)/login/page.tsx index 981be549..631850a1 100644 --- a/frontend/app/(auth)/login/page.tsx +++ b/frontend/app/(auth)/login/page.tsx @@ -1,135 +1,107 @@ -"use client"; +'use client'; -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import { useLoginMutation } from "@/lib/query/mutations/auth"; -import { useAuthStore } from "@/store/auth.store"; -import { Button } from "@/components/ui/button"; +import { Suspense } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import Link from 'next/link'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { useAuthStore } from '@/store/auth.store'; -// Zod schema for login validation -const loginSchema = z.object({ - email: z.string().email("Invalid email address"), - password: z.string().min(1, "Password is required"), +const schema = z.object({ + email: z.string().email('Enter a valid email address'), + password: z.string().min(1, 'Password is required'), }); -type LoginFormData = z.infer; +type FormValues = z.infer; -export default function LoginPage() { +function LoginForm() { const router = useRouter(); - const { setAuth } = useAuthStore(); - const [apiError, setApiError] = useState(""); + const searchParams = useSearchParams(); + const { login, isLoading } = useAuthStore(); const { register, handleSubmit, - formState: { errors }, setError, - } = useForm({ - resolver: zodResolver(loginSchema), - }); - - const loginMutation = useLoginMutation({ - onSuccess: (data) => { - setAuth(data.token, data.user); - router.push("/dashboard"); - }, - onError: (error: any) => { - if (error.statusCode === 401) { - setApiError("Invalid email or password"); - } else if (error.errors) { - // Handle field-specific errors from API - Object.entries(error.errors).forEach(([field, messages]) => { - setError(field as keyof LoginFormData, { - message: Array.isArray(messages) ? messages[0] : messages, - }); - }); - } else { - setApiError(error.message || "Login failed"); - } - }, - }); + formState: { errors }, + } = useForm({ resolver: zodResolver(schema) }); - const onSubmit = (data: LoginFormData) => { - setApiError(""); - loginMutation.mutate(data); + const onSubmit = async (values: FormValues) => { + try { + await login(values); + const redirect = searchParams.get('redirect') || '/dashboard'; + router.push(redirect); + } catch (err: unknown) { + const message = + (err as { response?: { data?: { message?: string } } })?.response?.data?.message || + 'Something went wrong. Please try again.'; + setError('root', { message }); + } }; return ( -
-
-

Sign in to your account

-

- Or{" "} - - create a new account - -

-
+ <> +

Welcome back

+

Sign in to your account to continue

-
- {/* Email Field */} -
- -
- - {errors.email && ( -

{errors.email.message}

- )} -
-
+ + - {/* Password Field */} -
- -
- - {errors.password && ( -

{errors.password.message}

- )} +
+
+ + + Forgot password? +
+
- {/* API Error */} - {apiError && ( -
-
{apiError}
-
+ {errors.root && ( +

+ {errors.root.message} +

)} - {/* Submit Button */} -
- -
+ -
+ +

+ Don't have an account?{' '} + + Create one + +

+ + ); +} + +export default function LoginPage() { + return ( + + + ); } diff --git a/frontend/app/(auth)/register/page.tsx b/frontend/app/(auth)/register/page.tsx index 93fe3d5c..2cf6d483 100644 --- a/frontend/app/(auth)/register/page.tsx +++ b/frontend/app/(auth)/register/page.tsx @@ -1,232 +1,110 @@ -"use client"; - -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import { useRegisterMutation, useLoginMutation } from "@/lib/query/mutations/auth"; -import { useAuthStore } from "@/store/auth.store"; -import { Button } from "@/components/ui/button"; - -// Zod schema for registration validation -const registerSchema = z - .object({ - firstName: z.string().min(1, "First name is required"), - lastName: z.string().min(1, "Last name is required"), - email: z.string().email("Invalid email address"), - password: z.string().min(8, "Password must be at least 8 characters"), - confirmPassword: z.string().min(1, "Please confirm your password"), - }) - .refine((data) => data.password === data.confirmPassword, { - message: "Passwords don't match", - path: ["confirmPassword"], - }); - -type RegisterFormData = z.infer; +'use client'; + +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { useAuthStore } from '@/store/auth.store'; + +const schema = z.object({ + firstName: z.string().min(1, 'First name is required').max(50), + lastName: z.string().min(1, 'Last name is required').max(50), + email: z.string().email('Enter a valid email address'), + password: z + .string() + .min(8, 'Password must be at least 8 characters'), +}); + +type FormValues = z.infer; export default function RegisterPage() { const router = useRouter(); - const { setAuth } = useAuthStore(); - const [apiError, setApiError] = useState(""); - - const [password, setPassword] = useState(""); + const { register: registerUser, isLoading } = useAuthStore(); const { register, handleSubmit, - formState: { errors }, setError, - } = useForm({ - resolver: zodResolver(registerSchema), - }); - - const registerMutation = useRegisterMutation({ - onSuccess: (data) => { - // Auto-login after successful registration - loginMutation.mutate({ - email: data.user.email, - password: password, // Use the password from form state - }); - }, - onError: (error: any) => { - if (error.errors) { - // Handle field-specific errors from API - Object.entries(error.errors).forEach(([field, messages]) => { - setError(field as keyof RegisterFormData, { - message: Array.isArray(messages) ? messages[0] : messages, - }); - }); - } else { - setApiError(error.message || "Registration failed"); - } - }, - }); - - const loginMutation = useLoginMutation({ - onSuccess: (data) => { - setAuth(data.token, data.user); - router.push("/dashboard"); - }, - onError: (error: any) => { - setApiError("Registration successful but login failed. Please try logging in manually."); - }, - }); - - const onSubmit = (data: RegisterFormData) => { - setApiError(""); - setPassword(data.password); // Store password for auto-login - - const { confirmPassword, ...registerData } = data; - - registerMutation.mutate({ - ...registerData, - name: `${data.firstName} ${data.lastName}`, - }); + formState: { errors }, + } = useForm({ resolver: zodResolver(schema) }); + + const onSubmit = async (values: FormValues) => { + try { + await registerUser(values); + router.push('/dashboard'); + } catch (err: unknown) { + const message = + (err as { response?: { data?: { message?: string } } })?.response?.data?.message || + 'Something went wrong. Please try again.'; + setError('root', { message }); + } }; return ( -
-
-

Create your account

-

- Already have an account?{" "} - - Sign in - -

-
- -
- {/* Name Fields */} -
-
- -
- - {errors.firstName && ( -

{errors.firstName.message}

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

{errors.lastName.message}

- )} -
-
+ <> +

Create your account

+

Start managing your assets today

+ + +
+ +
- {/* Email Field */} -
- -
- - {errors.email && ( -

{errors.email.message}

- )} -
-
- - {/* Password Fields */} -
-
- -
- - {errors.password && ( -

{errors.password.message}

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

{errors.confirmPassword.message}

- )} -
-
-
- - {/* API Error */} - {apiError && ( -
-
{apiError}
-
+ + + + + {errors.root && ( +

+ {errors.root.message} +

)} - {/* Submit Button */} -
- -
+ -
+ +

+ Already have an account?{' '} + + Sign in + +

+ ); } diff --git a/frontend/app/(dashboard)/assets/[id]/page.tsx b/frontend/app/(dashboard)/assets/[id]/page.tsx index c53d409d..c033518b 100644 --- a/frontend/app/(dashboard)/assets/[id]/page.tsx +++ b/frontend/app/(dashboard)/assets/[id]/page.tsx @@ -1,3 +1,4 @@ +// frontend/app/(public)/assets/[id]/page.tsx "use client"; import { useState } from "react"; @@ -8,18 +9,13 @@ import { Button } from "@/components/ui/button"; import { StatusBadge } from "@/components/assets/status-badge"; import { ConditionBadge } from "@/components/assets/condition-badge"; import { useAsset, useAssetHistory } from "@/lib/query/hooks/useAsset"; -import { useAuthStore } from "@/store/auth.store"; -import { TransferAssetDialog } from "@/components/assets/transfer-dialog"; -import { MoveHorizontal } from "lucide-react"; -type Tab = "overview" | "history" | "documents"; +type Tab = "overview" | "history"; export default function AssetDetailPage() { const { id } = useParams<{ id: string }>(); const router = useRouter(); const [tab, setTab] = useState("overview"); - const [isTransferOpen, setIsTransferOpen] = useState(false); - const { user } = useAuthStore(); const { data: asset, isLoading } = useAsset(id); const { data: history = [] } = useAssetHistory(id); @@ -46,7 +42,6 @@ export default function AssetDetailPage() { const tabs: { key: Tab; label: string; icon: React.ReactNode }[] = [ { key: "overview", label: "Overview", icon: }, { key: "history", label: "History", icon: }, - { key: "documents", label: "Documents", icon: }, ]; return ( @@ -78,16 +73,6 @@ export default function AssetDetailPage() {
- - {(user?.role === 'ADMIN' || user?.role === 'MANAGER') && ( - - )}
@@ -97,10 +82,11 @@ export default function AssetDetailPage() {
)} - - {tab === "documents" && } - - {isTransferOpen && ( - setIsTransferOpen(false)} - /> - )} -
- ); -} - -// Asset Documents Section -import { - useAssetDocuments, - useUploadDocument, - useDeleteDocument, -} from "@/lib/query/hooks/useAsset"; - -function AssetDocumentsSection({ assetId }: { assetId: string }) { - const { data: documents = [], isLoading } = useAssetDocuments(assetId); - const uploadMutation = useUploadDocument(assetId); - const deleteMutation = useDeleteDocument(assetId); - const [file, setFile] = useState(null); - const [name, setName] = useState(""); - - const handleUpload = (e: React.FormEvent) => { - e.preventDefault(); - if (file) { - uploadMutation.mutate({ file, name: name || file.name }); - setFile(null); - setName(""); - } - }; - - return ( -
-

- Asset Documents -

-
- setFile(e.target.files?.[0] || null)} - className="border rounded px-2 py-1 text-sm" - /> - setName(e.target.value)} - className="border rounded px-2 py-1 text-sm" - /> - -
- {isLoading ? ( -
Loading documents...
- ) : documents.length === 0 ? ( -
No documents uploaded yet.
- ) : ( -
    - {documents.map((doc) => ( -
  • -
    - - {doc.name} - - {doc.type} - - {(doc.size / 1024).toFixed(1)} KB - - - Uploaded by {doc.uploadedBy?.firstName}{" "} - {doc.uploadedBy?.lastName} - - - {format(new Date(doc.createdAt), "MMM d, yyyy")} - -
    - -
  • - ))} -
- )}
); } diff --git a/frontend/app/(dashboard)/assets/page.tsx b/frontend/app/(dashboard)/assets/page.tsx index 6e808bba..3209bdda 100644 --- a/frontend/app/(dashboard)/assets/page.tsx +++ b/frontend/app/(dashboard)/assets/page.tsx @@ -1,327 +1,208 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import { useRouter } from "next/navigation"; -import { Search, Plus, ChevronLeft, ChevronRight } from "lucide-react"; +import { Plus, Search, SlidersHorizontal } from "lucide-react"; import { Button } from "@/components/ui/button"; import { StatusBadge } from "@/components/assets/status-badge"; import { ConditionBadge } from "@/components/assets/condition-badge"; -import { useAssets } from "@/lib/query/hooks/useAsset"; -import { AssetStatus } from "@/lib/query/types/asset"; +import { CreateAssetModal } from "@/components/assets/create-asset-modal"; +import { useAssets } from "@/lib/query/hooks/useAssets"; +import { AssetStatus, AssetCondition } from "@/lib/query/types/asset"; -type SortField = "assetId" | "name" | "category" | "status" | "condition" | "department" | "assignedTo"; -type SortOrder = "asc" | "desc"; +const STATUS_OPTIONS = ["All", ...Object.values(AssetStatus)]; export default function AssetsPage() { const router = useRouter(); + const [showModal, setShowModal] = useState(false); const [search, setSearch] = useState(""); - const [debouncedSearch, setDebouncedSearch] = useState(""); - const [statusFilter, setStatusFilter] = useState(""); - const [currentPage, setCurrentPage] = useState(1); - const [sortField, setSortField] = useState("assetId"); - const [sortOrder, setSortOrder] = useState("asc"); - - const itemsPerPage = 10; - - // Debounce search - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearch(search); - setCurrentPage(1); // Reset to page 1 when search changes - }, 300); - - return () => clearTimeout(timer); - }, [search]); - - // Reset to page 1 when filter changes - useEffect(() => { - setCurrentPage(1); - }, [statusFilter]); - - const { data, isLoading, error } = useAssets({ - page: currentPage, - limit: itemsPerPage, - search: debouncedSearch, - status: statusFilter || undefined, - sortBy: sortField, - sortOrder, + const [status, setStatus] = useState(""); + const [page, setPage] = useState(1); + + const { data, isLoading, refetch } = useAssets({ + search: search || undefined, + status: (status as AssetStatus) || undefined, + page, + limit: 20, }); - const handleSort = (field: SortField) => { - if (sortField === field) { - setSortOrder(sortOrder === "asc" ? "desc" : "asc"); - } else { - setSortField(field); - setSortOrder("asc"); - } - }; - - const handleRowClick = (assetId: string) => { - router.push(`/assets/${assetId}`); - }; - - const handlePreviousPage = () => { - setCurrentPage((prev) => Math.max(1, prev - 1)); - }; - - const handleNextPage = () => { - setCurrentPage((prev) => Math.min(data?.totalPages || 1, prev + 1)); - }; - - if (error) { - return ( -
-

Error loading assets.

- -
- ); - } + const assets = data?.data ?? []; + const total = data?.total ?? 0; + const totalPages = Math.ceil(total / 20); return (
{/* Header */} -
-
-
-

Assets

-

- {data?.total || 0} total assets -

-
- +
+
+

Assets

+

+ {total > 0 + ? `${total} asset${total !== 1 ? "s" : ""} registered` + : "No assets yet"} +

+
{/* Filters */} -
-
- {/* Search */} -
-
- - setSearch(e.target.value)} - className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" - /> -
-
- - {/* Status Filter */} -
- -
+
+
+ + { + setSearch(e.target.value); + setPage(1); + }} + className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-900" + />
-
- {/* Loading State */} - {isLoading && ( -
-
-
-

Loading assets...

-
+
+ +
- )} +
{/* Table */} - {!isLoading && data && ( -
- {data.assets.length === 0 ? ( -
-

- {debouncedSearch || statusFilter - ? "No assets found matching your filters." - : "No assets registered yet."} -

- {!debouncedSearch && !statusFilter && ( - +
+
+ + + + + + + + + + + + + + {isLoading ? ( + + + + ) : assets.length === 0 ? ( + + + + ) : ( + assets.map((asset) => ( + router.push(`/assets/${asset.id}`)} + className="border-b border-gray-100 hover:bg-gray-50 cursor-pointer transition-colors" + > + + + + + + + + + )) )} + +
+ Asset ID + + Name + + Category + + Status + + Condition + + Department + + Assigned To +
+ Loading assets... +
+ {search || status + ? "No assets match your filters." + : 'No assets registered yet. Click "Register Asset" to get started.'} +
+ {asset.assetId} + + {asset.name} + + {asset.category?.name ?? "โ€”"} + + + + + + {asset.department?.name ?? "โ€”"} + + {asset.assignedTo ? ( + `${asset.assignedTo.name}` + ) : ( + Unassigned + )} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Page {page} of {totalPages} โ€” {total} total +

+
+ +
- ) : ( - <> -
- - - - - - - - - - - - - - {data.assets.map((asset) => ( - handleRowClick(asset.id)} - className="hover:bg-gray-50 cursor-pointer transition-colors" - > - - - - - - - - - ))} - -
- - - - - - - - - - - - - -
- {asset.assetId} - - {asset.name} - - {asset.category?.name || "โ€”"} - - - - - - {asset.department?.name || "โ€”"} - - {asset.assignedTo - ? `${asset.assignedTo.name}` - : "Unassigned"} -
-
+
+ )} +
- {/* Pagination */} - {data.totalPages > 1 && ( -
-
-
- Showing {((currentPage - 1) * itemsPerPage) + 1} to{" "} - {Math.min(currentPage * itemsPerPage, data.total)} of{" "} - {data.total} results -
-
- - - Page {currentPage} of {data.totalPages} - - -
-
-
- )} - - )} -
+ {showModal && ( + setShowModal(false)} + onSuccess={() => refetch()} + /> )}
); diff --git a/frontend/app/(dashboard)/dashboard/page.tsx b/frontend/app/(dashboard)/dashboard/page.tsx new file mode 100644 index 00000000..1df61765 --- /dev/null +++ b/frontend/app/(dashboard)/dashboard/page.tsx @@ -0,0 +1,83 @@ +'use client'; + +import Link from 'next/link'; +import { format } from 'date-fns'; +import { useAuthStore } from '@/store/auth.store'; +import { useAssets } from '@/lib/query/hooks/useAssets'; +import { StatusBadge } from '@/components/assets/status-badge'; +import { AssetStatus } from '@/lib/query/types/asset'; + +export default function DashboardPage() { + const user = useAuthStore((s) => s.user); + const { data: allAssets } = useAssets({ limit: 5 }); + const { data: activeAssets } = useAssets({ status: AssetStatus.ACTIVE, limit: 1 }); + const { data: assignedAssets } = useAssets({ status: AssetStatus.ASSIGNED, limit: 1 }); + + const total = allAssets?.total ?? 0; + const active = activeAssets?.total ?? 0; + const assigned = assignedAssets?.total ?? 0; + const recent = allAssets?.data ?? []; + + const stats = [ + { label: 'Total Assets', value: total }, + { label: 'Active', value: active }, + { label: 'Assigned', value: assigned }, + { label: 'In Maintenance', value: total - active - assigned }, + ]; + + return ( +
+
+

+ Welcome back{user ? `, ${user.firstName}` : ''} +

+

Here's an overview of your assets

+
+ + {/* Stat cards */} +
+ {stats.map(({ label, value }) => ( +
+

{label}

+

{value}

+
+ ))} +
+ + {/* Recent assets */} +
+
+

Recent Assets

+ + View all + +
+ + {recent.length === 0 ? ( +

+ No assets yet.{' '} + Register your first asset +

+ ) : ( +
+ {recent.map((asset) => ( + +
+

{asset.name}

+

+ {asset.assetId} ยท {asset.department?.name} ยท {format(new Date(asset.createdAt), 'MMM d')} +

+
+ + + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/app/(dashboard)/departments/page.tsx b/frontend/app/(dashboard)/departments/page.tsx index 20e9951a..9e0f9bbf 100644 --- a/frontend/app/(dashboard)/departments/page.tsx +++ b/frontend/app/(dashboard)/departments/page.tsx @@ -1,204 +1,334 @@ 'use client'; -import React, { useState } from 'react'; +import { useState } from 'react'; +import { Plus, Trash2, Building2, Tag, Package } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { - useDepartmentsList, - useCreateDepartment, - useDeleteDepartment, - useCategories, - useCreateCategory, - useDeleteCategory -} from '@/lib/query/hooks/query.hook'; + useDepartmentsList, + useCreateDepartment, + useDeleteDepartment, + useCategories, + useCreateCategory, + useDeleteCategory, +} from '@/lib/query/hooks/useAssets'; import { DepartmentWithCount, CategoryWithCount } from '@/lib/api/assets'; -import { Plus, Trash2, LayoutGrid, Tags, Loader2, AlertCircle } from 'lucide-react'; -import { toast } from 'react-toastify'; -type TabType = 'departments' | 'categories'; +type Tab = 'departments' | 'categories'; export default function DepartmentsPage() { - const [activeTab, setActiveTab] = useState('departments'); - const [isAdding, setIsAdding] = useState(false); - const [formData, setFormData] = useState({ name: '', description: '' }); - - const { data: departments, isLoading: isLoadingDepts } = useDepartmentsList(); - const { data: categories, isLoading: isLoadingCats } = useCategories(); - - const createDept = useCreateDepartment(); - const deleteDept = useDeleteDepartment(); - const createCat = useCreateCategory(); - const deleteCat = useDeleteCategory(); - - const handleCreate = async (e: React.FormEvent) => { - e.preventDefault(); - if (!formData.name.trim()) return; - - try { - if (activeTab === 'departments') { - await createDept.mutateAsync(formData); - } else { - await createCat.mutateAsync(formData); - } - toast.success(`${activeTab === 'departments' ? 'Department' : 'Category'} created successfully`); - setFormData({ name: '', description: '' }); - setIsAdding(false); - } catch (err: any) { - toast.error(err.message || `Failed to create ${activeTab}`); - } - }; - - const handleDelete = async (id: string, name: string) => { - const confirmMessage = `Are you sure you want to delete ${name}? Assets in this ${activeTab === 'departments' ? 'department' : 'category'} will need to be reassigned/recategorised.`; - if (!window.confirm(confirmMessage)) return; - - try { - if (activeTab === 'departments') { - await deleteDept.mutateAsync(id); - } else { - await deleteCat.mutateAsync(id); - } - toast.success(`${activeTab === 'departments' ? 'Department' : 'Category'} deleted successfully`); - } catch (err: any) { - toast.error(err.message || `Failed to delete ${activeTab}`); - } - }; - - const items = activeTab === 'departments' ? departments : categories; - const isLoading = activeTab === 'departments' ? isLoadingDepts : isLoadingCats; - - return ( -
-
-

Management

- -
- - -
+ const [tab, setTab] = useState('departments'); + + return ( +
+ {/* Header */} +
+

Organisation

+

Manage departments and asset categories

+
+ + {/* Tabs */} +
+ {([ + { key: 'departments' as Tab, label: 'Departments', icon: }, + { key: 'categories' as Tab, label: 'Categories', icon: }, + ] as const).map(({ key, label, icon }) => ( + + ))} +
+ + {tab === 'departments' && } + {tab === 'categories' && } +
+ ); +} + +// โ”€โ”€ Departments Tab โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function DepartmentsTab() { + const { data: departments = [], isLoading } = useDepartmentsList(); + const createDept = useCreateDepartment(); + const deleteDept = useDeleteDepartment(); + + const [showForm, setShowForm] = useState(false); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [formError, setFormError] = useState(''); + const [deleteTarget, setDeleteTarget] = useState(null); + + const handleCreate = async () => { + if (!name.trim()) { setFormError('Name is required'); return; } + setFormError(''); + try { + await createDept.mutateAsync({ name: name.trim(), description: description.trim() || undefined }); + setName(''); + setDescription(''); + setShowForm(false); + } catch (err: unknown) { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + setFormError(msg || 'Failed to create department.'); + } + }; + + const handleDelete = async () => { + if (!deleteTarget) return; + await deleteDept.mutateAsync(deleteTarget.id); + setDeleteTarget(null); + }; + + return ( +
+
+

+ {departments.length} department{departments.length !== 1 ? 's' : ''} +

+ +
+ + {/* Inline create form */} + {showForm && ( +
+

New Department

+
+ setName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleCreate()} + /> + setDescription(e.target.value)} + /> + {formError &&

{formError}

} +
+ +
+
+
+ )} + + {/* List */} + {isLoading ? ( +
Loading departments...
+ ) : departments.length === 0 ? ( + } + title="No departments yet" + message='Click "Add Department" to create your first one.' + /> + ) : ( +
+ {departments.map((dept) => ( + setDeleteTarget(dept)} + /> + ))} +
+ )} + + {deleteTarget && ( + setDeleteTarget(null)} + loading={deleteDept.isPending} + /> + )} +
+ ); +} + +// โ”€โ”€ Categories Tab โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function CategoriesTab() { + const { data: categories = [], isLoading } = useCategories(); + const createCat = useCreateCategory(); + const deleteCat = useDeleteCategory(); + + const [showForm, setShowForm] = useState(false); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [formError, setFormError] = useState(''); + const [deleteTarget, setDeleteTarget] = useState(null); -
-

- {activeTab} ({items?.length || 0}) -

- + const handleCreate = async () => { + if (!name.trim()) { setFormError('Name is required'); return; } + setFormError(''); + try { + await createCat.mutateAsync({ name: name.trim(), description: description.trim() || undefined }); + setName(''); + setDescription(''); + setShowForm(false); + } catch (err: unknown) { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + setFormError(msg || 'Failed to create category.'); + } + }; + + const handleDelete = async () => { + if (!deleteTarget) return; + await deleteCat.mutateAsync(deleteTarget.id); + setDeleteTarget(null); + }; + + return ( +
+
+

+ {categories.length} categor{categories.length !== 1 ? 'ies' : 'y'} +

+ +
+ + {/* Inline create form */} + {showForm && ( +
+

New Category

+
+ setName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleCreate()} + /> + setDescription(e.target.value)} + /> + {formError &&

{formError}

} +
+ +
+
+
+ )} - {isAdding && ( -
-
-
- - setFormData({ ...formData, name: e.target.value })} - className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all" - /> - {activeTab === 'departments' ? createDept.isError && ( -

- {createDept.error?.message} -

- ) : createCat.isError && ( -

- {createCat.error?.message} -

- )} -
-
- - setFormData({ ...formData, description: e.target.value })} - className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all" - /> -
-
-
- - -
-
- )} - - {isLoading ? ( -
- -

Loading {activeTab}...

-
- ) : ( -
- {items?.map((item: any) => ( -
-
-

{item.name}

- -
-

- {item.description || 'No description provided.'} -

-
- Asset Count - {item.assetCount || 0} -
-
- ))} - {!isLoading && items?.length === 0 && ( -
- -

No {activeTab} found. Create one to get started.

-
- )} -
- )} + {/* List */} + {isLoading ? ( +
Loading categories...
+ ) : categories.length === 0 ? ( + } + title="No categories yet" + message='Click "Add Category" to create your first one.' + /> + ) : ( +
+ {categories.map((cat) => ( + setDeleteTarget(cat)} + /> + ))}
- ); + )} + + {deleteTarget && ( + setDeleteTarget(null)} + loading={deleteCat.isPending} + /> + )} +
+ ); +} + +// โ”€โ”€ Shared components โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function EntityCard({ + name, + description, + count, + countLabel, + onDelete, +}: { + name: string; + description?: string | null; + count: number; + countLabel: string; + onDelete: () => void; +}) { + return ( +
+
+

{name}

+ {description && ( +

{description}

+ )} +
+ + {count} {countLabel}{count !== 1 ? 's' : ''} +
+
+ +
+ ); +} + +function EmptyState({ icon, title, message }: { icon: React.ReactNode; title: string; message: string }) { + return ( +
+
{icon}
+

{title}

+

{message}

+
+ ); } diff --git a/frontend/app/(dashboard)/layout.tsx b/frontend/app/(dashboard)/layout.tsx new file mode 100644 index 00000000..8e45bb04 --- /dev/null +++ b/frontend/app/(dashboard)/layout.tsx @@ -0,0 +1,19 @@ +// frontend/app/(dashboard)/layout.tsx +import { Sidebar } from "@/components/layout/sidebar"; +import { Topbar } from "@/components/layout/topbar"; + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ + +
+
{children}
+
+
+ ); +} diff --git a/frontend/app/(dashboard)/reports/page.tsx b/frontend/app/(dashboard)/reports/page.tsx index 108334d8..27f9f341 100644 --- a/frontend/app/(dashboard)/reports/page.tsx +++ b/frontend/app/(dashboard)/reports/page.tsx @@ -5,8 +5,8 @@ import Link from "next/link"; import { format } from "date-fns"; import { BarChart3, Package } from "lucide-react"; import { clsx } from "clsx"; -import { useAssets } from "@/lib/query/hooks/useAsset"; -import { Asset, AssetStatus } from "@/lib/query/types/asset"; +import { useReportsSummary } from "@/lib/query/hooks/useReports"; +import { AssetStatus } from "@/lib/query/types/asset"; import { StatusBadge } from "@/components/assets/status-badge"; const STATUS_COLORS: Record = { @@ -17,7 +17,7 @@ const STATUS_COLORS: Record = { }; export default function ReportsPage() { - const { data, isLoading } = useAssets({ page: 1, limit: 1000 }); + const { data, isLoading } = useReportsSummary(); if (isLoading) { return ( @@ -29,50 +29,7 @@ export default function ReportsPage() { if (!data) return null; - const assets = data?.assets ?? []; - const total = assets.length; - - const byStatus = assets.reduce>( - (acc, asset) => { - acc[asset.status] += 1; - return acc; - }, - { - [AssetStatus.ACTIVE]: 0, - [AssetStatus.ASSIGNED]: 0, - [AssetStatus.MAINTENANCE]: 0, - [AssetStatus.RETIRED]: 0, - }, - ); - - const byCategory = Object.values( - assets.reduce>((acc, asset) => { - const categoryName = asset.category?.name ?? "Uncategorized"; - if (!acc[categoryName]) { - acc[categoryName] = { name: categoryName, count: 0 }; - } - acc[categoryName].count += 1; - return acc; - }, {}), - ); - - const byDepartment = Object.values( - assets.reduce>((acc, asset) => { - const departmentName = asset.department?.name ?? "Unassigned"; - if (!acc[departmentName]) { - acc[departmentName] = { name: departmentName, count: 0 }; - } - acc[departmentName].count += 1; - return acc; - }, {}), - ); - - const recent = [...assets] - .sort( - (left, right) => - new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(), - ) - .slice(0, 10); + const { total, byStatus, byCategory, byDepartment, recent } = data; const statusItems = Object.entries(byStatus) as [AssetStatus, number][]; const topCategories = [...byCategory] @@ -230,7 +187,7 @@ export default function ReportsPage() {

) : (
- {recent.map((asset: Asset) => ( + {recent.map((asset) => ( ; export default function SettingsPage() { - const { user } = useAuthStore(); + const user = useAuthStore((s) => s.user); const updateProfile = useUpdateProfile(); const [profileSaved, setProfileSaved] = useState(false); const [passwordSaved, setPasswordSaved] = useState(false); diff --git a/frontend/app/(dashboard)/users/page.tsx b/frontend/app/(dashboard)/users/page.tsx new file mode 100644 index 00000000..c6013f4d --- /dev/null +++ b/frontend/app/(dashboard)/users/page.tsx @@ -0,0 +1,227 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { Search, Shield, UserCircle } from 'lucide-react'; +import { format } from 'date-fns'; +import { clsx } from 'clsx'; +import { useUsersList, useUpdateUserRole } from '@/lib/query/hooks/useUsers'; +import { useAuthStore } from '@/store/auth.store'; +import { AppUser, UserRole } from '@/lib/api/users'; + +const ROLES: UserRole[] = ['admin', 'manager', 'staff']; + +const roleConfig: Record = { + admin: { label: 'Admin', className: 'bg-purple-100 text-purple-700' }, + manager: { label: 'Manager', className: 'bg-blue-100 text-blue-700' }, + staff: { label: 'Staff', className: 'bg-gray-100 text-gray-600' }, +}; + +export default function UsersPage() { + const currentUser = useAuthStore((s) => s.user); + const [search, setSearch] = useState(''); + const [roleFilter, setRoleFilter] = useState(''); + + const { data: users = [], isLoading } = useUsersList(); + const updateRole = useUpdateUserRole(); + + // client-side filter (list is typically small) + const filtered = useMemo(() => { + const q = search.toLowerCase(); + return users.filter((u) => { + const matchSearch = + !q || + u.firstName.toLowerCase().includes(q) || + u.lastName.toLowerCase().includes(q) || + u.email.toLowerCase().includes(q); + const matchRole = !roleFilter || u.role === roleFilter; + return matchSearch && matchRole; + }); + }, [users, search, roleFilter]); + + const handleRoleChange = (user: AppUser, newRole: UserRole) => { + if (newRole === user.role) return; + updateRole.mutate({ id: user.id, role: newRole }); + }; + + return ( +
+ {/* Header */} +
+
+

Users

+

+ {users.length} member{users.length !== 1 ? 's' : ''} in your organisation +

+
+
+ + {/* Filters */} +
+
+ + setSearch(e.target.value)} + className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-900" + /> +
+ + +
+ + {/* Table */} +
+ + + + + + + + + + + {isLoading ? ( + + + + ) : filtered.length === 0 ? ( + + + + ) : ( + filtered.map((user) => { + const isCurrentUser = user.id === currentUser?.id; + const initials = `${user.firstName[0]}${user.lastName[0]}`.toUpperCase(); + + return ( + + {/* Avatar + name */} + + + {/* Email */} + + + {/* Role dropdown */} + + + {/* Joined */} + + + ); + }) + )} + +
UserEmailRoleJoined
Loading users...
+ +

+ {search || roleFilter ? 'No users match your filters.' : 'No users found.'} +

+
+
+
+ {initials} +
+
+

+ {user.firstName} {user.lastName} + {isCurrentUser && ( + (you) + )} +

+
+
+
{user.email} + handleRoleChange(user, role)} + /> + + {format(new Date(user.createdAt), 'MMM d, yyyy')} +
+ + {/* Role legend */} + {users.length > 0 && ( +
+

+ + Role permissions: +

+ {ROLES.map((r) => ( + + {roleConfig[r].label} + + ))} +
+ )} +
+
+ ); +} + +// โ”€โ”€ Role Dropdown โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function RoleDropdown({ + value, + disabled, + onChange, +}: { + value: UserRole; + disabled: boolean; + onChange: (role: UserRole) => void; +}) { + const config = roleConfig[value]; + + return ( +
+ + {/* Chevron icon overlay */} + + โ–พ + +
+ ); +} diff --git a/frontend/app/dashboard-layout.ts b/frontend/app/dashboard-layout.ts deleted file mode 100644 index 28c723ad..00000000 --- a/frontend/app/dashboard-layout.ts +++ /dev/null @@ -1,166 +0,0 @@ -// frontend/app/(dashboard)/layout.tsx -import { Sidebar } from '@/components/layout/sidebar'; -import { Topbar } from '@/components/layout/topbar'; - -export default function DashboardLayout({ children }: { children: React.ReactNode }) { -return ( -
- - -
-
{children}
-
-
-); -} - -// frontend/components/layout/topbar.tsx -'use client'; - -import { useRouter } from 'next/navigation'; -import { LogOut, User } from 'lucide-react'; -import { useAuthStore } from '@/store/auth.store'; - -export function Topbar() { -const router = useRouter(); -const { user, logout } = useAuthStore(); - -const handleLogout = async () => { -await logout(); -router.push('/login'); -}; - -const initials = user -? `${user.firstName[0]}${user.lastName[0]}`.toUpperCase() -: '?'; - -return ( -
-
- -
- {/* User info */} -
-
- {user ? ( - {initials} - ) : ( - - )} -
- {user && ( -
-

- {user.firstName} {user.lastName} -

-

{user.role}

-
- )} -
- - {/* Divider */} -
- - {/* Logout */} - -
-
- -); -} - -// frontend/components/layout/sidebar.tsx -"use client"; - -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { clsx } from "clsx"; -import { -LayoutDashboard, -Package, -Users, -Building2, -BarChart3, -Settings, -} from "lucide-react"; - -const navItems = [ -{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, -{ href: "/assets", label: "Assets", icon: Package }, -{ href: "/users", label: "Users", icon: Users }, -{ href: "/departments", label: "Organisation", icon: Building2 }, -{ href: "/reports", label: "Reports", icon: BarChart3 }, -]; - -export function Sidebar() { -const pathname = usePathname(); - -return ( - - -); -} diff --git a/frontend/app/hooks/useUsers.ts b/frontend/app/hooks/useUsers.ts deleted file mode 100644 index aadc2430..00000000 --- a/frontend/app/hooks/useUsers.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { getUsers, updateUserRole, updateProfile } from '../lib/api/usersApi'; -import { getReportsSummary } from '../lib/api/reportsApi'; -import { User, ReportSummary } from '../lib/types/users'; -import { useAuthStore } from '../store/authStore'; - -export function useUsersList() { - return useQuery({ - queryKey: ['users'], - queryFn: getUsers, - }); -} - -export function useUpdateUserRole() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ id, role }: { id: string; role: string }) => - updateUserRole(id, role), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['users'] }); - }, - }); -} - -export function useUpdateProfile() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ id, payload }: { id: string; payload: Partial }) => - updateProfile(id, payload), - onSuccess: (updatedUser) => { - // Update Zustand store to keep sidebar in sync - useAuthStore.getState().setUser(updatedUser); - queryClient.invalidateQueries({ queryKey: ['users'] }); - }, - }); -} - -export function useReportsSummary() { - return useQuery({ - queryKey: ['reportsSummary'], - queryFn: getReportsSummary, - }); -} diff --git a/frontend/app/providers.tsx b/frontend/app/providers.tsx index 22f8b8d6..ab91c89f 100644 --- a/frontend/app/providers.tsx +++ b/frontend/app/providers.tsx @@ -1,8 +1,7 @@ -'use client'; +"use client"; -import React from 'react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { AuthInitializer } from '@/components/auth/AuthInitializer'; +import React from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; const queryClient = new QueryClient({ defaultOptions: { @@ -15,9 +14,6 @@ const queryClient = new QueryClient({ export function Providers({ children }: { children: React.ReactNode }) { return ( - - - {children} - + {children} ); } diff --git a/frontend/app/users/page.tsx b/frontend/app/users/page.tsx deleted file mode 100644 index 287b41f0..00000000 --- a/frontend/app/users/page.tsx +++ /dev/null @@ -1,108 +0,0 @@ -'use client'; - -import React, { useState } from 'react'; -import { useUsers, useUpdateUserRole } from '../hooks/useUsers'; -import { Avatar } from '../../components/ui/Avatar'; - -export default function UsersPage() { - const { data: users = [], isLoading } = useUsers(); - const updateRole = useUpdateUserRole(); - - const [search, setSearch] = useState(''); - const [roleFilter, setRoleFilter] = useState(''); - - if (isLoading) return
Loading...
; - - const filteredUsers = users.filter((u: any) => - (u.name.toLowerCase().includes(search.toLowerCase()) || - u.email.toLowerCase().includes(search.toLowerCase())) && - (roleFilter ? u.role === roleFilter : true) - ); - - return ( -
-

Users Management

- -
- setSearch(e.target.value)} - className="border p-2" - /> - -
- - - - - - - - - - - - {filteredUsers.map((user: any) => ( - - - - - - - ))} - -
Avatar + NameEmailRoleJoined
- - {user.name}{' '} - {user.isCurrentUser && ( - - You - - )} - {user.email} - - {new Date(user.joinedAt).toLocaleDateString()}
- -
-

Role Legend

-
    -
  • - Admin โ€” full access -
  • -
  • - Manager โ€” manage teams -
  • -
  • - Staff โ€” standard user -
  • -
-
-
- ); -} diff --git a/frontend/components/assets/condition-badge.tsx b/frontend/components/assets/condition-badge.tsx index a0a3f65f..d21773f0 100644 --- a/frontend/components/assets/condition-badge.tsx +++ b/frontend/components/assets/condition-badge.tsx @@ -1,47 +1,18 @@ -"use client"; +import { clsx } from 'clsx'; +import { AssetCondition } from '@/lib/query/types/asset'; -import { AssetCondition } from "@/lib/query/types/asset"; - -const conditionConfig = { - [AssetCondition.NEW]: { - label: "New", - className: "bg-emerald-100 text-emerald-800 border-emerald-200", - }, - [AssetCondition.GOOD]: { - label: "Good", - className: "bg-green-100 text-green-800 border-green-200", - }, - [AssetCondition.FAIR]: { - label: "Fair", - className: "bg-blue-100 text-blue-800 border-blue-200", - }, - [AssetCondition.POOR]: { - label: "Poor", - className: "bg-orange-100 text-orange-800 border-orange-200", - }, - [AssetCondition.DAMAGED]: { - label: "Damaged", - className: "bg-red-100 text-red-800 border-red-200", - }, +const conditionConfig: Record = { + [AssetCondition.NEW]: { label: 'New', className: 'bg-emerald-100 text-emerald-700' }, + [AssetCondition.GOOD]: { label: 'Good', className: 'bg-green-100 text-green-700' }, + [AssetCondition.FAIR]: { label: 'Fair', className: 'bg-yellow-100 text-yellow-700' }, + [AssetCondition.POOR]: { label: 'Poor', className: 'bg-orange-100 text-orange-700' }, + [AssetCondition.DAMAGED]: { label: 'Damaged', className: 'bg-red-100 text-red-700' }, }; -interface ConditionBadgeProps { - condition: AssetCondition; -} - -export function ConditionBadge({ condition }: ConditionBadgeProps) { - const config = conditionConfig[condition]; - - if (!config) { - return ( - - {condition} - - ); - } - +export function ConditionBadge({ condition }: { condition: AssetCondition }) { + const config = conditionConfig[condition] ?? { label: condition, className: 'bg-gray-100 text-gray-500' }; return ( - + {config.label} ); diff --git a/frontend/components/assets/create-asset-modal.tsx b/frontend/components/assets/create-asset-modal.tsx index 9f455960..d6b5c76c 100644 --- a/frontend/components/assets/create-asset-modal.tsx +++ b/frontend/components/assets/create-asset-modal.tsx @@ -1,20 +1,21 @@ -'use client'; - -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; -import { X } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { useCreateAsset } from '@/lib/query/hooks/useAssets'; -import { useDepartments } from '@/lib/query/hooks/useAsset'; -import { useCategories } from '@/lib/query/hooks/useAssets'; -import { AssetStatus, AssetCondition } from '@/lib/query/types/asset'; +// frontend/components/assets/create-asset-modal.tsx +"use client"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useCreateAsset } from "@/lib/query/hooks/useAssets"; +import { useDepartments } from "@/lib/query/hooks/useAsset"; +import { useCategories } from "@/lib/query/hooks/useAssets"; +import { AssetStatus, AssetCondition } from "@/lib/query/types/asset"; const schema = z.object({ - name: z.string().min(1, 'Asset name is required'), - categoryId: z.string().min(1, 'Category is required'), - departmentId: z.string().min(1, 'Department is required'), + name: z.string().min(1, "Asset name is required"), + categoryId: z.string().min(1, "Category is required"), + departmentId: z.string().min(1, "Department is required"), serialNumber: z.string().optional(), manufacturer: z.string().optional(), model: z.string().optional(), @@ -44,7 +45,10 @@ export function CreateAssetModal({ onClose, onSuccess }: Props) { formState: { errors }, } = useForm({ resolver: zodResolver(schema), - defaultValues: { condition: AssetCondition.NEW, status: AssetStatus.ACTIVE }, + defaultValues: { + condition: AssetCondition.NEW, + status: AssetStatus.ACTIVE, + }, }); const onSubmit = async (values: FormValues) => { @@ -59,16 +63,18 @@ export function CreateAssetModal({ onClose, onSuccess }: Props) { location: values.location || undefined, condition: values.condition, status: values.status, - purchasePrice: values.purchasePrice ? Number(values.purchasePrice) : undefined, + purchasePrice: values.purchasePrice + ? Number(values.purchasePrice) + : undefined, notes: values.notes || undefined, }); onSuccess?.(); onClose(); } catch (err: unknown) { const message = - (err as { response?: { data?: { message?: string } } })?.response?.data?.message || - 'Failed to register asset.'; - setError('root', { message }); + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message || "Failed to register asset."; + setError("root", { message }); } }; @@ -80,81 +86,143 @@ export function CreateAssetModal({ onClose, onSuccess }: Props) { {/* Modal */}
-

Register New Asset

-
- +
{/* Category */}
- + - {errors.categoryId &&

{errors.categoryId.message}

} + {errors.categoryId && ( +

+ {errors.categoryId.message} +

+ )}
{/* Department */}
- + - {errors.departmentId &&

{errors.departmentId.message}

} + {errors.departmentId && ( +

+ {errors.departmentId.message} +

+ )}
- - + +
- - + +
{/* Condition */}
- +
- +
- +