Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion PROJECT_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ trip-search index. Full detail in §4.
| Observability — structured logs + health checks | ✅ | Serilog + `/health` + `/health/ready` |
| Frontend — customer surfaces for newest modules (seat-map picker, trip stops, passenger docs, promo entry, cancel, reviews, live seats) | ✅ | SPA wired to the backend |
| Frontend — vendor operations (trip lifecycle/stops/edit/delete, bus seat-layout/edit/delete, staff, drivers+assign, company profile) | ✅ | SPA wired to the backend |
| Frontend — vendor sales/analytics + admin (reports/export, demand, promo mgmt, counter booking, admin notify) | 🟡 | Backend complete; these SPA surfaces are the remaining build-out |
| Frontend — vendor sales/analytics + admin (reports/export, demand, promo mgmt, counter booking, admin notify + system overview) | | SPA wired to the backend |
| Cloud hosting / Infrastructure-as-Code | ❌ | Deferred by decision (images run anywhere; pick a target + add IaC/CD) |
| **Mobile app (iOS/Android)** | ❌ | Post-MVP |

Expand Down
21 changes: 21 additions & 0 deletions web/customer/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,22 @@ export const routes: Routes = [
canActivate: [roleGuard('VendorManager')],
loadComponent: () => import('./features/vendor/company').then((m) => m.VendorCompanyComponent),
},
{
path: 'vendor/promo',
canActivate: [roleGuard('VendorManager')],
loadComponent: () => import('./features/vendor/promo').then((m) => m.VendorPromoComponent),
},
{
path: 'vendor/reports',
canActivate: [roleGuard('VendorManager')],
loadComponent: () => import('./features/vendor/reports').then((m) => m.VendorReportsComponent),
},
{
// The desk is for managers AND staff (counter sales).
path: 'vendor/desk',
canActivate: [roleGuard('VendorManager', 'Staff')],
loadComponent: () => import('./features/vendor/desk').then((m) => m.VendorDeskComponent),
},

// ── Admin console (Admin / SuperAdmin only) ──
{ path: 'admin', pathMatch: 'full', redirectTo: 'admin/companies' },
Expand All @@ -94,6 +110,11 @@ export const routes: Routes = [
canActivate: [roleGuard('Admin', 'SuperAdmin')],
loadComponent: () => import('./features/admin/companies').then((m) => m.AdminCompaniesComponent),
},
{
path: 'admin/reports',
canActivate: [roleGuard('Admin', 'SuperAdmin')],
loadComponent: () => import('./features/admin/reports').then((m) => m.AdminReportsComponent),
},

