diff --git a/backend/src/auth/enums/permission.enum.ts b/backend/src/auth/enums/permission.enum.ts index 3ce55bcc..32b05403 100644 --- a/backend/src/auth/enums/permission.enum.ts +++ b/backend/src/auth/enums/permission.enum.ts @@ -69,4 +69,5 @@ export enum Permission { // ── Admin ───────────────────────────────────────────────────────────── ADMIN_ACCESS = 'admin:access', MANAGE_ROLES = 'manage:roles', + READ_ANALYTICS = 'read:analytics', } diff --git a/backend/src/orders/entities/order.entity.ts b/backend/src/orders/entities/order.entity.ts index 5a7d3793..e106f21f 100644 --- a/backend/src/orders/entities/order.entity.ts +++ b/backend/src/orders/entities/order.entity.ts @@ -5,11 +5,16 @@ import { CreateDateColumn, UpdateDateColumn, VersionColumn, + Index, } from 'typeorm'; import { OrderStatus } from '../enums/order-status.enum'; @Entity('orders') +@Index('IDX_ORDERS_HOSPITAL_ID', ['hospitalId']) +@Index('IDX_ORDERS_BLOOD_BANK_ID', ['bloodBankId']) +@Index('IDX_ORDERS_STATUS', ['status']) +@Index('IDX_ORDERS_CREATED_AT', ['createdAt']) export class OrderEntity { @PrimaryGeneratedColumn('uuid') id: string; diff --git a/backend/src/reporting/reporting.controller.ts b/backend/src/reporting/reporting.controller.ts new file mode 100644 index 00000000..e78e6870 --- /dev/null +++ b/backend/src/reporting/reporting.controller.ts @@ -0,0 +1,50 @@ +import { + Controller, + Get, + Query, + Res, + HttpStatus, + UseGuards, + ValidationPipe, +} from '@nestjs/common'; +import { Response } from 'express'; +import { ReportingService, ReportingFilterDto } from './reporting.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { PermissionsGuard } from '../auth/guards/permissions.guard'; +import { RequirePermissions } from '../auth/decorators/permissions.decorator'; +import { Permission } from '../auth/enums/permission.enum'; + +@Controller('reporting') +@UseGuards(JwtAuthGuard, PermissionsGuard) +export class ReportingController { + constructor(private readonly reportingService: ReportingService) {} + + @Get('search') + @RequirePermissions(Permission.READ_ANALYTICS) + async search(@Query(new ValidationPipe({ transform: true })) filters: ReportingFilterDto) { + return this.reportingService.search(filters); + } + + @Get('summary') + @RequirePermissions(Permission.READ_ANALYTICS) + async getSummary(@Query(new ValidationPipe({ transform: true })) filters: ReportingFilterDto) { + return this.reportingService.getSummary(filters); + } + + @Get('export') + @RequirePermissions(Permission.READ_ANALYTICS) + async export( + @Query(new ValidationPipe({ transform: true })) filters: ReportingFilterDto, + @Res() res: Response, + ) { + const buffer = await this.reportingService.exportToExcel(filters); + + res.set({ + 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition': 'attachment; filename=report.xlsx', + 'Content-Length': buffer.length, + }); + + res.status(HttpStatus.OK).send(buffer); + } +} diff --git a/backend/src/reporting/reporting.module.ts b/backend/src/reporting/reporting.module.ts new file mode 100644 index 00000000..8e8615fa --- /dev/null +++ b/backend/src/reporting/reporting.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ReportingController } from './reporting.controller'; +import { ReportingService } from './reporting.service'; +import { UserEntity } from '../users/entities/user.entity'; +import { BloodUnit } from '../blood-units/entities/blood-unit.entity'; +import { OrderEntity } from '../orders/entities/order.entity'; +import { DisputeEntity } from '../disputes/entities/dispute.entity'; +import { OrganizationEntity } from '../organizations/entities/organization.entity'; +import { BloodRequestEntity } from '../blood-requests/entities/blood-request.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + UserEntity, + BloodUnit, + OrderEntity, + DisputeEntity, + OrganizationEntity, + BloodRequestEntity, + ]), + ], + controllers: [ReportingController], + providers: [ReportingService], + exports: [ReportingService], +}) +export class ReportingModule {} diff --git a/backend/src/reporting/reporting.service.ts b/backend/src/reporting/reporting.service.ts new file mode 100644 index 00000000..d4f4ce95 --- /dev/null +++ b/backend/src/reporting/reporting.service.ts @@ -0,0 +1,202 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, SelectQueryBuilder, Between, In } from 'typeorm'; +import { UserEntity } from '../users/entities/user.entity'; +import { BloodUnit } from '../blood-units/entities/blood-unit.entity'; +import { OrderEntity } from '../orders/entities/order.entity'; +import { DisputeEntity } from '../disputes/entities/dispute.entity'; +import { OrganizationEntity } from '../organizations/entities/organization.entity'; +import { BloodRequestEntity } from '../blood-requests/entities/blood-request.entity'; +import * as ExcelJS from 'exceljs'; + +export interface ReportingFilterDto { + startDate?: string; + endDate?: string; + statusGroups?: string[]; + location?: string; + bloodType?: string; + domain?: 'donors' | 'units' | 'orders' | 'disputes' | 'organizations' | 'requests' | 'all'; + limit?: number; + offset?: number; +} + +@Injectable() +export class ReportingService { + private readonly logger = new Logger(ReportingService.name); + + constructor( + @InjectRepository(UserEntity) + private readonly userRepository: Repository, + @InjectRepository(BloodUnit) + private readonly unitRepository: Repository, + @InjectRepository(OrderEntity) + private readonly orderRepository: Repository, + @InjectRepository(DisputeEntity) + private readonly disputeRepository: Repository, + @InjectRepository(OrganizationEntity) + private readonly organizationRepository: Repository, + @InjectRepository(BloodRequestEntity) + private readonly requestRepository: Repository, + ) {} + + async search(filters: ReportingFilterDto) { + const domain = filters.domain || 'all'; + const results: any = {}; + + if (domain === 'all' || domain === 'donors') { + results.donors = await this.queryDonors(filters); + } + if (domain === 'all' || domain === 'units') { + results.units = await this.queryUnits(filters); + } + if (domain === 'all' || domain === 'orders') { + results.orders = await this.queryOrders(filters); + } + if (domain === 'all' || domain === 'disputes') { + results.disputes = await this.queryDisputes(filters); + } + if (domain === 'all' || domain === 'organizations') { + results.organizations = await this.queryOrganizations(filters); + } + if (domain === 'all' || domain === 'requests') { + results.requests = await this.queryRequests(filters); + } + + return results; + } + + private async queryDonors(filters: ReportingFilterDto) { + const query = this.userRepository.createQueryBuilder('user'); + query.where('user.role = :role', { role: 'donor' }); + this.applyCommonFilters(query, 'user', filters); + if (filters.bloodType) { + query.andWhere("user.profile->>'bloodType' = :bloodType", { bloodType: filters.bloodType }); + } + if (filters.location) { + query.andWhere('user.region ILIKE :location', { location: `%${filters.location}%` }); + } + return query.take(filters.limit || 50).skip(filters.offset || 0).getManyAndCount(); + } + + private async queryUnits(filters: ReportingFilterDto) { + const query = this.unitRepository.createQueryBuilder('unit'); + this.applyCommonFilters(query, 'unit', filters); + if (filters.bloodType) { + query.andWhere('unit.bloodType = :bloodType', { bloodType: filters.bloodType }); + } + if (filters.statusGroups && filters.statusGroups.length > 0) { + query.andWhere('unit.status IN (:...statuses)', { statuses: filters.statusGroups }); + } + return query.take(filters.limit || 50).skip(filters.offset || 0).getManyAndCount(); + } + + private async queryOrders(filters: ReportingFilterDto) { + const query = this.orderRepository.createQueryBuilder('order'); + this.applyCommonFilters(query, 'order', filters); + if (filters.statusGroups && filters.statusGroups.length > 0) { + query.andWhere('order.status IN (:...statuses)', { statuses: filters.statusGroups }); + } + return query.take(filters.limit || 50).skip(filters.offset || 0).getManyAndCount(); + } + + private async queryDisputes(filters: ReportingFilterDto) { + const query = this.disputeRepository.createQueryBuilder('dispute'); + this.applyCommonFilters(query, 'dispute', filters); + if (filters.statusGroups && filters.statusGroups.length > 0) { + query.andWhere('dispute.status IN (:...statuses)', { statuses: filters.statusGroups }); + } + return query.take(filters.limit || 50).skip(filters.offset || 0).getManyAndCount(); + } + + private async queryOrganizations(filters: ReportingFilterDto) { + const query = this.organizationRepository.createQueryBuilder('org'); + this.applyCommonFilters(query, 'org', filters); + if (filters.location) { + query.andWhere('(org.city ILIKE :loc OR org.country ILIKE :loc)', { loc: `%${filters.location}%` }); + } + return query.take(filters.limit || 50).skip(filters.offset || 0).getManyAndCount(); + } + + private async queryRequests(filters: ReportingFilterDto) { + const query = this.requestRepository.createQueryBuilder('req'); + this.applyCommonFilters(query, 'req', filters); + if (filters.bloodType) { + query.andWhere('req.bloodType = :bloodType', { bloodType: filters.bloodType }); + } + if (filters.statusGroups && filters.statusGroups.length > 0) { + query.andWhere('req.status IN (:...statuses)', { statuses: filters.statusGroups }); + } + return query.take(filters.limit || 50).skip(filters.offset || 0).getManyAndCount(); + } + + private applyCommonFilters(query: SelectQueryBuilder, alias: string, filters: ReportingFilterDto) { + if (filters.startDate && filters.endDate) { + query.andWhere(`${alias}.createdAt BETWEEN :start AND :end`, { + start: new Date(filters.startDate), + end: new Date(filters.endDate), + }); + } else if (filters.startDate) { + query.andWhere(`${alias}.createdAt >= :start`, { start: new Date(filters.startDate) }); + } else if (filters.endDate) { + query.andWhere(`${alias}.createdAt <= :end`, { end: new Date(filters.endDate) }); + } + } + + async getSummary(filters: ReportingFilterDto) { + // Generate high-level metrics + const [donorCount] = await this.queryDonors({ ...filters, limit: 0 }); + const [unitCount] = await this.queryUnits({ ...filters, limit: 0 }); + const [orderCount] = await this.queryOrders({ ...filters, limit: 0 }); + const [disputeCount] = await this.queryDisputes({ ...filters, limit: 0 }); + + return { + donors: donorCount[1], + units: unitCount[1], + orders: orderCount[1], + disputes: disputeCount[1], + }; + } + + async exportToExcel(filters: ReportingFilterDto): Promise { + const workbook = new ExcelJS.Workbook(); + const data = await this.search({ ...filters, limit: 10000 }); // Large limit for export + + if (data.donors) { + const sheet = workbook.addWorksheet('Donors'); + sheet.columns = [ + { header: 'ID', key: 'id' }, + { header: 'Email', key: 'email' }, + { header: 'Name', key: 'name' }, + { header: 'Region', key: 'region' }, + { header: 'Created At', key: 'createdAt' }, + ]; + data.donors[0].forEach((d: any) => sheet.addRow(d)); + } + + if (data.units) { + const sheet = workbook.addWorksheet('Units'); + sheet.columns = [ + { header: 'Unit Code', key: 'unitCode' }, + { header: 'Blood Type', key: 'bloodType' }, + { header: 'Status', key: 'status' }, + { header: 'Volume (ml)', key: 'volumeMl' }, + { header: 'Expires At', key: 'expiresAt' }, + ]; + data.units[0].forEach((u: any) => sheet.addRow(u)); + } + + if (data.orders) { + const sheet = workbook.addWorksheet('Orders'); + sheet.columns = [ + { header: 'ID', key: 'id' }, + { header: 'Hospital ID', key: 'hospitalId' }, + { header: 'Blood Type', key: 'bloodType' }, + { header: 'Quantity', key: 'quantity' }, + { header: 'Status', key: 'status' }, + ]; + data.orders[0].forEach((o: any) => sheet.addRow(o)); + } + + return workbook.xlsx.writeBuffer() as Promise; + } +} diff --git a/backend/src/users/entities/user.entity.ts b/backend/src/users/entities/user.entity.ts index 9cefb1a9..978b8de8 100644 --- a/backend/src/users/entities/user.entity.ts +++ b/backend/src/users/entities/user.entity.ts @@ -20,6 +20,9 @@ import { TwoFactorAuthEntity } from './two-factor-auth.entity'; @Entity('users') @Index('IDX_USERS_EMAIL', ['email'], { unique: true }) @Index('IDX_USERS_ORGANIZATION_ID', ['organizationId']) +@Index('IDX_USERS_ROLE', ['role']) +@Index('IDX_USERS_REGION', ['region']) +@Index('IDX_USERS_CREATED_AT', ['createdAt']) export class UserEntity extends BaseEntity { @PrimaryGeneratedColumn('uuid') id: string; diff --git a/frontend/health-chain/app/admin/reporting/page.tsx b/frontend/health-chain/app/admin/reporting/page.tsx new file mode 100644 index 00000000..c5029bca --- /dev/null +++ b/frontend/health-chain/app/admin/reporting/page.tsx @@ -0,0 +1,96 @@ +'use client'; + +import React from 'react'; +import { FilterPanel } from '@/components/admin/reporting/FilterPanel'; +import { SummaryCards } from '@/components/admin/reporting/SummaryCards'; +import { ReportingTable } from '@/components/admin/reporting/ReportingTable'; +import { useAuth } from '@/components/providers/auth-provider'; +import { toast } from 'sonner'; + +export default function ReportingPage() { + const [data, setData] = React.useState(null); + const [summary, setSummary] = React.useState(null); + const [loading, setLoading] = React.useState(false); + const { user } = useAuth(); + + const fetchSummary = async (filters: any) => { + try { + const queryParams = new URLSearchParams(filters).toString(); + const res = await fetch(`/api/reporting/summary?${queryParams}`); + if (!res.ok) throw new Error('Failed to fetch summary'); + const json = await res.json(); + setSummary(json); + } catch (err) { + console.error(err); + } + }; + + const handleSearch = async (filters: any) => { + setLoading(true); + try { + const queryParams = new URLSearchParams(filters).toString(); + + // Fetch both search results and updated summary + const [searchRes, summaryRes] = await Promise.all([ + fetch(`/api/reporting/search?${queryParams}`), + fetch(`/api/reporting/summary?${queryParams}`) + ]); + + if (!searchRes.ok || !summaryRes.ok) throw new Error('Failed to fetch records'); + + const [searchData, summaryData] = await Promise.all([ + searchRes.json(), + summaryRes.json() + ]); + + setData(searchData); + setSummary(summaryData); + toast.success('Search completed successfully'); + } catch (err: any) { + toast.error(err.message || 'Error fetching reporting data'); + } finally { + setLoading(false); + } + }; + + const handleExport = async (filters: any) => { + try { + const queryParams = new URLSearchParams(filters).toString(); + window.open(`/api/reporting/export?${queryParams}`, '_blank'); + toast.info('Export started. Check your downloads.'); + } catch (err) { + toast.error('Failed to trigger export'); + } + }; + + // Initial fetch + React.useEffect(() => { + handleSearch({ domain: 'all' }); + }, []); + + return ( +
+
+
+

+ Operations Reporting Beta +

+

+ Analyze records across donors, units, and orders with shared filters. + Export reports for compliance and auditing. +

+
+ + + + handleExport(data?.filters || { domain: 'all' })} + isLoading={loading} + /> + + +
+
+ ); +} diff --git a/frontend/health-chain/components/admin/reporting/FilterPanel.tsx b/frontend/health-chain/components/admin/reporting/FilterPanel.tsx new file mode 100644 index 00000000..db91e62c --- /dev/null +++ b/frontend/health-chain/components/admin/reporting/FilterPanel.tsx @@ -0,0 +1,116 @@ +'use client'; + +import React from 'react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Button } from '@/components/ui/button'; +import { CalendarIcon, Download, Search } from 'lucide-react'; + +interface FilterPanelProps { + onSearch: (filters: any) => void; + onExport: () => void; + isLoading?: boolean; +} + +export function FilterPanel({ onSearch, onExport, isLoading }: FilterPanelProps) { + const [filters, setFilters] = React.useState({ + startDate: '', + endDate: '', + domain: 'all', + status: '', + bloodType: '', + location: '', + }); + + const handleChange = (key: string, value: string) => { + setFilters(prev => ({ ...prev, [key]: value })); + }; + + const handleSearch = () => { + onSearch(filters); + }; + + return ( + + +
+
+ +
+ handleChange('startDate', e.target.value)} + className="bg-white/80 dark:bg-slate-800/80" + /> + handleChange('endDate', e.target.value)} + className="bg-white/80 dark:bg-slate-800/80" + /> +
+
+ +
+ + +
+ +
+ + +
+ +
+ + handleChange('location', e.target.value)} + className="bg-white/80 dark:bg-slate-800/80" + /> +
+
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/health-chain/components/admin/reporting/ReportingTable.tsx b/frontend/health-chain/components/admin/reporting/ReportingTable.tsx new file mode 100644 index 00000000..f31c425a --- /dev/null +++ b/frontend/health-chain/components/admin/reporting/ReportingTable.tsx @@ -0,0 +1,159 @@ +'use client'; + +import React from 'react'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { format } from 'date-fns'; + +interface ReportingTableProps { + data: any; + isLoading?: boolean; +} + +export function ReportingTable({ data, isLoading }: ReportingTableProps) { + if (isLoading) { + return ( +
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+ ); + } + + const renderDonors = (donors: any[]) => ( +
+

+ Donors ({donors.length}) +

+ + + + Name + Email + Region + Blood Type + Joined + + + + {donors.map((donor) => ( + + {donor.name || 'N/A'} + {donor.email} + {donor.region} + + + {donor.profile?.bloodType || 'Unknown'} + + + + {format(new Date(donor.createdAt), 'MMM d, yyyy')} + + + ))} + +
+
+ ); + + const renderUnits = (units: any[]) => ( +
+

+ Blood Units ({units.length}) +

+ + + + Unit Code + Type + Component + Status + Expires + + + + {units.map((unit) => ( + + #{unit.unitCode} + + {unit.bloodType} + + {unit.component} + + + {unit.status} + + + + {format(new Date(unit.expiresAt), 'MMM d, yyyy')} + + + ))} + +
+
+ ); + + const renderOrders = (orders: any[]) => ( +
+

+ Market Orders ({orders.length}) +

+ + + + Order ID + Blood Type + Quantity + Status + Date + + + + {orders.map((order) => ( + + {order.id.slice(0, 8)}... + {order.bloodType} + {order.quantity} units + + + {order.status} + + + + {format(new Date(order.createdAt), 'MMM d, yyyy')} + + + ))} + +
+
+ ); + + return ( + + + {!data || (Array.isArray(data) && data.length === 0) ? ( +
+ No records found. Adjust your filters to see results. +
+ ) : ( + <> + {data.donors && data.donors[0] && renderDonors(data.donors[0])} + {data.units && data.units[0] && renderUnits(data.units[0])} + {data.orders && data.orders[0] && renderOrders(data.orders[0])} + + )} +
+
+ ); +} diff --git a/frontend/health-chain/components/admin/reporting/SummaryCards.tsx b/frontend/health-chain/components/admin/reporting/SummaryCards.tsx new file mode 100644 index 00000000..0ce38cec --- /dev/null +++ b/frontend/health-chain/components/admin/reporting/SummaryCards.tsx @@ -0,0 +1,51 @@ +'use client'; + +import React from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Users, Droplet, ShoppingCart, AlertTriangle } from 'lucide-react'; + +interface SummaryData { + donors: number; + units: number; + orders: number; + disputes: number; +} + +interface SummaryCardsProps { + data?: SummaryData; + isLoading?: boolean; +} + +export function SummaryCards({ data, isLoading }: SummaryCardsProps) { + const cards = [ + { title: 'Total Donors', value: data?.donors || 0, icon: Users, color: 'text-blue-600', bg: 'bg-blue-50' }, + { title: 'Blood Units', value: data?.units || 0, icon: Droplet, color: 'text-red-600', bg: 'bg-red-50' }, + { title: 'Market Orders', value: data?.orders || 0, icon: ShoppingCart, color: 'text-green-600', bg: 'bg-green-50' }, + { title: 'Active Disputes', value: data?.disputes || 0, icon: AlertTriangle, color: 'text-orange-600', bg: 'bg-orange-50' }, + ]; + + return ( +
+ {cards.map((card, i) => ( + + + {card.title} +
+ +
+
+ + {isLoading ? ( +
+ ) : ( +
+ {card.value.toLocaleString()} +
+ )} +

Based on current filters

+ + + ))} +
+ ); +}