diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 092846d4..83a20e05 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -25,6 +25,8 @@ import { AuditsModule } from './audits/audits.module'; import { NotificationsModule } from './notifications/notifications.module'; import { GatewayModule } from './gateway/gateway.module'; import { AuditLogsModule } from './audit-logs/audit-logs.module'; +import { DepreciationModule } from './depreciation/depreciation.module'; +import { ReservationsModule } from './reservations/reservations.module'; @Module({ imports: [ @@ -50,6 +52,8 @@ import { AuditLogsModule } from './audit-logs/audit-logs.module'; NotificationsModule, GatewayModule, AuditLogsModule, + DepreciationModule, + ReservationsModule, TypeOrmModule.forRootAsync({ imports: [ConfigModule], useFactory: (configService: ConfigService) => ({ diff --git a/backend/src/assets/assets.controller.ts b/backend/src/assets/assets.controller.ts index d7e33f06..0becad72 100644 --- a/backend/src/assets/assets.controller.ts +++ b/backend/src/assets/assets.controller.ts @@ -18,6 +18,7 @@ import { } from '@nestjs/swagger'; import { AssetsService } from './assets.service'; import { AssetHistoryService } from './asset-history.service'; +import { ReservationsService } from '../reservations/reservations.service'; import { AssetHistoryAction } from './entities/asset-history-event.entity'; import { BulkStatusDto } from './dto/bulk-status.dto'; import { BulkAssignDto } from './dto/bulk-assign.dto'; @@ -37,6 +38,7 @@ export class AssetsController { constructor( private readonly assetsService: AssetsService, private readonly assetHistoryService: AssetHistoryService, + private readonly reservationsService: ReservationsService, ) {} @Get() @@ -162,4 +164,17 @@ export class AssetsController { bulkDelete(@Body() dto: BulkDeleteDto, @GetUser() user: User) { return this.assetsService.bulkDelete(dto, user.id); } + + @Get(':id/availability') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Get asset availability (free/busy windows)' }) + @ApiResponse({ status: 200, description: 'Availability data' }) + getAvailability( + @Param('id') id: string, + @Query('from') from: string, + @Query('to') to: string, + ) { + return this.reservationsService.getAvailability(id, from, to); + } } diff --git a/backend/src/assets/assets.module.ts b/backend/src/assets/assets.module.ts index fa52279f..975b429b 100644 --- a/backend/src/assets/assets.module.ts +++ b/backend/src/assets/assets.module.ts @@ -6,6 +6,7 @@ import { AssetsController } from './assets.controller'; import { AssetsLifecycleModule } from './assets-lifecycle.module'; import { AssetHistoryModule } from './asset-history.module'; import { AuditLogsModule } from '../audit-logs/audit-logs.module'; +import { ReservationsModule } from '../reservations/reservations.module'; import { Department } from '../departments/entities/department.entity'; import { User } from '../users/entities/user.entity'; @@ -15,6 +16,7 @@ import { User } from '../users/entities/user.entity'; AssetsLifecycleModule, AssetHistoryModule, AuditLogsModule, + ReservationsModule, ], providers: [AssetsService], controllers: [AssetsController], diff --git a/backend/src/assets/entities/asset.entity.ts b/backend/src/assets/entities/asset.entity.ts index 645f034d..c67d2da8 100644 --- a/backend/src/assets/entities/asset.entity.ts +++ b/backend/src/assets/entities/asset.entity.ts @@ -104,6 +104,18 @@ export class Asset { @Column({ default: false }) isDigital: boolean; + @Column({ nullable: true }) + depreciationMethod?: string; + + @Column({ nullable: true }) + usefulLifeMonths?: number; + + @Column({ nullable: true }) + salvageValue?: number; + + @Column({ nullable: true, type: 'decimal', precision: 14, scale: 2 }) + currentValue?: number; + @CreateDateColumn() createdAt: Date; diff --git a/backend/src/depreciation/depreciation.controller.ts b/backend/src/depreciation/depreciation.controller.ts new file mode 100644 index 00000000..a1926db7 --- /dev/null +++ b/backend/src/depreciation/depreciation.controller.ts @@ -0,0 +1,35 @@ +import { + Controller, + Get, + Param, + Query, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiResponse, +} from '@nestjs/swagger'; +import { DepreciationService } from './depreciation.service'; + +@ApiTags('depreciation') +@ApiBearerAuth('JWT-auth') +@Controller() +export class DepreciationController { + constructor(private readonly depreciationService: DepreciationService) {} + + @Get('assets/:id/depreciation') + @ApiOperation({ summary: 'Get depreciation schedule and book value for an asset' }) + @ApiResponse({ status: 200, description: 'Asset depreciation details' }) + @ApiResponse({ status: 404, description: 'Asset not found' }) + getAssetDepreciation(@Param('id') id: string) { + return this.depreciationService.getAssetDepreciation(id); + } + + @Get('reports/depreciation') + @ApiOperation({ summary: 'Get org-wide depreciation report' }) + @ApiResponse({ status: 200, description: 'Depreciation report' }) + getDepreciationReport() { + return this.depreciationService.getDepreciationReport(); + } +} diff --git a/backend/src/depreciation/depreciation.module.ts b/backend/src/depreciation/depreciation.module.ts new file mode 100644 index 00000000..e164fe29 --- /dev/null +++ b/backend/src/depreciation/depreciation.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Asset } from '../assets/entities/asset.entity'; +import { DepreciationService } from './depreciation.service'; +import { DepreciationController } from './depreciation.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Asset])], + providers: [DepreciationService], + controllers: [DepreciationController], + exports: [DepreciationService], +}) +export class DepreciationModule {} diff --git a/backend/src/depreciation/depreciation.service.ts b/backend/src/depreciation/depreciation.service.ts new file mode 100644 index 00000000..0fc9c2e4 --- /dev/null +++ b/backend/src/depreciation/depreciation.service.ts @@ -0,0 +1,195 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Asset } from '../assets/entities/asset.entity'; +import { DepreciationScheduleEntryDto } from './dto/depreciation-schedule-entry.dto'; +import { AssetDepreciationResponseDto } from './dto/asset-depreciation-response.dto'; +import { DepreciationReportDto } from './dto/depreciation-report.dto'; + +@Injectable() +export class DepreciationService { + constructor( + @InjectRepository(Asset) + private readonly assetRepo: Repository, + ) {} + + private async findAssetOrThrow(id: string): Promise { + const asset = await this.assetRepo.findOne({ + where: { id }, + relations: ['category'], + }); + if (!asset) throw new NotFoundException(`Asset ${id} not found`); + return asset; + } + + getSchedule(asset: Asset): DepreciationScheduleEntryDto[] { + const method = asset.depreciationMethod; + if (!method || method === 'NONE' || !asset.usefulLifeMonths || !asset.purchaseDate) { + return []; + } + + const cost = Number(asset.purchaseCost) || 0; + const salvage = Number(asset.salvageValue) || 0; + const usefulLife = asset.usefulLifeMonths; + const purchaseDate = new Date(asset.purchaseDate); + + const depreciableAmount = cost - salvage; + if (depreciableAmount <= 0 || usefulLife <= 0) { + return []; + } + + const purchaseDay = purchaseDate.getDate(); + const daysInMonth = new Date( + purchaseDate.getFullYear(), + purchaseDate.getMonth() + 1, + 0, + ).getDate(); + const firstMonthFraction = 1 - (purchaseDay - 1) / daysInMonth; + + const schedule: DepreciationScheduleEntryDto[] = []; + let openingValue = cost; + + if (method === 'STRAIGHT_LINE') { + const monthlyDepreciation = depreciableAmount / usefulLife; + for (let period = 1; period <= usefulLife; period++) { + const isPartialFirst = period === 1 && firstMonthFraction < 1; + const depreciation = isPartialFirst + ? monthlyDepreciation * firstMonthFraction + : monthlyDepreciation; + + const closingValue = Math.max(openingValue - depreciation, salvage); + const actualDepreciation = openingValue - closingValue; + + schedule.push({ + period, + openingValue: round(openingValue), + depreciation: round(actualDepreciation), + closingValue: round(closingValue), + }); + + openingValue = closingValue; + if (openingValue <= salvage) break; + } + } else if (method === 'DECLINING_BALANCE') { + const rate = 2 / usefulLife; + for (let period = 1; period <= usefulLife; period++) { + const isPartialFirst = period === 1 && firstMonthFraction < 1; + let depreciation = openingValue * rate; + if (isPartialFirst) { + depreciation *= firstMonthFraction; + } + + const closingValue = Math.max(openingValue - depreciation, salvage); + const actualDepreciation = openingValue - closingValue; + + schedule.push({ + period, + openingValue: round(openingValue), + depreciation: round(actualDepreciation), + closingValue: round(closingValue), + }); + + openingValue = closingValue; + if (openingValue <= salvage) break; + } + } + + return schedule; + } + + getCurrentBookValue(asset: Asset): number { + if (!asset.depreciationMethod || asset.depreciationMethod === 'NONE') { + return Number(asset.purchaseCost) || 0; + } + + const schedule = this.getSchedule(asset); + if (schedule.length === 0) { + return Number(asset.purchaseCost) || 0; + } + + return schedule[schedule.length - 1].closingValue; + } + + async getAssetDepreciation(id: string): Promise { + const asset = await this.findAssetOrThrow(id); + return { + assetId: asset.id, + assetTag: asset.assetTag, + name: asset.name, + purchaseCost: Number(asset.purchaseCost) || 0, + purchaseDate: asset.purchaseDate ?? null, + depreciationMethod: asset.depreciationMethod ?? null, + usefulLifeMonths: asset.usefulLifeMonths ?? null, + salvageValue: asset.salvageValue != null ? Number(asset.salvageValue) : null, + currentBookValue: this.getCurrentBookValue(asset), + schedule: this.getSchedule(asset), + }; + } + + async getDepreciationReport(): Promise { + const assets = await this.assetRepo.find({ + where: { depreciationMethod: 'STRAIGHT_LINE' }, + }); + const assetsDB = await this.assetRepo.find({ + where: { depreciationMethod: 'DECLINING_BALANCE' }, + }); + + const allDepreciatingAssets = [...assets, ...assetsDB]; + + let totalBookValue = 0; + let totalOriginalCost = 0; + let totalAccumulatedDepreciation = 0; + let monthlyDepreciationExpense = 0; + + const categoryMap = new Map< + string, + { categoryName: string | null; bookValue: number; assetCount: number } + >(); + + for (const asset of allDepreciatingAssets) { + const bookValue = this.getCurrentBookValue(asset); + const cost = Number(asset.purchaseCost) || 0; + const accumulated = cost - bookValue; + + totalBookValue += bookValue; + totalOriginalCost += cost; + totalAccumulatedDepreciation += accumulated; + + const schedule = this.getSchedule(asset); + if (schedule.length > 0) { + monthlyDepreciationExpense += schedule[0].depreciation; + } + + const catId = asset.categoryId ?? 'uncategorized'; + const existing = categoryMap.get(catId); + if (existing) { + existing.bookValue += bookValue; + existing.assetCount += 1; + } else { + categoryMap.set(catId, { + categoryName: asset.category?.name ?? null, + bookValue, + assetCount: 1, + }); + } + } + + return { + totalBookValue: round(totalBookValue), + totalOriginalCost: round(totalOriginalCost), + totalAccumulatedDepreciation: round(totalAccumulatedDepreciation), + monthlyDepreciationExpense: round(monthlyDepreciationExpense), + assetCount: allDepreciatingAssets.length, + byCategory: Array.from(categoryMap.entries()).map(([categoryId, data]) => ({ + categoryId, + categoryName: data.categoryName, + bookValue: round(data.bookValue), + assetCount: data.assetCount, + })), + }; + } +} + +function round(value: number): number { + return Math.round(value * 100) / 100; +} diff --git a/backend/src/depreciation/dto/asset-depreciation-response.dto.ts b/backend/src/depreciation/dto/asset-depreciation-response.dto.ts new file mode 100644 index 00000000..4b0532b7 --- /dev/null +++ b/backend/src/depreciation/dto/asset-depreciation-response.dto.ts @@ -0,0 +1,14 @@ +import { DepreciationScheduleEntryDto } from './depreciation-schedule-entry.dto'; + +export class AssetDepreciationResponseDto { + assetId: string; + assetTag: string; + name: string; + purchaseCost: number; + purchaseDate: Date | null; + depreciationMethod: string | null; + usefulLifeMonths: number | null; + salvageValue: number | null; + currentBookValue: number; + schedule: DepreciationScheduleEntryDto[]; +} diff --git a/backend/src/depreciation/dto/depreciation-report.dto.ts b/backend/src/depreciation/dto/depreciation-report.dto.ts new file mode 100644 index 00000000..3d04d521 --- /dev/null +++ b/backend/src/depreciation/dto/depreciation-report.dto.ts @@ -0,0 +1,13 @@ +export class DepreciationReportDto { + totalBookValue: number; + totalOriginalCost: number; + totalAccumulatedDepreciation: number; + monthlyDepreciationExpense: number; + assetCount: number; + byCategory: Array<{ + categoryId: string | null; + categoryName: string | null; + bookValue: number; + assetCount: number; + }>; +} diff --git a/backend/src/depreciation/dto/depreciation-schedule-entry.dto.ts b/backend/src/depreciation/dto/depreciation-schedule-entry.dto.ts new file mode 100644 index 00000000..c6b58ffa --- /dev/null +++ b/backend/src/depreciation/dto/depreciation-schedule-entry.dto.ts @@ -0,0 +1,6 @@ +export class DepreciationScheduleEntryDto { + period: number; + openingValue: number; + depreciation: number; + closingValue: number; +} diff --git a/backend/src/reservations/entities/reservation.entity.ts b/backend/src/reservations/entities/reservation.entity.ts new file mode 100644 index 00000000..949d67c9 --- /dev/null +++ b/backend/src/reservations/entities/reservation.entity.ts @@ -0,0 +1,45 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Asset } from '../../assets/entities/asset.entity'; +import { User } from '../../users/entities/user.entity'; + +@Entity('reservations') +export class Reservation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + assetId: string; + + @ManyToOne(() => Asset) + @JoinColumn({ name: 'assetId' }) + asset: Asset; + + @Column() + reservedByUserId: string; + + @ManyToOne(() => User) + @JoinColumn({ name: 'reservedByUserId' }) + reservedBy: User; + + @Column({ type: 'timestamp' }) + startsAt: Date; + + @Column({ type: 'timestamp' }) + endsAt: Date; + + @Column({ nullable: true }) + purpose?: string; + + @Column({ default: 'PENDING' }) + status: string; // PENDING | CONFIRMED | CANCELLED | COMPLETED + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/reservations/reservations.controller.ts b/backend/src/reservations/reservations.controller.ts new file mode 100644 index 00000000..d30449e1 --- /dev/null +++ b/backend/src/reservations/reservations.controller.ts @@ -0,0 +1,84 @@ +import { + Controller, + Get, + Post, + Patch, + Body, + Param, + Query, + UseGuards, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiResponse, +} from '@nestjs/swagger'; +import { ReservationsService } from './reservations.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; +import { GetUser } from '../auth/decorators/get-user.decorator'; +import { User } from '../users/entities/user.entity'; + +@ApiTags('reservations') +@ApiBearerAuth('JWT-auth') +@Controller('reservations') +@UseGuards(JwtAuthGuard) +export class ReservationsController { + constructor(private readonly reservationsService: ReservationsService) {} + + @Post() + @ApiOperation({ summary: 'Create a reservation' }) + @ApiResponse({ status: 201, description: 'Reservation created' }) + @ApiResponse({ status: 409, description: 'Time window conflict' }) + create( + @Body() + dto: { + assetId: string; + startsAt: string; + endsAt: string; + purpose?: string; + }, + @GetUser() user: User, + ) { + return this.reservationsService.create(dto, user.id); + } + + @Get() + @ApiOperation({ summary: 'List reservations with filters' }) + @ApiResponse({ status: 200, description: 'List of reservations' }) + findAll( + @Query('assetId') assetId?: string, + @Query('userId') userId?: string, + @Query('from') from?: string, + @Query('to') to?: string, + @Query('status') status?: string, + ) { + return this.reservationsService.findAll({ + assetId, + userId, + from, + to, + status, + }); + } + + @Patch(':id/cancel') + @ApiOperation({ summary: 'Cancel a reservation' }) + @ApiResponse({ status: 200, description: 'Reservation cancelled' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + cancel(@Param('id') id: string, @GetUser() user: User) { + return this.reservationsService.cancel(id, user.id, user.role); + } + + @Patch(':id/confirm') + @UseGuards(RolesGuard) + @Roles('ADMIN', 'MANAGER') + @ApiOperation({ summary: 'Confirm a reservation (ADMIN/MANAGER only)' }) + @ApiResponse({ status: 200, description: 'Reservation confirmed' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + confirm(@Param('id') id: string, @GetUser() user: User) { + return this.reservationsService.confirm(id, user.role); + } +} diff --git a/backend/src/reservations/reservations.module.ts b/backend/src/reservations/reservations.module.ts new file mode 100644 index 00000000..aabaa86c --- /dev/null +++ b/backend/src/reservations/reservations.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Reservation } from './entities/reservation.entity'; +import { ReservationsService } from './reservations.service'; +import { ReservationsController } from './reservations.controller'; +import { Asset } from '../assets/entities/asset.entity'; +import { User } from '../users/entities/user.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Reservation, Asset, User])], + providers: [ReservationsService], + controllers: [ReservationsController], + exports: [ReservationsService], +}) +export class ReservationsModule {} diff --git a/backend/src/reservations/reservations.service.ts b/backend/src/reservations/reservations.service.ts new file mode 100644 index 00000000..00654604 --- /dev/null +++ b/backend/src/reservations/reservations.service.ts @@ -0,0 +1,187 @@ +import { + Injectable, + ConflictException, + NotFoundException, + BadRequestException, + ForbiddenException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; +import { Reservation } from './entities/reservation.entity'; +import { Asset } from '../assets/entities/asset.entity'; +import { User } from '../users/entities/user.entity'; + +@Injectable() +export class ReservationsService { + constructor( + @InjectRepository(Reservation) + private readonly reservationRepo: Repository, + @InjectRepository(Asset) + private readonly assetRepo: Repository, + private readonly dataSource: DataSource, + ) {} + + async create( + dto: { + assetId: string; + startsAt: string; + endsAt: string; + purpose?: string; + }, + userId: string, + ): Promise { + const startsAt = new Date(dto.startsAt); + const endsAt = new Date(dto.endsAt); + + if (isNaN(startsAt.getTime()) || isNaN(endsAt.getTime())) { + throw new BadRequestException('Invalid date format'); + } + if (endsAt <= startsAt) { + throw new BadRequestException('endsAt must be after startsAt'); + } + + const asset = await this.assetRepo.findOneBy({ id: dto.assetId }); + if (!asset) { + throw new NotFoundException('Asset not found'); + } + if (asset.status === 'RETIRED') { + throw new BadRequestException('Cannot reserve a retired asset'); + } + + const overlap = await this.dataSource.query( + `SELECT id FROM reservations + WHERE "assetId" = $1 + AND status IN ('PENDING', 'CONFIRMED') + AND "startsAt" < $3 + AND "endsAt" > $2 + LIMIT 1`, + [dto.assetId, startsAt, endsAt], + ); + + if (overlap.length > 0) { + throw new ConflictException( + 'Asset is already reserved during the requested time window', + ); + } + + const reservation = this.reservationRepo.create({ + assetId: dto.assetId, + reservedByUserId: userId, + startsAt, + endsAt, + purpose: dto.purpose, + status: 'PENDING', + }); + + return this.reservationRepo.save(reservation); + } + + async findAll(filters: { + assetId?: string; + userId?: string; + from?: string; + to?: string; + status?: string; + }): Promise { + const qb = this.reservationRepo + .createQueryBuilder('r') + .leftJoinAndSelect('r.asset', 'asset') + .leftJoinAndSelect('r.reservedBy', 'reservedBy'); + + if (filters.assetId) { + qb.andWhere('r.assetId = :assetId', { assetId: filters.assetId }); + } + if (filters.userId) { + qb.andWhere('r.reservedByUserId = :userId', { userId: filters.userId }); + } + if (filters.from) { + qb.andWhere('r.startsAt >= :from', { from: new Date(filters.from) }); + } + if (filters.to) { + qb.andWhere('r.endsAt <= :to', { to: new Date(filters.to) }); + } + if (filters.status) { + qb.andWhere('r.status = :status', { status: filters.status }); + } + + qb.orderBy('r.startsAt', 'DESC'); + + return qb.getMany(); + } + + async getAvailability( + assetId: string, + from: string, + to: string, + ): Promise<{ busy: Array<{ startsAt: Date; endsAt: Date }> }> { + const reservations = await this.reservationRepo.find({ + where: { + assetId, + status: 'CONFIRMED', + }, + select: ['startsAt', 'endsAt'], + }); + + const fromDate = new Date(from); + const toDate = new Date(to); + + const busy = reservations.filter( + (r) => r.startsAt < toDate && r.endsAt > fromDate, + ); + + return { busy }; + } + + async cancel( + id: string, + userId: string, + role: string, + ): Promise { + const reservation = await this.reservationRepo.findOneBy({ id }); + if (!reservation) { + throw new NotFoundException('Reservation not found'); + } + + const isOwner = reservation.reservedByUserId === userId; + const canCancel = + isOwner || role === 'ADMIN' || role === 'MANAGER'; + + if (!canCancel) { + throw new ForbiddenException( + 'You can only cancel your own reservations unless you are a manager or admin', + ); + } + + if ( + reservation.status === 'CANCELLED' || + reservation.status === 'COMPLETED' + ) { + throw new BadRequestException( + `Cannot cancel a reservation with status ${reservation.status}`, + ); + } + + reservation.status = 'CANCELLED'; + return this.reservationRepo.save(reservation); + } + + async confirm(id: string, role: string): Promise { + if (role !== 'ADMIN' && role !== 'MANAGER') { + throw new ForbiddenException('Only managers or admins can confirm reservations'); + } + + const reservation = await this.reservationRepo.findOneBy({ id }); + if (!reservation) { + throw new NotFoundException('Reservation not found'); + } + + if (reservation.status !== 'PENDING') { + throw new BadRequestException( + `Cannot confirm a reservation with status ${reservation.status}`, + ); + } + + reservation.status = 'CONFIRMED'; + return this.reservationRepo.save(reservation); + } +} diff --git a/frontend/app/(dashboard)/purchase-orders/page.tsx b/frontend/app/(dashboard)/purchase-orders/page.tsx new file mode 100644 index 00000000..192cdf7a --- /dev/null +++ b/frontend/app/(dashboard)/purchase-orders/page.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { useState } from "react"; +import { Plus, Search, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { CreatePOModal } from "@/components/purchase-orders/create-po-modal"; +import { ReceiveModal } from "@/components/purchase-orders/receive-modal"; +import { + usePurchaseOrders, + useCancelPO, +} from "@/lib/query/hooks/usePurchaseOrders"; +import { PurchaseOrder, POStatus } from "@/lib/api/purchase-orders"; + +const STATUS_COLORS: Record = { + [POStatus.DRAFT]: "bg-gray-100 text-gray-700", + [POStatus.ORDERED]: "bg-blue-100 text-blue-700", + [POStatus.PARTIALLY_RECEIVED]: "bg-yellow-100 text-yellow-700", + [POStatus.RECEIVED]: "bg-green-100 text-green-700", + [POStatus.CANCELLED]: "bg-red-100 text-red-700", +}; + +export default function PurchaseOrdersPage() { + const [search, setSearch] = useState(""); + const [showCreate, setShowCreate] = useState(false); + const [receiveTarget, setReceiveTarget] = useState(null); + const [cancelTarget, setCancelTarget] = useState(null); + + const { data: orders = [], isLoading } = usePurchaseOrders(); + const cancelPO = useCancelPO(); + + const filtered = orders.filter((o) => { + if (!search) return true; + const q = search.toLowerCase(); + return ( + o.poNumber.toLowerCase().includes(q) || + o.vendor?.name?.toLowerCase().includes(q) + ); + }); + + return ( +
+
+
+

Purchase Orders

+

+ Manage purchase orders and receive items as assets +

+
+ +
+ +
+ + setSearch(e.target.value)} + className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> + {search && ( + + )} +
+ +
+ + + + + + + + + + + + + + {isLoading ? ( + + ) : filtered.length === 0 ? ( + + ) : ( + filtered.map((po) => ( + + + + + + + + + + )) + )} + +
PO NumberVendorItemsTotalStatusExpectedActions
Loading...
No purchase orders found
{po.poNumber}{po.vendor?.name ?? "—"}{po.lineItems.length} + {po.totalAmount.toLocaleString(undefined, { style: "currency", currency: po.currency || "USD" })} + + + {po.status.replace(/_/g, " ")} + + + {po.expectedDate ? new Date(po.expectedDate).toLocaleDateString() : "—"} + +
+ {(po.status === POStatus.ORDERED || po.status === POStatus.PARTIALLY_RECEIVED) && ( + + )} + {po.status === POStatus.DRAFT && ( + + )} +
+
+
+ + {showCreate && setShowCreate(false)} />} + {receiveTarget && ( + setReceiveTarget(null)} /> + )} + { + if (cancelTarget) await cancelPO.mutateAsync(cancelTarget.id); + setCancelTarget(null); + }} + onCancel={() => setCancelTarget(null)} + title="Cancel Purchase Order" + description={`Are you sure you want to cancel ${cancelTarget?.poNumber}? This cannot be undone.`} + /> +
+ ); +} diff --git a/frontend/app/(dashboard)/vendors/page.tsx b/frontend/app/(dashboard)/vendors/page.tsx new file mode 100644 index 00000000..6de47c57 --- /dev/null +++ b/frontend/app/(dashboard)/vendors/page.tsx @@ -0,0 +1,226 @@ +"use client"; + +import { useState } from "react"; +import { Plus, Search, Pencil, Trash2, Store } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { Badge } from "@/components/ui/badge"; +import { VendorModal } from "@/components/vendors/vendor-modal"; +import { + useVendors, + useDeleteVendor, +} from "@/lib/query/hooks/useVendors"; +import { Vendor } from "@/lib/api/vendors"; + +export default function VendorsPage() { + const [search, setSearch] = useState(""); + const [showModal, setShowModal] = useState(false); + const [editVendor, setEditVendor] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteError, setDeleteError] = useState(""); + + const { data: vendors = [], isLoading } = useVendors(); + const deleteVendor = useDeleteVendor(); + + const filtered = vendors.filter((v) => { + if (!search) return true; + const q = search.toLowerCase(); + return ( + v.name.toLowerCase().includes(q) || + v.code.toLowerCase().includes(q) || + v.contactName?.toLowerCase().includes(q) + ); + }); + + const handleDelete = async () => { + if (!deleteTarget) return; + setDeleteError(""); + try { + await deleteVendor.mutateAsync(deleteTarget.id); + setDeleteTarget(null); + } catch (err: unknown) { + setDeleteError( + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message || "Failed to delete vendor." + ); + } + }; + + return ( +
+
+
+

Vendors

+

+ Manage suppliers and service providers for your assets +

+
+ +
+ +
+
+
+
+ +
+
+ + + + + + + + + + + + + + {isLoading ? ( + + + + ) : filtered.length === 0 ? ( + + + + ) : ( + filtered.map((vendor) => ( + + + + + + + + + + )) + )} + +
+ Name + + Code + + Contact + + Email + + Phone + + Status + + Actions +
+ Loading vendors... +
+ {search + ? "No vendors match your search." + : 'No vendors yet. Click "Add Vendor" to get started.'} +
+
+ + + {vendor.name} + +
+
+ {vendor.code} + + {vendor.contactName || "—"} + + {vendor.email || "—"} + + {vendor.phone || "—"} + + + {vendor.isActive ? "Active" : "Inactive"} + + +
+ + +
+
+
+ + {!isLoading && filtered.length > 0 && ( +
+

+ {filtered.length} vendor{filtered.length !== 1 ? "s" : ""} +

+
+ )} +
+ + {showModal && ( + setShowModal(false)} /> + )} + + {editVendor && ( + setEditVendor(null)} + /> + )} + + {deleteTarget && ( + setDeleteTarget(null)} + loading={deleteVendor.isPending} + /> + )} +
+ ); +} diff --git a/frontend/components/layout/sidebar.tsx b/frontend/components/layout/sidebar.tsx index ced3ac5d..36d7055c 100644 --- a/frontend/components/layout/sidebar.tsx +++ b/frontend/components/layout/sidebar.tsx @@ -17,6 +17,8 @@ import { KeyRound, Wrench, ClipboardCheck, + Store, + FileText, } from "lucide-react"; import { useAuthStore } from "@/store/auth.store"; @@ -24,6 +26,8 @@ const navItems = [ { href: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, { href: "/assets", label: "Assets", icon: Package }, { href: "/locations", label: "Locations", icon: MapPin }, + { href: "/vendors", label: "Vendors", icon: Store }, + { href: "/purchase-orders", label: "Purchase Orders", icon: FileText }, { href: "/maintenance", label: "Maintenance", icon: Wrench }, { href: "/audits", label: "Audits", icon: ClipboardCheck }, { href: "/licenses", label: "Licenses", icon: KeyRound }, diff --git a/frontend/components/purchase-orders/create-po-modal.tsx b/frontend/components/purchase-orders/create-po-modal.tsx new file mode 100644 index 00000000..ea21f0da --- /dev/null +++ b/frontend/components/purchase-orders/create-po-modal.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useState } from "react"; +import { useForm, useFieldArray } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { X, Plus, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useCreatePO } from "@/lib/query/hooks/usePurchaseOrders"; +import { useVendors } from "@/lib/query/hooks/useVendors"; + +const schema = z.object({ + vendorId: z.string().min(1, "Vendor is required"), + expectedDate: z.string().optional(), + lineItems: z.array( + z.object({ + description: z.string().min(1, "Description is required"), + category: z.string().optional(), + quantity: z.number().min(1, "Quantity must be at least 1"), + unitPrice: z.number().min(0, "Price must be positive"), + }) + ).min(1, "At least one line item is required"), +}); + +type FormValues = z.infer; + +interface Props { + onClose: () => void; +} + +export function CreatePOModal({ onClose }: Props) { + const { data: vendors = [] } = useVendors(); + const createPO = useCreatePO(); + const [error, setError] = useState(""); + + const { register, control, handleSubmit, watch, formState: { errors } } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + lineItems: [{ description: "", category: "", quantity: 1, unitPrice: 0 }], + }, + }); + + const { fields, append, remove } = useFieldArray({ control, name: "lineItems" }); + const lineItems = watch("lineItems"); + const total = lineItems.reduce((sum, item) => sum + (item.quantity * item.unitPrice), 0); + + const onSubmit = async (values: FormValues) => { + try { + await createPO.mutateAsync({ + vendorId: values.vendorId, + lineItems: values.lineItems, + expectedDate: values.expectedDate || undefined, + }); + onClose(); + } catch (err: unknown) { + setError((err as { response?: { data?: { message?: string } } })?.response?.data?.message || "Failed to create PO"); + } + }; + + return ( +
+
+
+
+

Create Purchase Order

+ +
+
+ {error &&

{error}

} + +
+ + + {errors.vendorId &&

{errors.vendorId.message}

} +
+ +
+ + +
+ +
+
+ + +
+
+ {fields.map((field, index) => ( +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ {fields.length > 1 && ( + + )} +
+
+ ))} +
+ {errors.lineItems &&

{errors.lineItems.message}

} +
+ +
+ + Total: {total.toLocaleString(undefined, { style: "currency", currency: "USD" })} + +
+ + +
+
+
+
+
+ ); +} diff --git a/frontend/components/purchase-orders/receive-modal.tsx b/frontend/components/purchase-orders/receive-modal.tsx new file mode 100644 index 00000000..47af83ac --- /dev/null +++ b/frontend/components/purchase-orders/receive-modal.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useState } from "react"; +import { X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useReceiveLineItems } from "@/lib/query/hooks/usePurchaseOrders"; +import { PurchaseOrder } from "@/lib/api/purchase-orders"; + +interface Props { + po: PurchaseOrder; + onClose: () => void; +} + +export function ReceiveModal({ po, onClose }: Props) { + const receiveItems = useReceiveLineItems(); + const [quantities, setQuantities] = useState>( + Object.fromEntries(po.lineItems.map((li) => [li.id ?? li.description, li.quantity - (li.receivedQuantity ?? 0)])) + ); + const [error, setError] = useState(""); + + const handleChange = (key: string, value: number) => { + setQuantities((prev) => ({ ...prev, [key]: Math.max(0, value) })); + }; + + const handleSubmit = async () => { + const items = po.lineItems + .filter((li) => { + const key = li.id ?? li.description; + return (quantities[key] ?? 0) > 0; + }) + .map((li) => ({ + lineItemId: li.id ?? "", + receivedQuantity: quantities[li.id ?? li.description] ?? 0, + })); + + if (items.length === 0) { + setError("Enter at least one quantity to receive"); + return; + } + + try { + await receiveItems.mutateAsync({ id: po.id, items }); + onClose(); + } catch (err: unknown) { + setError((err as { response?: { data?: { message?: string } } })?.response?.data?.message || "Failed to receive items"); + } + }; + + return ( +
+
+
+
+

Receive Items — {po.poNumber}

+ +
+
+ {error &&

{error}

} +

+ Enter the quantity received for each line item. Received items will be created as assets. +

+
+ {po.lineItems.map((li) => { + const key = li.id ?? li.description; + const remaining = li.quantity - (li.receivedQuantity ?? 0); + return ( +
+
+

{li.description}

+

+ Ordered: {li.quantity} | Received: {li.receivedQuantity ?? 0} | Remaining: {remaining} +

+
+
+ handleChange(key, Number(e.target.value))} + /> +
+
+ ); + })} +
+
+ + +
+
+
+
+ ); +} diff --git a/frontend/components/vendors/vendor-modal.tsx b/frontend/components/vendors/vendor-modal.tsx new file mode 100644 index 00000000..658fdf20 --- /dev/null +++ b/frontend/components/vendors/vendor-modal.tsx @@ -0,0 +1,215 @@ +"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 { Textarea } from "@/components/ui/textarea"; +import { + useCreateVendor, + useUpdateVendor, +} from "@/lib/query/hooks/useVendors"; +import { Vendor, CreateVendorInput } from "@/lib/api/vendors"; + +const schema = z.object({ + name: z.string().min(1, "Vendor name is required"), + code: z.string().min(1, "Vendor code is required"), + contactName: z.string().optional(), + email: z + .string() + .optional() + .refine((v) => !v || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v), { + message: "Invalid email address", + }), + phone: z.string().optional(), + address: z.string().optional(), + website: z.string().optional(), + taxId: z.string().optional(), + notes: z.string().optional(), +}); + +type FormValues = z.infer; + +interface Props { + mode: "create" | "edit"; + vendor?: Vendor; + onClose: () => void; +} + +export function VendorModal({ mode, vendor, onClose }: Props) { + const createVendor = useCreateVendor(); + const updateVendor = useUpdateVendor(); + const isPending = createVendor.isPending || updateVendor.isPending; + + const { + register, + handleSubmit, + setError, + formState: { errors }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: vendor + ? { + name: vendor.name, + code: vendor.code, + contactName: vendor.contactName ?? "", + email: vendor.email ?? "", + phone: vendor.phone ?? "", + address: vendor.address ?? "", + website: vendor.website ?? "", + taxId: vendor.taxId ?? "", + notes: vendor.notes ?? "", + } + : undefined, + }); + + const onSubmit = async (values: FormValues) => { + try { + const payload: CreateVendorInput = { + name: values.name.trim(), + code: values.code.trim(), + contactName: values.contactName?.trim() || undefined, + email: values.email?.trim() || undefined, + phone: values.phone?.trim() || undefined, + address: values.address?.trim() || undefined, + website: values.website?.trim() || undefined, + taxId: values.taxId?.trim() || undefined, + notes: values.notes?.trim() || undefined, + }; + + if (mode === "create") { + await createVendor.mutateAsync(payload); + } else if (vendor) { + await updateVendor.mutateAsync({ id: vendor.id, data: payload }); + } + onClose(); + } catch (err: unknown) { + const message = + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message || "Failed to save vendor."; + setError("root", { message }); + } + }; + + return ( +
+
+
+
+

+ {mode === "create" ? "New Vendor" : "Edit Vendor"} +

+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + + + +
+ +