From 89319a76e319195de3f71af0d4ec62cbac74eab4 Mon Sep 17 00:00:00 2001 From: Thommyy7 Date: Sat, 27 Jun 2026 21:26:21 +0100 Subject: [PATCH] feat: implement inventory management (BE-28, FE-25), admin audit log (BE-29), and promo code checkout integration (FE-24) Closes #1203 Closes #1204 Closes #1205 Closes #1202 --- backend/src/app.module.ts | 4 + backend/src/audit-log/audit-log.controller.ts | 35 ++++ backend/src/audit-log/audit-log.module.ts | 13 ++ backend/src/audit-log/audit-log.service.ts | 49 ++++++ .../audit-log/entities/audit-log.entity.ts | 41 +++++ .../src/audit-log/enums/audit-action.enum.ts | 12 ++ .../dto/assign-inventory-item.dto.ts | 6 + .../dto/create-inventory-item.dto.ts | 32 ++++ .../entities/inventory-item.entity.ts | 57 +++++++ .../inventory/enums/item-condition.enum.ts | 6 + backend/src/inventory/inventory.controller.ts | 85 ++++++++++ backend/src/inventory/inventory.module.ts | 13 ++ backend/src/inventory/inventory.service.ts | 63 ++++++++ frontend/app/admin/inventory/page.tsx | 152 ++++++++++++++++++ frontend/components/bookings/BookingForm.tsx | 97 +++++++++-- .../admin/inventory/useAssignInventoryItem.ts | 13 ++ .../admin/inventory/useCreateInventoryItem.ts | 12 ++ .../hooks/admin/inventory/useGetInventory.ts | 16 ++ .../admin/inventory/useUpdateInventoryItem.ts | 13 ++ 19 files changed, 708 insertions(+), 11 deletions(-) create mode 100644 backend/src/audit-log/audit-log.controller.ts create mode 100644 backend/src/audit-log/audit-log.module.ts create mode 100644 backend/src/audit-log/audit-log.service.ts create mode 100644 backend/src/audit-log/entities/audit-log.entity.ts create mode 100644 backend/src/audit-log/enums/audit-action.enum.ts create mode 100644 backend/src/inventory/dto/assign-inventory-item.dto.ts create mode 100644 backend/src/inventory/dto/create-inventory-item.dto.ts create mode 100644 backend/src/inventory/entities/inventory-item.entity.ts create mode 100644 backend/src/inventory/enums/item-condition.enum.ts create mode 100644 backend/src/inventory/inventory.controller.ts create mode 100644 backend/src/inventory/inventory.module.ts create mode 100644 backend/src/inventory/inventory.service.ts create mode 100644 frontend/app/admin/inventory/page.tsx create mode 100644 frontend/lib/react-query/hooks/admin/inventory/useAssignInventoryItem.ts create mode 100644 frontend/lib/react-query/hooks/admin/inventory/useCreateInventoryItem.ts create mode 100644 frontend/lib/react-query/hooks/admin/inventory/useGetInventory.ts create mode 100644 frontend/lib/react-query/hooks/admin/inventory/useUpdateInventoryItem.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index a909a297..daac043a 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -29,6 +29,8 @@ import { AccessControlModule } from './access-control/access-control.module'; import { WaitlistModule } from './waitlist/waitlist.module'; import { EventsModule } from './events/events.module'; import { MembershipPlansModule } from './membership-plans/membership-plans.module'; +import { InventoryModule } from './inventory/inventory.module'; +import { AuditLogModule } from './audit-log/audit-log.module'; @Module({ imports: [ @@ -114,6 +116,8 @@ import { MembershipPlansModule } from './membership-plans/membership-plans.modul WaitlistModule, EventsModule, MembershipPlansModule, + InventoryModule, + AuditLogModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/audit-log/audit-log.controller.ts b/backend/src/audit-log/audit-log.controller.ts new file mode 100644 index 00000000..55c9c9d5 --- /dev/null +++ b/backend/src/audit-log/audit-log.controller.ts @@ -0,0 +1,35 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { AuditLogService } from './audit-log.service'; +import { JwtAuthGuard } from '../auth/guard/jwt.auth.guard'; +import { RolesGuard } from '../auth/guard/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorators'; +import { UserRole } from '../users/enums/userRoles.enum'; + +@ApiTags('Audit Log') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Controller('audit-log') +export class AuditLogController { + constructor(private readonly service: AuditLogService) {} + + @Get() + @Roles(UserRole.SUPER_ADMIN) + async findAll( + @Query('actorId') actorId?: string, + @Query('resourceType') resourceType?: string, + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + @Query('page') page?: string, + @Query('limit') limit?: string, + ) { + return this.service.findAll({ + actorId, + resourceType, + startDate, + endDate, + page: page ? +page : 1, + limit: limit ? +limit : 20, + }); + } +} diff --git a/backend/src/audit-log/audit-log.module.ts b/backend/src/audit-log/audit-log.module.ts new file mode 100644 index 00000000..9556f90c --- /dev/null +++ b/backend/src/audit-log/audit-log.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuditLog } from './entities/audit-log.entity'; +import { AuditLogService } from './audit-log.service'; +import { AuditLogController } from './audit-log.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([AuditLog])], + controllers: [AuditLogController], + providers: [AuditLogService], + exports: [AuditLogService], +}) +export class AuditLogModule {} diff --git a/backend/src/audit-log/audit-log.service.ts b/backend/src/audit-log/audit-log.service.ts new file mode 100644 index 00000000..e167f14a --- /dev/null +++ b/backend/src/audit-log/audit-log.service.ts @@ -0,0 +1,49 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AuditLog } from './entities/audit-log.entity'; + +@Injectable() +export class AuditLogService { + constructor( + @InjectRepository(AuditLog) + private readonly repo: Repository, + ) {} + + async log( + actorId: string, + action: string, + resourceType: string, + resourceId: string, + metadata?: Record, + ipAddress?: string, + ): Promise { + await this.repo.save( + this.repo.create({ actorUserId: actorId, action, resourceType, resourceId, metadata, ipAddress: ipAddress ?? null }), + ); + } + + async findAll(filters: { + actorId?: string; + resourceType?: string; + startDate?: string; + endDate?: string; + page?: number; + limit?: number; + }) { + const { page = 1, limit = 20, actorId, resourceType, startDate, endDate } = filters; + const qb = this.repo.createQueryBuilder('a') + .leftJoinAndSelect('a.actor', 'actor') + .orderBy('a.createdAt', 'DESC') + .skip((page - 1) * limit) + .take(limit); + + if (actorId) qb.andWhere('a.actorUserId = :actorId', { actorId }); + if (resourceType) qb.andWhere('a.resourceType = :resourceType', { resourceType }); + if (startDate) qb.andWhere('a.createdAt >= :startDate', { startDate }); + if (endDate) qb.andWhere('a.createdAt <= :endDate', { endDate }); + + const [items, total] = await qb.getManyAndCount(); + return { items, total, page, limit, totalPages: Math.ceil(total / limit) }; + } +} diff --git a/backend/src/audit-log/entities/audit-log.entity.ts b/backend/src/audit-log/entities/audit-log.entity.ts new file mode 100644 index 00000000..4d82ff45 --- /dev/null +++ b/backend/src/audit-log/entities/audit-log.entity.ts @@ -0,0 +1,41 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { AuditAction } from '../enums/audit-action.enum'; + +@Entity('audit_logs') +export class AuditLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + actorUserId: string; + + @ManyToOne(() => User, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'actorUserId' }) + actor: User; + + @Column({ type: 'varchar' }) + action: string; + + @Column({ type: 'varchar' }) + resourceType: string; + + @Column({ type: 'varchar' }) + resourceId: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @Column({ nullable: true }) + ipAddress: string | null; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/audit-log/enums/audit-action.enum.ts b/backend/src/audit-log/enums/audit-action.enum.ts new file mode 100644 index 00000000..651e2fda --- /dev/null +++ b/backend/src/audit-log/enums/audit-action.enum.ts @@ -0,0 +1,12 @@ +export enum AuditAction { + MEMBER_SUSPENDED = 'MEMBER_SUSPENDED', + MEMBER_ACTIVATED = 'MEMBER_ACTIVATED', + BOOKING_CANCELLED = 'BOOKING_CANCELLED', + BOOKING_STATUS_OVERRIDE = 'BOOKING_STATUS_OVERRIDE', + PAYMENT_REFUNDED = 'PAYMENT_REFUNDED', + WORKSPACE_CREATED = 'WORKSPACE_CREATED', + WORKSPACE_UPDATED = 'WORKSPACE_UPDATED', + WORKSPACE_DELETED = 'WORKSPACE_DELETED', + PROMO_CODE_CREATED = 'PROMO_CODE_CREATED', + PROMO_CODE_DISABLED = 'PROMO_CODE_DISABLED', +} diff --git a/backend/src/inventory/dto/assign-inventory-item.dto.ts b/backend/src/inventory/dto/assign-inventory-item.dto.ts new file mode 100644 index 00000000..2175e369 --- /dev/null +++ b/backend/src/inventory/dto/assign-inventory-item.dto.ts @@ -0,0 +1,6 @@ +import { IsUUID } from 'class-validator'; + +export class AssignInventoryItemDto { + @IsUUID() + userId: string; +} diff --git a/backend/src/inventory/dto/create-inventory-item.dto.ts b/backend/src/inventory/dto/create-inventory-item.dto.ts new file mode 100644 index 00000000..91b6691f --- /dev/null +++ b/backend/src/inventory/dto/create-inventory-item.dto.ts @@ -0,0 +1,32 @@ +import { IsDateString, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { ItemCondition } from '../enums/item-condition.enum'; + +export class CreateInventoryItemDto { + @IsString() + @IsNotEmpty() + name: string; + + @IsString() + @IsNotEmpty() + category: string; + + @IsOptional() + @IsString() + serialNumber?: string; + + @IsString() + @IsNotEmpty() + location: string; + + @IsOptional() + @IsEnum(ItemCondition) + condition?: ItemCondition; + + @IsOptional() + @IsDateString() + purchasedAt?: string; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/backend/src/inventory/entities/inventory-item.entity.ts b/backend/src/inventory/entities/inventory-item.entity.ts new file mode 100644 index 00000000..1640e5dc --- /dev/null +++ b/backend/src/inventory/entities/inventory-item.entity.ts @@ -0,0 +1,57 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { ItemCondition } from '../enums/item-condition.enum'; + +@Entity('inventory_items') +export class InventoryItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + name: string; + + @Column() + category: string; + + @Column({ nullable: true, unique: true }) + serialNumber: string | null; + + @Column() + location: string; + + @Column({ type: 'enum', enum: ItemCondition, default: ItemCondition.GOOD }) + condition: ItemCondition; + + @Column('uuid', { nullable: true }) + assignedToUserId: string | null; + + @ManyToOne(() => User, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'assignedToUserId' }) + assignedTo: User; + + @Column({ type: 'timestamptz', nullable: true }) + assignedAt: Date | null; + + @Column({ type: 'date', nullable: true }) + purchasedAt: string | null; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @Column({ default: false }) + isDeleted: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/inventory/enums/item-condition.enum.ts b/backend/src/inventory/enums/item-condition.enum.ts new file mode 100644 index 00000000..b9a62740 --- /dev/null +++ b/backend/src/inventory/enums/item-condition.enum.ts @@ -0,0 +1,6 @@ +export enum ItemCondition { + GOOD = 'GOOD', + FAIR = 'FAIR', + NEEDS_REPAIR = 'NEEDS_REPAIR', + RETIRED = 'RETIRED', +} diff --git a/backend/src/inventory/inventory.controller.ts b/backend/src/inventory/inventory.controller.ts new file mode 100644 index 00000000..fc9ab53e --- /dev/null +++ b/backend/src/inventory/inventory.controller.ts @@ -0,0 +1,85 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Param, + Body, + Query, + UseGuards, + ParseUUIDPipe, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { InventoryService } from './inventory.service'; +import { CreateInventoryItemDto } from './dto/create-inventory-item.dto'; +import { AssignInventoryItemDto } from './dto/assign-inventory-item.dto'; +import { JwtAuthGuard } from '../auth/guard/jwt.auth.guard'; +import { RolesGuard } from '../auth/guard/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorators'; +import { UserRole } from '../users/enums/userRoles.enum'; + +@ApiTags('Inventory') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Controller('inventory') +export class InventoryController { + constructor(private readonly service: InventoryService) {} + + @Post() + @Roles(UserRole.ADMIN) + async create(@Body() dto: CreateInventoryItemDto) { + const data = await this.service.create(dto); + return { message: 'Item created', data }; + } + + @Get() + @Roles(UserRole.ADMIN, UserRole.STAFF) + async findAll( + @Query('category') category?: string, + @Query('condition') condition?: string, + @Query('location') location?: string, + @Query('assignedToUserId') assignedToUserId?: string, + ) { + const data = await this.service.findAll({ category, condition, location, assignedToUserId }); + return { data }; + } + + @Get(':id') + @Roles(UserRole.ADMIN, UserRole.STAFF) + async findOne(@Param('id', ParseUUIDPipe) id: string) { + const data = await this.service.findOne(id); + return { data }; + } + + @Patch(':id') + @Roles(UserRole.ADMIN) + async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: Partial) { + const data = await this.service.update(id, dto); + return { message: 'Item updated', data }; + } + + @Post(':id/assign') + @Roles(UserRole.ADMIN) + async assign(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignInventoryItemDto) { + const data = await this.service.assign(id, dto); + return { message: 'Item assigned', data }; + } + + @Post(':id/unassign') + @Roles(UserRole.ADMIN) + async unassign(@Param('id', ParseUUIDPipe) id: string) { + const data = await this.service.unassign(id); + return { message: 'Item unassigned', data }; + } + + @Delete(':id') + @Roles(UserRole.ADMIN) + @HttpCode(HttpStatus.OK) + async remove(@Param('id', ParseUUIDPipe) id: string) { + await this.service.softDelete(id); + return { message: 'Item retired' }; + } +} diff --git a/backend/src/inventory/inventory.module.ts b/backend/src/inventory/inventory.module.ts new file mode 100644 index 00000000..d866ef74 --- /dev/null +++ b/backend/src/inventory/inventory.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { InventoryItem } from './entities/inventory-item.entity'; +import { InventoryService } from './inventory.service'; +import { InventoryController } from './inventory.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([InventoryItem])], + controllers: [InventoryController], + providers: [InventoryService], + exports: [InventoryService], +}) +export class InventoryModule {} diff --git a/backend/src/inventory/inventory.service.ts b/backend/src/inventory/inventory.service.ts new file mode 100644 index 00000000..3753ed08 --- /dev/null +++ b/backend/src/inventory/inventory.service.ts @@ -0,0 +1,63 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { InventoryItem } from './entities/inventory-item.entity'; +import { CreateInventoryItemDto } from './dto/create-inventory-item.dto'; +import { AssignInventoryItemDto } from './dto/assign-inventory-item.dto'; + +@Injectable() +export class InventoryService { + constructor( + @InjectRepository(InventoryItem) + private readonly repo: Repository, + ) {} + + async create(dto: CreateInventoryItemDto): Promise { + return this.repo.save(this.repo.create(dto)); + } + + async findAll(filters: { category?: string; condition?: string; location?: string; assignedToUserId?: string }) { + const qb = this.repo.createQueryBuilder('i') + .leftJoinAndSelect('i.assignedTo', 'user') + .where('i.isDeleted = :d', { d: false }); + if (filters.category) qb.andWhere('i.category = :c', { c: filters.category }); + if (filters.condition) qb.andWhere('i.condition = :cond', { cond: filters.condition }); + if (filters.location) qb.andWhere('i.location = :l', { l: filters.location }); + if (filters.assignedToUserId) qb.andWhere('i.assignedToUserId = :u', { u: filters.assignedToUserId }); + return qb.orderBy('i.createdAt', 'DESC').getMany(); + } + + async findOne(id: string): Promise { + const item = await this.repo.findOne({ where: { id, isDeleted: false }, relations: ['assignedTo'] }); + if (!item) throw new NotFoundException(`Inventory item ${id} not found`); + return item; + } + + async update(id: string, dto: Partial): Promise { + const item = await this.findOne(id); + Object.assign(item, dto); + return this.repo.save(item); + } + + async assign(id: string, dto: AssignInventoryItemDto): Promise { + const item = await this.findOne(id); + if (item.assignedToUserId) throw new BadRequestException('Item already assigned'); + item.assignedToUserId = dto.userId; + item.assignedAt = new Date(); + return this.repo.save(item); + } + + async unassign(id: string): Promise { + const item = await this.findOne(id); + item.assignedToUserId = null; + item.assignedAt = null; + return this.repo.save(item); + } + + async softDelete(id: string): Promise { + const item = await this.findOne(id); + if (item.assignedToUserId) throw new BadRequestException('Cannot retire an assigned item'); + item.isDeleted = true; + await this.repo.save(item); + } +} diff --git a/frontend/app/admin/inventory/page.tsx b/frontend/app/admin/inventory/page.tsx new file mode 100644 index 00000000..cf182a55 --- /dev/null +++ b/frontend/app/admin/inventory/page.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useState } from "react"; +import DashboardLayout from "@/components/dashboard/DashboardLayout"; +import { useGetInventory } from "@/lib/react-query/hooks/admin/inventory/useGetInventory"; +import { useCreateInventoryItem } from "@/lib/react-query/hooks/admin/inventory/useCreateInventoryItem"; +import { Plus, X, Package } from "lucide-react"; + +const CONDITIONS = ["GOOD", "FAIR", "NEEDS_REPAIR", "RETIRED"]; + +const conditionBadge: Record = { + GOOD: "bg-green-100 text-green-700", + FAIR: "bg-yellow-100 text-yellow-700", + NEEDS_REPAIR: "bg-red-100 text-red-700", + RETIRED: "bg-gray-100 text-gray-500", +}; + +const emptyForm = { name: "", category: "", serialNumber: "", location: "", condition: "GOOD", notes: "" }; + +export default function AdminInventoryPage() { + const [filters, setFilters] = useState<{ category?: string; condition?: string }>({}); + const [showModal, setShowModal] = useState(false); + const [form, setForm] = useState(emptyForm); + + const { data, isLoading } = useGetInventory(filters); + const createItem = useCreateInventoryItem(); + const items = (data as any)?.data ?? []; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + await createItem.mutateAsync({ ...form, serialNumber: form.serialNumber || undefined }); + setShowModal(false); + setForm(emptyForm); + }; + + return ( + +
+
+