{ path: '**', redirectTo: 'search' },
];
14 changes: 13 additions & 1 deletion web/customer/src/app/core/api/admin-api.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { inject, Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { Company, CompanyManager, CompanyStatus, PagedResult } from '../models';
import { AdminSystemSummary, Company, CompanyManager, CompanyStatus, PagedResult } from '../models';

export interface CreateCompanyRequest {
name: string;
Expand Down Expand Up @@ -43,4 +43,16 @@ export class AdminApiService {
createManager(companyId: string, body: CreateManagerRequest): Observable<CompanyManager> {
return this.http.post<CompanyManager>(`${this.base}/companies/${companyId}/manager`, body);
}

/** Send an in-app notification to a company's manager(s). */
notifyCompany(
companyId: string,
body: { title: string; message: string; type: string | null },
): Observable<unknown> {
return this.http.post(`${this.base}/companies/${companyId}/notify`, body);
}

systemSummary(): Observable<AdminSystemSummary> {
return this.http.get<AdminSystemSummary>(`${this.base}/reports/summary`);
}
}
81 changes: 81 additions & 0 deletions web/customer/src/app/core/api/vendor-api.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,22 @@ import { environment } from '../../../environments/environment';
import {
Bus,
BusType,
BookingResult,
CancelBookingResult,
CancelTripResult,
Company,
CompanyBooking,
DemandPrediction,
DiscountType,
Driver,
PagedResult,
PassengerInput,
PromoCodeDto,
Staff,
StaffType,
TripReportRow,
TripStop,
VendorReportSummary,
VendorTrip,
} from '../models';

Expand Down Expand Up @@ -56,6 +65,22 @@ export interface AddDriverRequest {
licenseNumber: string | null;
}

export interface CreatePromoCodeRequest {
code: string;
discountType: DiscountType;
discountValue: number;
maxRedemptions: number | null;
expiresAtUtc: string | null;
}

export interface CounterBookingRequest {
tripId: string;
customerEmail: string;
passengers: PassengerInput[];
}

export type ReportFormat = 'csv' | 'xlsx' | 'pdf';

/** Vendor-scoped API. Every call is auto-scoped to the caller's company by the backend. */
@Injectable({ providedIn: 'root' })
export class VendorApiService {
Expand Down Expand Up @@ -154,4 +179,60 @@ export class VendorApiService {
updateCompany(body: { name: string; phone: string | null }): Observable<Company> {
return this.http.put<Company>(`${this.base}/company`, body);
}

// ── Reports + demand ─────────────────────────────────────────────────────────────
reportSummary(from?: string, to?: string): Observable<VendorReportSummary> {
return this.http.get<VendorReportSummary>(`${this.base}/reports/summary`, { params: range(from, to) });
}

tripReport(from?: string, to?: string): Observable<TripReportRow[]> {
return this.http.get<TripReportRow[]>(`${this.base}/reports/trips`, { params: range(from, to) });
}

/** Download the per-trip report as a file (csv/xlsx/pdf). */
exportTripReport(format: ReportFormat, from?: string, to?: string): Observable<Blob> {
const params = range(from, to).set('format', format);
return this.http.get(`${this.base}/reports/trips/export`, { params, responseType: 'blob' });
}

predictDemand(origin: string, destination: string, date: string): Observable<DemandPrediction> {
const params = new HttpParams().set('origin', origin).set('destination', destination).set('date', date);
return this.http.get<DemandPrediction>(`${this.base}/demand/predict`, { params });
}

// ── Promo codes ──────────────────────────────────────────────────────────────────
listPromoCodes(page = 1, limit = 50): Observable<PagedResult<PromoCodeDto>> {
const params = new HttpParams().set('page', page).set('limit', limit);
return this.http.get<PagedResult<PromoCodeDto>>(`${this.base}/promo-codes`, { params });
}

createPromoCode(body: CreatePromoCodeRequest): Observable<PromoCodeDto> {
return this.http.post<PromoCodeDto>(`${this.base}/promo-codes`, body);
}

deactivatePromoCode(id: string): Observable<void> {
return this.http.post<void>(`${this.base}/promo-codes/${id}/deactivate`, {});
}

// ── Desk (counter booking) ───────────────────────────────────────────────────────
counterBooking(body: CounterBookingRequest): Observable<BookingResult> {
return this.http.post<BookingResult>(`${this.base}/bookings`, body);
}

listCompanyBookings(page = 1, limit = 20): Observable<PagedResult<CompanyBooking>> {
const params = new HttpParams().set('page', page).set('limit', limit);
return this.http.get<PagedResult<CompanyBooking>>(`${this.base}/bookings`, { params });
}

cancelCompanyBooking(bookingId: string): Observable<CancelBookingResult> {
return this.http.post<CancelBookingResult>(`${this.base}/bookings/${bookingId}/cancel`, {});
}
}

/** Build the optional from/to date-range query params shared by the report endpoints. */
function range(from?: string, to?: string): HttpParams {
let params = new HttpParams();
if (from) params = params.set('from', from);
if (to) params = params.set('to', to);
return params;
}
69 changes: 69 additions & 0 deletions web/customer/src/app/core/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,72 @@ export interface CancelTripResult {
cancelledPendingBookings: number;
confirmedBookingsAffected: number;
}

// ── Reports, demand, promotions, desk ────────────────────────────────────────────

export interface VendorReportSummary {
from: string;
to: string;
trips: number;
confirmedBookings: number;
seatsSold: number;
seatsOffered: number;
revenue: number;
currency: string;
occupancyPct: number;
}

export interface TripReportRow {
tripId: string;
origin: string;
destination: string;
departureUtc: string;
seatCount: number;
seatsSold: number;
revenue: number;
currency: string;
status: string;
}

export interface DemandPrediction {
origin: string;
destination: string;
date: string;
predictedBookings: number;
confidence: string;
sampleSize: number;
}

export type DiscountType = 'Percent' | 'Fixed';

export interface PromoCodeDto {
id: string;
code: string;
discountType: string;
discountValue: number;
maxRedemptions: number | null;
redemptionCount: number;
expiresAtUtc: string | null;
active: boolean;
}

export interface CompanyBooking {
bookingId: string;
reference: string;
customerEmail: string;
status: string;
totalAmount: number;
currency: string;
origin: string;
destination: string;
departureUtc: string;
createdAtUtc: string;
}

export interface AdminSystemSummary {
companies: number;
activeCompanies: number;
trips: number;
confirmedBookings: number;
revenue: number;
}
27 changes: 27 additions & 0 deletions web/customer/src/app/features/admin/admin-nav.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';

/** Sub-navigation for the admin console. */
@Component({
selector: 'app-admin-nav',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink, RouterLinkActive],
template: `
<nav class="mb-6 flex flex-wrap gap-1 border-b border-slate-200">
@for (link of links; track link.path) {
<a
[routerLink]="link.path"
routerLinkActive="border-indigo-600 text-indigo-700"
class="-mb-px border-b-2 border-transparent px-4 py-2 text-sm font-medium text-slate-500 hover:text-slate-800"
>{{ link.label }}</a
>
}
</nav>
`,
})
export class AdminNavComponent {
protected readonly links = [
{ path: '/admin/companies', label: 'Companies' },
{ path: '/admin/reports', label: 'Reports' },
];
}
45 changes: 44 additions & 1 deletion web/customer/src/app/features/admin/companies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@ import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { AdminApiService, CreateCompanyRequest, CreateManagerRequest } from '../../core/api/admin-api.service';
import { ToastService } from '../../core/toast/toast.service';
import { Company, CompanyStatus } from '../../core/models';
import { AdminNavComponent } from './admin-nav';

const STATUSES: CompanyStatus[] = ['Pending', 'Active', 'Suspended'];

@Component({
selector: 'app-admin-companies',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReactiveFormsModule],
imports: [ReactiveFormsModule, AdminNavComponent],
template: `
<app-admin-nav />
<h1 class="mb-6 text-2xl font-bold text-slate-900">Companies</h1>

<div class="grid gap-6 lg:grid-cols-3">
Expand Down Expand Up @@ -59,6 +61,9 @@ const STATUSES: CompanyStatus[] = ['Pending', 'Active', 'Suspended'];
<button type="button" (click)="toggleManager(c.id)" class="rounded-md bg-slate-100 px-3 py-1.5 font-medium text-slate-700 hover:bg-slate-200">
{{ managerFor() === c.id ? 'Close' : 'Add manager' }}
</button>
<button type="button" (click)="toggleNotify(c.id)" class="rounded-md bg-slate-100 px-3 py-1.5 font-medium text-slate-700 hover:bg-slate-200">
{{ notifyFor() === c.id ? 'Close' : 'Notify' }}
</button>
</div>

@if (managerFor() === c.id) {
Expand All @@ -70,6 +75,14 @@ const STATUSES: CompanyStatus[] = ['Pending', 'Active', 'Suspended'];
<button type="submit" [disabled]="busyId() === c.id" class="rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50">Create manager</button>
</form>
}

@if (notifyFor() === c.id) {
<form [formGroup]="notifyForm" (ngSubmit)="notify(c)" class="mt-3 space-y-2 rounded-lg bg-slate-50 p-3">
<input type="text" formControlName="title" placeholder="Title" class="w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
<textarea formControlName="message" rows="2" placeholder="Message to the company's manager(s)" class="w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"></textarea>
<button type="submit" [disabled]="busyId() === c.id" class="rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50">Send notification</button>
</form>
}
</div>
}
</div>
Expand Down Expand Up @@ -114,6 +127,7 @@ export class AdminCompaniesComponent implements OnInit {
protected readonly busyId = signal<string | null>(null);
protected readonly filter = signal<CompanyStatus | null>(null);
protected readonly managerFor = signal<string | null>(null);
protected readonly notifyFor = signal<string | null>(null);

protected readonly form = this.fb.nonNullable.group({
name: ['', [Validators.required, Validators.maxLength(200)]],
Expand All @@ -130,6 +144,11 @@ export class AdminCompaniesComponent implements OnInit {
],
});

protected readonly notifyForm = this.fb.nonNullable.group({
title: ['', [Validators.required, Validators.maxLength(200)]],
message: ['', [Validators.required, Validators.maxLength(2000)]],
});

ngOnInit(): void {
this.load();
}
Expand Down Expand Up @@ -200,9 +219,33 @@ export class AdminCompaniesComponent implements OnInit {

protected toggleManager(companyId: string): void {
this.managerFor.set(this.managerFor() === companyId ? null : companyId);
this.notifyFor.set(null);
this.managerForm.reset({ fullName: '', email: '', password: '' });
}

protected toggleNotify(companyId: string): void {
this.notifyFor.set(this.notifyFor() === companyId ? null : companyId);
this.managerFor.set(null);
this.notifyForm.reset({ title: '', message: '' });
}

protected notify(c: Company): void {
if (this.notifyForm.invalid) {
this.notifyForm.markAllAsTouched();
return;
}
const v = this.notifyForm.getRawValue();
this.busyId.set(c.id);
this.api.notifyCompany(c.id, { title: v.title.trim(), message: v.message.trim(), type: 'info' }).subscribe({
next: () => {
this.busyId.set(null);
this.notifyFor.set(null);
this.toasts.success(`Notification sent to ${c.name}.`);
},
error: () => this.busyId.set(null),
});
}

protected createManager(c: Company): void {
if (this.managerForm.invalid) {
this.managerForm.markAllAsTouched();
Expand Down
Loading