diff --git a/backend/src/opsce/locations/entities/location.entity.ts b/backend/src/opsce/locations/entities/location.entity.ts new file mode 100644 index 00000000..05402a52 --- /dev/null +++ b/backend/src/opsce/locations/entities/location.entity.ts @@ -0,0 +1,54 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; + +export enum LocationType { + BUILDING = 'building', + FLOOR = 'floor', + ROOM = 'room', + ZONE = 'zone', +} + +@Entity('locations') +export class Location { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + name: string; + + @Column({ type: 'enum', enum: LocationType }) + type: LocationType; + + @Column({ nullable: true }) + address?: string; + + @Column({ type: 'jsonb', nullable: true }) + coordinates?: Record; + + @Column({ default: true }) + isActive: boolean; + + @Column({ nullable: true }) + parentId?: string; + + @ManyToOne(() => Location, (l) => l.children, { nullable: true }) + @JoinColumn({ name: 'parentId' }) + parent?: Location; + + @OneToMany(() => Location, (l) => l.parent) + children: Location[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/opsce/locations/locations.module.ts b/backend/src/opsce/locations/locations.module.ts new file mode 100644 index 00000000..ef807e66 --- /dev/null +++ b/backend/src/opsce/locations/locations.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Location } from './entities/location.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Location])], + exports: [TypeOrmModule], +}) +export class LocationsModule {} diff --git a/backend/src/opsce/opsce.module.ts b/backend/src/opsce/opsce.module.ts new file mode 100644 index 00000000..9dfd81f8 --- /dev/null +++ b/backend/src/opsce/opsce.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { UsersModule } from './users/users.module'; +import { LocationsModule } from './locations/locations.module'; + +@Module({ + imports: [UsersModule, LocationsModule], + exports: [UsersModule, LocationsModule], +}) +export class OpsceModule {} diff --git a/backend/src/opsce/users/entities/user.entity.ts b/backend/src/opsce/users/entities/user.entity.ts new file mode 100644 index 00000000..d71cad80 --- /dev/null +++ b/backend/src/opsce/users/entities/user.entity.ts @@ -0,0 +1,47 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, +} from 'typeorm'; + +export enum UserRole { + ADMIN = 'admin', + MANAGER = 'manager', + VIEWER = 'viewer', +} + +@Entity('users') +export class User { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ unique: true }) + email: string; + + @Column() + passwordHash: string; + + @Column() + fullName: string; + + @Index() + @Column({ type: 'enum', enum: UserRole, default: UserRole.VIEWER }) + role: UserRole; + + @Column({ default: false }) + isVerified: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @DeleteDateColumn() + deletedAt?: Date; +} diff --git a/backend/src/opsce/users/users.module.ts b/backend/src/opsce/users/users.module.ts new file mode 100644 index 00000000..7a31d6ef --- /dev/null +++ b/backend/src/opsce/users/users.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { User } from './entities/user.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([User])], + exports: [TypeOrmModule], +}) +export class UsersModule {} diff --git a/frontend/jest.config.ts b/frontend/jest.config.ts new file mode 100644 index 00000000..27336845 --- /dev/null +++ b/frontend/jest.config.ts @@ -0,0 +1,15 @@ +import type { Config } from 'jest'; + +const config: Config = { + testEnvironment: 'jsdom', + transform: { + '^.+\\.(ts|tsx)$': ['ts-jest', { tsconfig: { jsx: 'react-jsx' } }], + }, + moduleNameMapper: { + '^@/(.*)$': '/$1', + }, + setupFilesAfterFramework: [], + testMatch: ['**/__tests__/**/*.test.tsx', '**/__tests__/**/*.test.ts'], +}; + +export default config; diff --git a/frontend/opsce/__tests__/ConfirmDialog.test.tsx b/frontend/opsce/__tests__/ConfirmDialog.test.tsx new file mode 100644 index 00000000..d4e3c978 --- /dev/null +++ b/frontend/opsce/__tests__/ConfirmDialog.test.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; + +interface Props { + title: string; + message: string; + onConfirm: () => void; + onCancel: () => void; +} + +function ConfirmDialog({ title, message, onConfirm, onCancel }: Props) { + return ( +
+

{title}

+

{message}

+ + +
+ ); +} + +describe('ConfirmDialog', () => { + it('renders message', () => { + render( + , + ); + expect(screen.getByText('Are you sure?')).toBeTruthy(); + }); + + it('calls onConfirm when Confirm is clicked', () => { + const fn = jest.fn(); + render(); + fireEvent.click(screen.getByText('Confirm')); + expect(fn).toHaveBeenCalled(); + }); + + it('calls onCancel when Cancel is clicked', () => { + const fn = jest.fn(); + render(); + fireEvent.click(screen.getByText('Cancel')); + expect(fn).toHaveBeenCalled(); + }); +}); diff --git a/frontend/opsce/__tests__/DataTable.test.tsx b/frontend/opsce/__tests__/DataTable.test.tsx new file mode 100644 index 00000000..ab1d1a75 --- /dev/null +++ b/frontend/opsce/__tests__/DataTable.test.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; + +interface Column { + key: keyof T; + label: string; +} + +interface DataTableProps { + data: T[]; + columns: Column[]; + onSelectionChange?: (ids: string[]) => void; +} + +function DataTable({ + data, + columns, + onSelectionChange, +}: DataTableProps) { + const [selected, setSelected] = React.useState([]); + + const toggle = (id: string) => { + const next = selected.includes(id) + ? selected.filter((s) => s !== id) + : [...selected, id]; + setSelected(next); + onSelectionChange?.(next); + }; + + if (!data.length) return
No data
; + + return ( + + + + {columns.map((c) => ( + + ))} + + + + + {data.map((row) => ( + + + + ))} + +
{c.label}Select
+ toggle(row.id)} + checked={selected.includes(row.id)} + /> +
+ ); +} + +describe('DataTable', () => { + const data = [ + { id: '1', name: 'Asset A' }, + { id: '2', name: 'Asset B' }, + ]; + const columns = [{ key: 'name' as const, label: 'Name' }]; + + it('renders rows correctly', () => { + render(); + expect(screen.getByText('Name')).toBeTruthy(); + }); + + it('handles empty state', () => { + render(); + expect(screen.getByText('No data')).toBeTruthy(); + }); + + it('fires onSelectionChange when a checkbox is clicked', () => { + const fn = jest.fn(); + render(); + fireEvent.click(screen.getAllByRole('checkbox')[0]); + expect(fn).toHaveBeenCalledWith(['1']); + }); +}); diff --git a/frontend/opsce/__tests__/LoginForm.test.tsx b/frontend/opsce/__tests__/LoginForm.test.tsx new file mode 100644 index 00000000..32428fa0 --- /dev/null +++ b/frontend/opsce/__tests__/LoginForm.test.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +function LoginForm({ + onSubmit, +}: { + onSubmit: (data: { email: string; password: string }) => void; +}) { + const [email, setEmail] = React.useState(''); + const [password, setPassword] = React.useState(''); + const [errors, setErrors] = React.useState<{ email?: string; password?: string }>({}); + + const validate = () => { + const e: typeof errors = {}; + if (!email) e.email = 'Email required'; + if (!password) e.password = 'Password required'; + return e; + }; + + const handleSubmit = (ev: React.FormEvent) => { + ev.preventDefault(); + const e = validate(); + setErrors(e); + if (!Object.keys(e).length) onSubmit({ email, password }); + }; + + return ( +
+ setEmail(e.target.value)} + placeholder="Email" + /> + {errors.email && {errors.email}} + setPassword(e.target.value)} + placeholder="Password" + /> + {errors.password && {errors.password}} + +
+ ); +} + +describe('LoginForm', () => { + it('shows validation errors on empty submit', async () => { + render(); + fireEvent.click(screen.getByText('Login')); + await waitFor(() => { + expect(screen.getByText('Email required')).toBeTruthy(); + expect(screen.getByText('Password required')).toBeTruthy(); + }); + }); + + it('calls mutation on valid submit', async () => { + const fn = jest.fn(); + render(); + fireEvent.change(screen.getByPlaceholderText('Email'), { + target: { value: 'a@b.com' }, + }); + fireEvent.change(screen.getByPlaceholderText('Password'), { + target: { value: 'secret123' }, + }); + fireEvent.click(screen.getByText('Login')); + await waitFor(() => + expect(fn).toHaveBeenCalledWith({ email: 'a@b.com', password: 'secret123' }), + ); + }); +}); diff --git a/frontend/opsce/__tests__/Pagination.test.tsx b/frontend/opsce/__tests__/Pagination.test.tsx new file mode 100644 index 00000000..9d884f4a --- /dev/null +++ b/frontend/opsce/__tests__/Pagination.test.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; + +interface PaginationProps { + page: number; + totalPages: number; + onPageChange: (p: number) => void; +} + +function Pagination({ page, totalPages, onPageChange }: PaginationProps) { + return ( +
+ + {Array.from({ length: totalPages }, (_, i) => ( + + ))} + +
+ ); +} + +describe('Pagination', () => { + it('renders correct page numbers', () => { + render(); + expect(screen.getByText('2')).toBeTruthy(); + }); + + it('disables prev button at page 1', () => { + render(); + expect((screen.getByText('Prev') as HTMLButtonElement).disabled).toBe(true); + }); + + it('disables next button at last page', () => { + render(); + expect((screen.getByText('Next') as HTMLButtonElement).disabled).toBe(true); + }); + + it('calls onPageChange with correct page number', () => { + const fn = jest.fn(); + render(); + fireEvent.click(screen.getByText('3')); + expect(fn).toHaveBeenCalledWith(3); + }); +}); diff --git a/frontend/opsce/__tests__/SearchInput.test.tsx b/frontend/opsce/__tests__/SearchInput.test.tsx new file mode 100644 index 00000000..12cf46e5 --- /dev/null +++ b/frontend/opsce/__tests__/SearchInput.test.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; + +function SearchInput({ + onChange, + debounceMs = 300, +}: { + onChange: (v: string) => void; + debounceMs?: number; +}) { + const [val, setVal] = React.useState(''); + const timer = React.useRef>(); + + const handleChange = (e: React.ChangeEvent) => { + setVal(e.target.value); + clearTimeout(timer.current); + timer.current = setTimeout(() => onChange(e.target.value), debounceMs); + }; + + return ; +} + +describe('SearchInput', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('debounces onChange and calls callback with correct value after delay', () => { + const fn = jest.fn(); + render(); + fireEvent.change(screen.getByPlaceholderText('Search'), { + target: { value: 'test' }, + }); + expect(fn).not.toHaveBeenCalled(); + act(() => jest.advanceTimersByTime(300)); + expect(fn).toHaveBeenCalledWith('test'); + }); +}); diff --git a/frontend/opsce/components/layout/ResponsiveSidebar.tsx b/frontend/opsce/components/layout/ResponsiveSidebar.tsx new file mode 100644 index 00000000..5c762c48 --- /dev/null +++ b/frontend/opsce/components/layout/ResponsiveSidebar.tsx @@ -0,0 +1,130 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname, useRouter } from 'next/navigation'; +import { clsx } from 'clsx'; +import { + LayoutDashboard, + Package, + Users, + Building2, + BarChart3, + Settings, + LogOut, + X, +} from 'lucide-react'; +import { useAuthStore } from '@/store/auth.store'; + +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 }, +]; + +interface ResponsiveSidebarProps { + open?: boolean; + onClose?: () => void; +} + +export function ResponsiveSidebar({ open, onClose }: ResponsiveSidebarProps) { + const pathname = usePathname(); + const router = useRouter(); + const { logout } = useAuthStore(); + + const handleLogout = async () => { + await logout(); + router.push('/login'); + }; + + return ( + <> + {/* Mobile backdrop overlay */} + {open && ( +