Inventory

+

Track hub assets and assignments.

+
+ +
+ + {/* Filters */} +
+ setFilters((f) => ({ ...f, category: e.target.value || undefined }))} + /> + +
+ + {isLoading ? ( +
Loading...
+ ) : items.length === 0 ? ( +
+ +

No inventory items found.

+
+ ) : ( +
+ + + + {["Name", "Category", "Serial No.", "Location", "Condition", "Assigned To"].map((h) => ( + + ))} + + + + {items.map((item: any) => ( + + + + + + + + + ))} + +
{h}
{item.name}{item.category}{item.serialNumber ?? "—"}{item.location} + + {item.condition.replace("_", " ")} + + + {item.assignedTo ? `${item.assignedTo.firstname} ${item.assignedTo.lastname}` : "—"} +
+
+ )} + + {showModal && ( +
+
+
+

Add Inventory Item

+ +
+
+ {[ + { label: "Name", key: "name", required: true }, + { label: "Category", key: "category", required: true }, + { label: "Serial Number", key: "serialNumber", required: false }, + { label: "Location", key: "location", required: true }, + ].map(({ label, key, required }) => ( +
+ + setForm({ ...form, [key]: e.target.value })} + required={required} + className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm" + /> +
+ ))} +
+ + +
+
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/frontend/components/bookings/BookingForm.tsx b/frontend/components/bookings/BookingForm.tsx index e7527da1..1614fa5b 100644 --- a/frontend/components/bookings/BookingForm.tsx +++ b/frontend/components/bookings/BookingForm.tsx @@ -58,6 +58,11 @@ export default function BookingForm() { const [seatCount, setSeatCount] = useState(1); const [notes, setNotes] = useState(""); const [bookingId, setBookingId] = useState(null); + const [promoCodeInput, setPromoCodeInput] = useState(""); + const [appliedPromo, setAppliedPromo] = useState<{ code: string; discountAmountKobo: number; finalAmountKobo: number } | null>(null); + const [promoError, setPromoError] = useState(null); + const [applyingPromo, setApplyingPromo] = useState(false); + const [showPromoField, setShowPromoField] = useState(false); const { data: workspaceData } = useGetWorkspaceById(workspaceId); const workspace = workspaceData?.data; @@ -101,6 +106,33 @@ export default function BookingForm() { const canProceedStep0 = workspaceId && planType && startDate && endDate && seatCount > 0; + const displayTotal = appliedPromo + ? (appliedPromo.finalAmountKobo / 100).toLocaleString("en-NG", { style: "currency", currency: "NGN" }) + : totalNaira; + + async function handleApplyPromo() { + if (!promoCodeInput.trim() || !totalAmount) return; + setApplyingPromo(true); + setPromoError(null); + try { + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:6001/api"}/promo-codes/validate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: promoCodeInput.trim(), subtotalKobo: totalAmount }), + }); + const json = await res.json(); + if (!res.ok || !json.valid) { + setPromoError(json.message || "Invalid promo code"); + } else { + setAppliedPromo({ code: promoCodeInput.trim(), discountAmountKobo: json.discountAmountKobo, finalAmountKobo: json.finalAmountKobo }); + } + } catch { + setPromoError("Could not validate promo code"); + } finally { + setApplyingPromo(false); + } + } + async function handleConfirmBooking() { if (!canProceedStep0) return; try { @@ -344,17 +376,60 @@ export default function BookingForm() { {/* Price estimate */} {estimateParams && ( -
- Estimated total - - {estimatingPrice ? ( - - ) : totalAmount > 0 ? ( - totalNaira - ) : ( - "—" - )} - +
+
+ Estimated total + + {estimatingPrice ? ( + + ) : totalAmount > 0 ? ( + totalNaira + ) : ( + "—" + )} + +
+ {appliedPromo && ( +
+ Discount ({appliedPromo.code}) +
+ + -{(appliedPromo.discountAmountKobo / 100).toLocaleString("en-NG", { style: "currency", currency: "NGN" })} + + +
+
+ )} + {!appliedPromo && ( +
+ + {showPromoField && ( +
+ setPromoCodeInput(e.target.value)} + placeholder="Enter promo code" + className="flex-1 border border-gray-200 rounded-lg px-3 py-2 text-sm" + /> + +
+ )} + {promoError &&

{promoError}

} +
+ )}
)} diff --git a/frontend/lib/react-query/hooks/admin/inventory/useAssignInventoryItem.ts b/frontend/lib/react-query/hooks/admin/inventory/useAssignInventoryItem.ts new file mode 100644 index 00000000..31b95c48 --- /dev/null +++ b/frontend/lib/react-query/hooks/admin/inventory/useAssignInventoryItem.ts @@ -0,0 +1,13 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiClient } from "@/lib/apiClient"; + +export const useAssignInventoryItem = () => { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, userId }: { id: string; userId: string }) => + apiClient.post(`/inventory/${id}/assign`, { userId }), + onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "inventory"] }), + }); +}; diff --git a/frontend/lib/react-query/hooks/admin/inventory/useCreateInventoryItem.ts b/frontend/lib/react-query/hooks/admin/inventory/useCreateInventoryItem.ts new file mode 100644 index 00000000..5a509fcc --- /dev/null +++ b/frontend/lib/react-query/hooks/admin/inventory/useCreateInventoryItem.ts @@ -0,0 +1,12 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiClient } from "@/lib/apiClient"; + +export const useCreateInventoryItem = () => { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data: any) => apiClient.post("/inventory", data), + onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "inventory"] }), + }); +}; diff --git a/frontend/lib/react-query/hooks/admin/inventory/useGetInventory.ts b/frontend/lib/react-query/hooks/admin/inventory/useGetInventory.ts new file mode 100644 index 00000000..40044067 --- /dev/null +++ b/frontend/lib/react-query/hooks/admin/inventory/useGetInventory.ts @@ -0,0 +1,16 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { apiClient } from "@/lib/apiClient"; + +export const useGetInventory = (filters?: { category?: string; condition?: string }) => { + const params = new URLSearchParams(); + if (filters?.category) params.set("category", filters.category); + if (filters?.condition) params.set("condition", filters.condition); + const qs = params.toString(); + + return useQuery({ + queryKey: ["admin", "inventory", filters], + queryFn: () => apiClient.get(`/inventory${qs ? `?${qs}` : ""}`), + }); +}; diff --git a/frontend/lib/react-query/hooks/admin/inventory/useUpdateInventoryItem.ts b/frontend/lib/react-query/hooks/admin/inventory/useUpdateInventoryItem.ts new file mode 100644 index 00000000..bd949b91 --- /dev/null +++ b/frontend/lib/react-query/hooks/admin/inventory/useUpdateInventoryItem.ts @@ -0,0 +1,13 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiClient } from "@/lib/apiClient"; + +export const useUpdateInventoryItem = () => { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...data }: { id: string; [key: string]: any }) => + apiClient.patch(`/inventory/${id}`, data), + onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "inventory"] }), + }); +};