From 2c74a5ecb116fd5bdfb3b4168486e03235bde3ba Mon Sep 17 00:00:00 2001 From: temiport25 Date: Sat, 30 May 2026 12:27:22 +0100 Subject: [PATCH] feat: add Asset/Department TypeORM entities, DashboardPage with Recharts, and BulkActionBar --- backend/src/opsce/assets/assets.module.ts | 9 + .../src/opsce/assets/entities/asset.entity.ts | 77 +++++++ .../opsce/departments/departments.module.ts | 9 + .../departments/entities/department.entity.ts | 44 ++++ backend/src/opsce/opsce.module.ts | 9 + .../opsce/features/assets/BulkActionBar.tsx | 163 ++++++++++++++ .../features/dashboard/DashboardPage.tsx | 204 ++++++++++++++++++ 7 files changed, 515 insertions(+) create mode 100644 backend/src/opsce/assets/assets.module.ts create mode 100644 backend/src/opsce/assets/entities/asset.entity.ts create mode 100644 backend/src/opsce/departments/departments.module.ts create mode 100644 backend/src/opsce/departments/entities/department.entity.ts create mode 100644 backend/src/opsce/opsce.module.ts create mode 100644 frontend/opsce/features/assets/BulkActionBar.tsx create mode 100644 frontend/opsce/features/dashboard/DashboardPage.tsx diff --git a/backend/src/opsce/assets/assets.module.ts b/backend/src/opsce/assets/assets.module.ts new file mode 100644 index 00000000..f1055a63 --- /dev/null +++ b/backend/src/opsce/assets/assets.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Asset } from './entities/asset.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Asset])], + exports: [TypeOrmModule], +}) +export class AssetsModule {} diff --git a/backend/src/opsce/assets/entities/asset.entity.ts b/backend/src/opsce/assets/entities/asset.entity.ts new file mode 100644 index 00000000..2d546799 --- /dev/null +++ b/backend/src/opsce/assets/entities/asset.entity.ts @@ -0,0 +1,77 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, +} from 'typeorm'; + +export enum AssetStatus { + ACTIVE = 'active', + INACTIVE = 'inactive', + MAINTENANCE = 'maintenance', + RETIRED = 'retired', +} + +export enum AssetCondition { + EXCELLENT = 'excellent', + GOOD = 'good', + FAIR = 'fair', + POOR = 'poor', +} + +@Entity('assets') +export class Asset { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + name: string; + + @Column({ nullable: true }) + description?: string; + + @Index() + @Column() + category: string; + + @Index() + @Column({ nullable: true }) + serialNumber?: string; + + @Index() + @Column({ type: 'enum', enum: AssetStatus, default: AssetStatus.ACTIVE }) + status: AssetStatus; + + @Column({ type: 'enum', enum: AssetCondition, default: AssetCondition.GOOD }) + condition: AssetCondition; + + @Column({ type: 'date', nullable: true }) + purchaseDate?: Date; + + @Column({ type: 'decimal', precision: 12, scale: 2, nullable: true }) + purchaseValue?: number; + + @Column({ type: 'decimal', precision: 12, scale: 2, nullable: true }) + currentValue?: number; + + @Column({ nullable: true }) + assignedToUserId?: string; + + @Column({ nullable: true }) + departmentId?: string; + + @Column({ nullable: true }) + locationId?: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @DeleteDateColumn() + deletedAt?: Date; +} diff --git a/backend/src/opsce/departments/departments.module.ts b/backend/src/opsce/departments/departments.module.ts new file mode 100644 index 00000000..3c135018 --- /dev/null +++ b/backend/src/opsce/departments/departments.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Department } from './entities/department.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Department])], + exports: [TypeOrmModule], +}) +export class DepartmentsModule {} diff --git a/backend/src/opsce/departments/entities/department.entity.ts b/backend/src/opsce/departments/entities/department.entity.ts new file mode 100644 index 00000000..30bc174e --- /dev/null +++ b/backend/src/opsce/departments/entities/department.entity.ts @@ -0,0 +1,44 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; + +@Entity('departments') +export class Department { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + name: string; + + @Column({ nullable: true }) + description?: string; + + @Column({ nullable: true }) + code?: string; + + @Column({ default: true }) + isActive: boolean; + + @Column({ nullable: true }) + parentId?: string; + + @ManyToOne(() => Department, (d) => d.children, { nullable: true }) + @JoinColumn({ name: 'parentId' }) + parent?: Department; + + @OneToMany(() => Department, (d) => d.parent) + children: Department[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/opsce/opsce.module.ts b/backend/src/opsce/opsce.module.ts new file mode 100644 index 00000000..82aa0f00 --- /dev/null +++ b/backend/src/opsce/opsce.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { AssetsModule } from './assets/assets.module'; +import { DepartmentsModule } from './departments/departments.module'; + +@Module({ + imports: [AssetsModule, DepartmentsModule], + exports: [AssetsModule, DepartmentsModule], +}) +export class OpsceModule {} diff --git a/frontend/opsce/features/assets/BulkActionBar.tsx b/frontend/opsce/features/assets/BulkActionBar.tsx new file mode 100644 index 00000000..fc3fc356 --- /dev/null +++ b/frontend/opsce/features/assets/BulkActionBar.tsx @@ -0,0 +1,163 @@ +'use client'; + +import { useState } from 'react'; +import { X, Check } from 'lucide-react'; + +type BulkAction = 'status' | 'department' | 'location' | 'delete'; + +const ACTION_OPTIONS: { value: BulkAction; label: string }[] = [ + { value: 'status', label: 'Change Status' }, + { value: 'department', label: 'Reassign Department' }, + { value: 'location', label: 'Change Location' }, + { value: 'delete', label: 'Delete' }, +]; + +interface BulkResult { + id: string; + success: boolean; + error?: string; +} + +interface Props { + selectedIds: string[]; + onClearSelection: () => void; + onActionComplete?: () => void; +} + +export function BulkActionBar({ selectedIds, onClearSelection, onActionComplete }: Props) { + const [activeAction, setActiveAction] = useState(null); + const [actionValue, setActionValue] = useState(''); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + + if (!selectedIds.length) return null; + + const handleApply = async () => { + if (!activeAction) return; + setLoading(true); + try { + const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:6003/api'; + const token = + typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''; + const res = await fetch(`${API}/assets/bulk`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + ids: selectedIds, + action: activeAction, + value: actionValue, + }), + }); + const data = await res.json(); + setResults( + data.results ?? selectedIds.map((id) => ({ id, success: res.ok })), + ); + if (res.ok) onActionComplete?.(); + } catch { + setResults( + selectedIds.map((id) => ({ id, success: false, error: 'Request failed' })), + ); + } + setLoading(false); + }; + + return ( + <> + {/* Sticky action bar */} +
+ + {selectedIds.length} asset{selectedIds.length !== 1 ? 's' : ''} selected + + +
+ + + {activeAction && activeAction !== 'delete' && ( + setActionValue(e.target.value)} + /> + )} + + +
+ + +
+ + {/* Results modal */} + {results.length > 0 && ( +
+
setResults([])} + /> +
+

+ Bulk Action Results +

+
+ {results.map((r) => ( +
+ {r.success ? : } + + {r.id}: {r.success ? 'Success' : (r.error ?? 'Failed')} + +
+ ))} +
+ +
+
+ )} + + ); +} diff --git a/frontend/opsce/features/dashboard/DashboardPage.tsx b/frontend/opsce/features/dashboard/DashboardPage.tsx new file mode 100644 index 00000000..24598ead --- /dev/null +++ b/frontend/opsce/features/dashboard/DashboardPage.tsx @@ -0,0 +1,204 @@ +'use client'; + +import { + PieChart, + Pie, + Cell, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from 'recharts'; +import { TrendingUp, TrendingDown, Package, CheckCircle2, Wrench, DollarSign } from 'lucide-react'; +import { useQuery } from '@tanstack/react-query'; + +interface ReportsSummary { + total: number; + active: number; + maintenance: number; + totalValue: number; + byStatus: Record; + byDepartment: Array<{ name: string; count: number }>; + recent: Array<{ + id: string; + action: string; + entityName: string; + createdAt: string; + }>; +} + +function fetchReports(): Promise { + const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:6003/api'; + const token = typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''; + return fetch(`${API}/reports`, { + headers: { Authorization: `Bearer ${token}` }, + }).then((r) => r.json()); +} + +const STATUS_COLORS: Record = { + Active: '#10b981', + Maintenance: '#f59e0b', + Retired: '#ef4444', + Inactive: '#6b7280', +}; + +function CardSkeleton() { + return ( +
+
+
+
+ ); +} + +function ChartSkeleton() { + return
; +} + +interface TrendProps { + value: number; +} + +function Trend({ value }: TrendProps) { + const up = value >= 0; + return ( + + {up ? : } + {Math.abs(value)}% vs last month + + ); +} + +export function DashboardPage() { + const { data, isLoading } = useQuery({ + queryKey: ['reports', 'summary'], + queryFn: fetchReports, + }); + + const statCards = [ + { label: 'Total Assets', value: data?.total ?? 0, icon: Package, trend: 5 }, + { label: 'Active Assets', value: data?.active ?? 0, icon: CheckCircle2, trend: 2 }, + { label: 'In Maintenance', value: data?.maintenance ?? 0, icon: Wrench, trend: -1 }, + { + label: 'Total Asset Value', + value: `$${(data?.totalValue ?? 0).toLocaleString()}`, + icon: DollarSign, + trend: 8, + }, + ]; + + const pieData = Object.entries(data?.byStatus ?? {}).map(([name, value]) => ({ + name, + value, + })); + + const barData = (data?.byDepartment ?? []).slice(0, 5); + + return ( +
+ {/* Summary cards */} +
+ {isLoading + ? statCards.map((_, i) => ) + : statCards.map((card) => ( +
+
+

{card.label}

+ +
+

{card.value}

+ +
+ ))} +
+ + {/* Charts */} +
+ {/* Assets by Status — Pie */} +
+

Assets by Status

+ {isLoading ? ( + + ) : ( + + + + `${name} ${(percent * 100).toFixed(0)}%` + } + > + {pieData.map((entry) => ( + + ))} + + + + + )} +
+ + {/* Assets by Department — Bar */} +
+

+ Assets by Department (Top 5) +

+ {isLoading ? ( + + ) : ( + + + + + + + + + + )} +
+
+ + {/* Recent Activity */} +
+
+

Recent Activity

+
+
+ {isLoading + ? Array.from({ length: 5 }).map((_, i) => ( +
+
+
+ )) + : (data?.recent ?? []).slice(0, 5).map((entry, i) => ( +
+
+

{entry.entityName}

+

{entry.action}

+
+ + {new Date(entry.createdAt).toLocaleDateString()} + +
+ ))} + {!isLoading && !(data?.recent?.length) && ( +

No recent activity

+ )} +
+
+
+ ); +}