Files
tmtsaqatravelmanager/Tmt.SaqaTravel/Components/Pages/Home.razor
T
2026-07-09 21:41:26 +02:00

640 lines
39 KiB
Plaintext

@page "/"
@rendermode InteractiveServer
@using System
@using System.Collections.Generic
@using System.Linq
@using System.Threading.Tasks
<PageTitle>Overview Dashboard</PageTitle>
<div class="max-w-[1600px] mx-auto pb-8">
<!-- Header -->
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-end mb-[22px] gap-3">
<div>
<h2 class="text-[22px] font-bold text-[#1f2a3a] tracking-tight outline-none focus:outline-none select-none" tabindex="-1">Travel Overview</h2>
<p class="text-[13px] text-[#5a6577] mt-1">Consolidated single source of truth, May 2026 reporting period</p>
</div>
<div class="flex gap-2">
<button class="flex items-center gap-1.5 px-[14px] py-2 bg-white border border-[#e4eaf2] text-[#1f2a3a] rounded-lg text-[13px] font-semibold hover:bg-[#f4f7fb] transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-3.5 h-3.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
Export
</button>
<button @onclick="@(() => OpenBookingModal())" class="flex items-center gap-1.5 px-[14px] py-2 bg-[#0066b3] text-white rounded-lg text-[13px] font-semibold hover:bg-[#003b71] transition-colors cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-3.5 h-3.5">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
New Booking
</button>
</div>
</div>
<!-- KPI Cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<!-- Spend Card -->
<div class="bg-white rounded-[10px] shadow-[0_1px_2px_rgba(15,23,42,.06)] border border-[#e4eaf2] overflow-hidden relative group p-[18px]">
<div class="absolute left-0 top-0 bottom-0 w-1 bg-[#0066b3] transition-all duration-500 group-hover:w-1.5"></div>
<div class="transition-all duration-700 ease-out" style="opacity: @(_isLoaded ? "1" : "0"); transform: translateY(@(_isLoaded ? "0" : "10px"));">
<p class="text-[11px] font-semibold text-[#5a6577] tracking-[0.08em] uppercase mb-2.5">Total Travel Spend</p>
<div class="text-[26px] font-bold text-[#1f2a3a] tracking-tight leading-none">R @(_kpiSpend.ToString("N0"))</div>
<div class="flex items-center gap-1.5 mt-2 text-[12px]">
<span class="text-[#22a06b] font-semibold">▲ 4.8%</span>
<span class="text-[#8a96a8]">vs. April 2026</span>
</div>
</div>
</div>
<!-- Savings Card -->
<div class="bg-white rounded-[10px] shadow-[0_1px_2px_rgba(15,23,42,.06)] border border-[#e4eaf2] overflow-hidden relative group p-[18px]">
<div class="absolute left-0 top-0 bottom-0 w-1 bg-[#00a3a1] transition-all duration-500 group-hover:w-1.5"></div>
<div class="transition-all duration-700 ease-out delay-100" style="opacity: @(_isLoaded ? "1" : "0"); transform: translateY(@(_isLoaded ? "0" : "10px"));">
<p class="text-[11px] font-semibold text-[#5a6577] tracking-[0.08em] uppercase mb-2.5">Negotiated Savings</p>
<div class="text-[26px] font-bold text-[#1f2a3a] tracking-tight leading-none">R @(_kpiSavings.ToString("N0"))</div>
<div class="flex items-center gap-1.5 mt-2 text-[12px]">
<span class="text-[#22a06b] font-semibold">▲ 14.5%</span>
<span class="text-[#8a96a8]">discounted fares</span>
</div>
</div>
</div>
<!-- Travellers Card -->
<div class="bg-white rounded-[10px] shadow-[0_1px_2px_rgba(15,23,42,.06)] border border-[#e4eaf2] overflow-hidden relative group p-[18px]">
<div class="absolute left-0 top-0 bottom-0 w-1 bg-[#22a06b] transition-all duration-500 group-hover:w-1.5"></div>
<div class="transition-all duration-700 ease-out delay-200" style="opacity: @(_isLoaded ? "1" : "0"); transform: translateY(@(_isLoaded ? "0" : "10px"));">
<p class="text-[11px] font-semibold text-[#5a6577] tracking-[0.08em] uppercase mb-2.5">Active Travellers</p>
<div class="text-[26px] font-bold text-[#1f2a3a] tracking-tight leading-none">@(_kpiTravellers)</div>
<div class="flex items-center gap-1.5 mt-2 text-[12px]">
<span class="text-[#22a06b] font-semibold">▲ 12</span>
<span class="text-[#8a96a8]">this month</span>
</div>
</div>
</div>
<!-- Out of Policy Card -->
<div class="bg-white rounded-[10px] shadow-[0_1px_2px_rgba(15,23,42,.06)] border border-[#e4eaf2] overflow-hidden relative group p-[18px]">
<div class="absolute left-0 top-0 bottom-0 w-1 bg-[#f4a300] transition-all duration-500 group-hover:w-1.5"></div>
<div class="transition-all duration-700 ease-out delay-300" style="opacity: @(_isLoaded ? "1" : "0"); transform: translateY(@(_isLoaded ? "0" : "10px"));">
<p class="text-[11px] font-semibold text-[#5a6577] tracking-[0.08em] uppercase mb-2.5">Bookings Outside Policy</p>
<div class="text-[26px] font-bold text-[#1f2a3a] tracking-tight leading-none">@(_kpiPolicy)</div>
<div class="flex items-center gap-1.5 mt-2 text-[12px]">
<span class="text-[#dc2626] font-semibold">▼ 3</span>
<span class="text-[#8a96a8]">vs. last month</span>
</div>
</div>
</div>
</div>
<!-- Main Grid Row 1 (Charts) -->
<div class="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-4 mb-[22px]">
<!-- DYNAMIC SPEND TREND CHART -->
<div class="bg-white rounded-[10px] shadow-[0_1px_2px_rgba(15,23,42,.06)] border border-[#e4eaf2] p-5 flex flex-col">
<div class="flex justify-between items-center mb-4">
<div>
<h3 class="text-[15px] font-bold text-[#1f2a3a]">Monthly Spend Trend</h3>
<p class="text-[12px] text-[#8a96a8] mt-0.5">Air, accommodation and ground transport over 6 months</p>
</div>
<span class="bg-[#e2eefb] text-[#0066b3] px-2.5 py-[3px] rounded-full text-[11px] font-semibold">Live from GDS</span>
</div>
<div class="relative flex-1 w-full min-h-[260px] flex items-end pt-4">
<!-- Y-Axis Labels -->
<div class="absolute left-0 top-0 bottom-8 flex flex-col justify-between text-[11px] text-[#8a96a8] pr-2 pb-2 z-10 transition-opacity duration-700" style="opacity: @(_isLoaded ? "1" : "0")">
<span>R @((MaxValue).ToString("N0"))k</span>
<span>R @((MaxValue * 0.75).ToString("N0"))k</span>
<span>R @((MaxValue * 0.5).ToString("N0"))k</span>
<span>R @((MaxValue * 0.25).ToString("N0"))k</span>
<span>R 0k</span>
</div>
<!-- Grid Lines -->
<div class="absolute inset-0 ml-12 flex flex-col justify-between pb-8">
<div class="border-b border-[#eef2f8] w-full h-0"></div>
<div class="border-b border-[#eef2f8] w-full h-0"></div>
<div class="border-b border-[#eef2f8] w-full h-0"></div>
<div class="border-b border-[#eef2f8] w-full h-0"></div>
<div class="border-b border-[#eef2f8] w-full h-0"></div>
</div>
<!-- Dynamic SVG Chart -->
<div class="absolute inset-0 ml-12 pb-8">
<svg class="h-full w-full overflow-visible" preserveAspectRatio="none" viewBox="0 0 @ChartWidth @ChartHeight">
<defs>
<linearGradient id="airGradient" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="#0066b3" stop-opacity="0.15" />
<stop offset="100%" stop-color="#0066b3" stop-opacity="0" />
</linearGradient>
<linearGradient id="accGradient" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="#00a3a1" stop-opacity="0.15" />
<stop offset="100%" stop-color="#00a3a1" stop-opacity="0" />
</linearGradient>
<linearGradient id="groundGradient" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="#f4a300" stop-opacity="0.15" />
<stop offset="100%" stop-color="#f4a300" stop-opacity="0" />
</linearGradient>
</defs>
<!-- Animating Group (Draws from left to right) -->
<g class="transition-all duration-[1500ms] ease-in-out" style="clip-path: inset(0 @(_isLoaded ? "0%" : "100%") 0 0);">
<!-- Area Fills -->
<polygon fill="url(#airGradient)" points="@GetPolygonPoints(AirData)" />
<polygon fill="url(#accGradient)" points="@GetPolygonPoints(AccData)" />
<polygon fill="url(#groundGradient)" points="@GetPolygonPoints(GroundData)" />
<!-- Data Lines -->
<polyline fill="none" stroke="#f4a300" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" points="@GetPolylinePoints(GroundData)" />
<polyline fill="none" stroke="#00a3a1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" points="@GetPolylinePoints(AccData)" />
<polyline fill="none" stroke="#0066b3" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" points="@GetPolylinePoints(AirData)" />
</g>
<!-- Interactive Data Points (Popping in synced with the line drawing) -->
@for (int i = 0; i < Months.Length; i++)
{
var groundCoord = GetCoordinate(i, GroundData[i]);
var accCoord = GetCoordinate(i, AccData[i]);
var airCoord = GetCoordinate(i, AirData[i]);
double delaySeconds = 1.5 * ((double)i / (Months.Length - 1));
string delayStyle = $"transition-delay: {delaySeconds.ToString(System.Globalization.CultureInfo.InvariantCulture)}s;";
<g class="group cursor-pointer transition-all duration-500 ease-out" style="opacity: @(_isLoaded ? "1" : "0"); transform: scale(@(_isLoaded ? "1" : "0")); transform-origin: @(groundCoord.X)px @(groundCoord.Y)px; @delayStyle">
<circle cx="@groundCoord.X" cy="@groundCoord.Y" r="4" fill="white" stroke="#f4a300" stroke-width="2" class="transition-all duration-200 group-hover:r-5" />
<text x="@groundCoord.X" y="@(groundCoord.Y - 10)" text-anchor="middle" fill="#1f2a3a" font-size="11" font-family="Inter" font-weight="600" class="opacity-0 group-hover:opacity-100 transition-opacity">R @GroundData[i]k</text>
</g>
<g class="group cursor-pointer transition-all duration-500 ease-out" style="opacity: @(_isLoaded ? "1" : "0"); transform: scale(@(_isLoaded ? "1" : "0")); transform-origin: @(accCoord.X)px @(accCoord.Y)px; @delayStyle">
<circle cx="@accCoord.X" cy="@accCoord.Y" r="4" fill="white" stroke="#00a3a1" stroke-width="2" class="transition-all duration-200 group-hover:r-5" />
<text x="@accCoord.X" y="@(accCoord.Y - 10)" text-anchor="middle" fill="#1f2a3a" font-size="11" font-family="Inter" font-weight="600" class="opacity-0 group-hover:opacity-100 transition-opacity">R @AccData[i]k</text>
</g>
<g class="group cursor-pointer transition-all duration-500 ease-out" style="opacity: @(_isLoaded ? "1" : "0"); transform: scale(@(_isLoaded ? "1" : "0")); transform-origin: @(airCoord.X)px @(airCoord.Y)px; @delayStyle">
<circle cx="@airCoord.X" cy="@airCoord.Y" r="4" fill="white" stroke="#0066b3" stroke-width="2" class="transition-all duration-200 group-hover:r-5" />
<text x="@airCoord.X" y="@(airCoord.Y - 10)" text-anchor="middle" fill="#1f2a3a" font-size="11" font-family="Inter" font-weight="600" class="opacity-0 group-hover:opacity-100 transition-opacity">R @AirData[i]k</text>
</g>
}
</svg>
</div>
<!-- X-Axis Labels -->
<div class="absolute bottom-0 left-12 right-0 flex justify-between text-[11px] text-[#8a96a8] transition-opacity duration-1000 delay-300" style="opacity: @(_isLoaded ? "1" : "0")">
@foreach (var month in Months)
{
<span class="w-8 text-center -ml-4">@month</span>
}
</div>
</div>
<!-- Legend -->
<div class="flex justify-center gap-6 mt-4 transition-opacity duration-1000 delay-500" style="opacity: @(_isLoaded ? "1" : "0")">
<div class="flex items-center gap-2">
<span class="w-2.5 h-2.5 bg-[#0066b3]"></span>
<span class="text-[11px] text-[#5a6577]">Air</span>
</div>
<div class="flex items-center gap-2">
<span class="w-2.5 h-2.5 bg-[#00a3a1]"></span>
<span class="text-[11px] text-[#5a6577]">Accommodation</span>
</div>
<div class="flex items-center gap-2">
<span class="w-2.5 h-2.5 bg-[#f4a300]"></span>
<span class="text-[11px] text-[#5a6577]">Ground</span>
</div>
</div>
</div>
<!-- Spend by Category -->
<div class="bg-white rounded-[10px] shadow-[0_1px_2px_rgba(15,23,42,.06)] border border-[#e4eaf2] p-5">
<div class="mb-5">
<h3 class="text-[15px] font-bold text-[#1f2a3a]">Spend by Category</h3>
<p class="text-[12px] text-[#8a96a8] mt-0.5">May 2026</p>
</div>
<div class="space-y-4 pt-2">
<div>
<div class="flex justify-between text-[12px] mb-1">
<span class="text-[#1f2a3a]">Air Travel</span>
<span class="text-[#5a6577] font-semibold transition-opacity duration-500 delay-[500ms]" style="opacity: @(_isLoaded ? "1" : "0")">R 1.24M</span>
</div>
<div class="w-full bg-[#f4f7fb] rounded-full h-1.5">
<div class="bg-[#0066b3] h-1.5 rounded-full transition-all duration-[1500ms] ease-out" style="width: @(_isLoaded ? "50%" : "0%")"></div>
</div>
</div>
<div>
<div class="flex justify-between text-[12px] mb-1">
<span class="text-[#1f2a3a]">Accommodation</span>
<span class="text-[#5a6577] font-semibold transition-opacity duration-500 delay-[600ms]" style="opacity: @(_isLoaded ? "1" : "0")">R 720k</span>
</div>
<div class="w-full bg-[#f4f7fb] rounded-full h-1.5">
<div class="bg-[#00a3a1] h-1.5 rounded-full transition-all duration-[1500ms] ease-out delay-[100ms]" style="width: @(_isLoaded ? "30%" : "0%")"></div>
</div>
</div>
<div>
<div class="flex justify-between text-[12px] mb-1">
<span class="text-[#1f2a3a]">Car Rental</span>
<span class="text-[#5a6577] font-semibold transition-opacity duration-500 delay-[700ms]" style="opacity: @(_isLoaded ? "1" : "0")">R 290k</span>
</div>
<div class="w-full bg-[#f4f7fb] rounded-full h-1.5">
<div class="bg-[#f4a300] h-1.5 rounded-full transition-all duration-[1500ms] ease-out delay-[200ms]" style="width: @(_isLoaded ? "12%" : "0%")"></div>
</div>
</div>
<div>
<div class="flex justify-between text-[12px] mb-1">
<span class="text-[#1f2a3a]">Shuttle</span>
<span class="text-[#5a6577] font-semibold transition-opacity duration-500 delay-[800ms]" style="opacity: @(_isLoaded ? "1" : "0")">R 110k</span>
</div>
<div class="w-full bg-[#f4f7fb] rounded-full h-1.5">
<div class="bg-[#003b71] h-1.5 rounded-full transition-all duration-[1500ms] ease-out delay-[300ms]" style="width: @(_isLoaded ? "5%" : "0%")"></div>
</div>
</div>
<div>
<div class="flex justify-between text-[12px] mb-1">
<span class="text-[#1f2a3a]">Other</span>
<span class="text-[#5a6577] font-semibold transition-opacity duration-500 delay-[900ms]" style="opacity: @(_isLoaded ? "1" : "0")">R 80k</span>
</div>
<div class="w-full bg-[#f4f7fb] rounded-full h-1.5">
<div class="bg-[#c0d4e6] h-1.5 rounded-full transition-all duration-[1500ms] ease-out delay-[400ms]" style="width: @(_isLoaded ? "3%" : "0%")"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Main Grid Row 2 (Tables) -->
<div class="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-4">
<!-- Recent Bookings Table -->
<div class="bg-white rounded-[10px] shadow-[0_1px_2px_rgba(15,23,42,.06)] border border-[#e4eaf2] p-5 flex flex-col transition-opacity duration-700 delay-300" style="opacity: @(_isLoaded ? "1" : "0")">
<div class="flex justify-between items-center mb-4">
<div>
<h3 class="text-[15px] font-bold text-[#1f2a3a]">Recent Bookings</h3>
<p class="text-[12px] text-[#8a96a8] mt-0.5">Last confirmed trips</p>
</div>
<button class="px-[14px] py-2 bg-white border border-[#e4eaf2] text-[#1f2a3a] rounded-lg text-[13px] font-semibold hover:bg-[#f4f7fb] transition-colors">View all</button>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse min-w-[600px]">
<thead>
<tr>
<th class="text-[11px] text-[#8a96a8] uppercase tracking-[0.06em] font-semibold p-3 border-b border-[#e4eaf2] whitespace-nowrap">Traveller</th>
<th class="text-[11px] text-[#8a96a8] uppercase tracking-[0.06em] font-semibold p-3 border-b border-[#e4eaf2] whitespace-nowrap">Route / Stay</th>
<th class="text-[11px] text-[#8a96a8] uppercase tracking-[0.06em] font-semibold p-3 border-b border-[#e4eaf2] whitespace-nowrap">Type</th>
<th class="text-[11px] text-[#8a96a8] uppercase tracking-[0.06em] font-semibold p-3 border-b border-[#e4eaf2] whitespace-nowrap">Status</th>
<th class="text-[11px] text-[#8a96a8] uppercase tracking-[0.06em] font-semibold p-3 border-b border-[#e4eaf2] whitespace-nowrap">Cost</th>
</tr>
</thead>
<tbody>
@foreach (var item in _recentBookings)
{
<tr class="hover:bg-[#fafcfe] transition-colors">
<td class="p-3 border-b border-[#e4eaf2] text-[13px] text-[#1f2a3a]">
<div class="flex items-center gap-2.5">
<div class="w-7 h-7 rounded-full bg-gradient-to-br from-[#0066b3] to-[#00a3a1] text-white flex items-center justify-center text-[10px] font-semibold">@item.Initials</div>
<div><strong class="font-bold">@item.Traveller</strong><span class="block text-[#8a96a8] text-[11px]">@item.Role</span></div>
</div>
</td>
<td class="p-3 border-b border-[#e4eaf2] text-[13px] text-[#1f2a3a]">@item.Details</td>
<td class="p-3 border-b border-[#e4eaf2] text-[13px]">
@if (item.Type == "Flight")
{
<span class="bg-[#e2eefb] text-[#0066b3] px-2.5 py-[3px] rounded-full text-[11px] font-semibold">Flight</span>
}
else if (item.Type == "Hotel")
{
<span class="bg-[#e8f4f4] text-[#00a3a1] px-2.5 py-[3px] rounded-full text-[11px] font-semibold">Hotel</span>
}
else if (item.Type == "Car")
{
<span class="bg-[#fff4dd] text-[#b06b00] px-2.5 py-[3px] rounded-full text-[11px] font-semibold">Car</span>
}
else if (item.Type == "Shuttle")
{
<span class="bg-[#e8f4f4] text-[#00a3a1] px-2.5 py-[3px] rounded-full text-[11px] font-semibold">Shuttle</span>
}
else
{
<span class="bg-[#f4f7fb] text-[#5a6577] px-2.5 py-[3px] rounded-full text-[11px] font-semibold">@item.Type</span>
}
</td>
<td class="p-3 border-b border-[#e4eaf2] text-[13px]">
@if (item.Status == "Confirmed")
{
<span class="bg-[#e3f6ed] text-[#22a06b] px-2.5 py-[3px] rounded-full text-[11px] font-semibold">Confirmed</span>
}
else if (item.Status == "Pending")
{
<span class="bg-[#fff4dd] text-[#b06b00] px-2.5 py-[3px] rounded-full text-[11px] font-semibold">Pending</span>
}
else if (item.Status == "Cancelled")
{
<span class="bg-[#fde5e8] text-[#c8102e] px-2.5 py-[3px] rounded-full text-[11px] font-semibold">Cancelled</span>
}
else
{
<span class="bg-gray-100 text-gray-800 px-2.5 py-[3px] rounded-full text-[11px] font-semibold">@item.Status</span>
}
</td>
<td class="p-3 border-b border-[#e4eaf2] text-[13px] text-[#1f2a3a]">R @item.Cost.ToString("N0")</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<!-- Top Destinations -->
<div class="bg-white rounded-[10px] shadow-[0_1px_2px_rgba(15,23,42,.06)] border border-[#e4eaf2] p-5 flex flex-col transition-opacity duration-700 delay-300" style="opacity: @(_isLoaded ? "1" : "0")">
<div class="mb-4">
<h3 class="text-[15px] font-bold text-[#1f2a3a]">Top Destinations</h3>
<p class="text-[12px] text-[#8a96a8] mt-0.5">By spend, this month</p>
</div>
<div class="flex flex-col">
<div class="flex items-center justify-between py-2.5 border-b border-dashed border-[#e4eaf2]">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg bg-gradient-to-br from-[#00a3a1] to-[#0066b3] text-white flex items-center justify-center text-[12px] font-bold">CT</div>
<div><strong class="block text-[13px] text-[#1f2a3a]">Cape Town</strong><span class="text-[11px] text-[#8a96a8]">42 trips · ZA</span></div>
</div>
<div class="font-bold text-[#003b71] text-[13px]">R 612,400</div>
</div>
<div class="flex items-center justify-between py-2.5 border-b border-dashed border-[#e4eaf2]">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg bg-gradient-to-br from-[#00a3a1] to-[#0066b3] text-white flex items-center justify-center text-[12px] font-bold">DBN</div>
<div><strong class="block text-[13px] text-[#1f2a3a]">Durban</strong><span class="text-[11px] text-[#8a96a8]">28 trips · ZA</span></div>
</div>
<div class="font-bold text-[#003b71] text-[13px]">R 318,750</div>
</div>
<div class="flex items-center justify-between py-2.5 border-b border-dashed border-[#e4eaf2]">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg bg-gradient-to-br from-[#00a3a1] to-[#0066b3] text-white flex items-center justify-center text-[12px] font-bold">PE</div>
<div><strong class="block text-[13px] text-[#1f2a3a]">Gqeberha</strong><span class="text-[11px] text-[#8a96a8]">19 trips · ZA</span></div>
</div>
<div class="font-bold text-[#003b71] text-[13px]">R 214,300</div>
</div>
<div class="flex items-center justify-between py-2.5 border-b border-dashed border-[#e4eaf2]">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg bg-gradient-to-br from-[#00a3a1] to-[#0066b3] text-white flex items-center justify-center text-[12px] font-bold">LDN</div>
<div><strong class="block text-[13px] text-[#1f2a3a]">London</strong><span class="text-[11px] text-[#8a96a8]">4 trips · UK</span></div>
</div>
<div class="font-bold text-[#003b71] text-[13px]">R 188,900</div>
</div>
<div class="flex items-center justify-between py-2.5">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-lg bg-gradient-to-br from-[#00a3a1] to-[#0066b3] text-white flex items-center justify-center text-[12px] font-bold">NBO</div>
<div><strong class="block text-[13px] text-[#1f2a3a]">Nairobi</strong><span class="text-[11px] text-[#8a96a8]">6 trips · KE</span></div>
</div>
<div class="font-bold text-[#003b71] text-[13px]">R 142,100</div>
</div>
</div>
</div>
</div>
</div>
@* Create Booking Modal *@
@if (_showNewBookingModal)
{
<div class="fixed inset-0 bg-[#1f2a3a]/60 backdrop-blur-xs z-50 flex items-center justify-center p-4 text-left">
<div class="bg-white rounded-xl border border-[#e4eaf2] shadow-xl max-w-md w-full p-6 animate-in fade-in duration-150">
<div class="flex items-center justify-between mb-4 pb-3 border-b border-[#e4eaf2]">
<h3 class="text-base font-bold text-[#003b71] flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 text-[#0066b3]">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
New Travel Booking
</h3>
<button @onclick="@(() => _showNewBookingModal = false)" class="text-[#8a96a8] hover:text-[#1f2a3a] cursor-pointer">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div class="space-y-4 text-xs text-[#5a6577]">
<div>
<label class="block text-[10px] font-bold uppercase text-[#8a96a8] mb-1">Traveller Name</label>
<input type="text" @bind="_newTraveller" placeholder="e.g. J. Mokoena" class="w-full px-3 py-2 border border-[#c8d3e1] rounded-lg text-xs bg-white text-[#1f2a3a] outline-none focus:border-[#0066b3]" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-[10px] font-bold uppercase text-[#8a96a8] mb-1">Role / Grade</label>
<select @bind="_newRole" class="w-full px-3 py-2 border border-[#c8d3e1] rounded-lg text-xs bg-white text-[#1f2a3a] outline-none focus:border-[#0066b3]">
<option value="Executive">Executive</option>
<option value="Director">Director</option>
<option value="Manager">Manager</option>
<option value="Officer">Officer</option>
<option value="Coordinator">Coordinator</option>
</select>
</div>
<div>
<label class="block text-[10px] font-bold uppercase text-[#8a96a8] mb-1">Booking Type</label>
<select @bind="_newType" class="w-full px-3 py-2 border border-[#c8d3e1] rounded-lg text-xs bg-white text-[#1f2a3a] outline-none focus:border-[#0066b3]">
<option value="Flight">Flight</option>
<option value="Hotel">Hotel</option>
<option value="Car">Car</option>
<option value="Shuttle">Shuttle</option>
</select>
</div>
</div>
<div>
<label class="block text-[10px] font-bold uppercase text-[#8a96a8] mb-1">Route / Stay Details</label>
<input type="text" @bind="_newDetails" placeholder="e.g. JNB → CPT, 18 Jun or Premier Hotel, 3 nights" class="w-full px-3 py-2 border border-[#c8d3e1] rounded-lg text-xs bg-white text-[#1f2a3a] outline-none focus:border-[#0066b3]" />
</div>
<div>
<label class="block text-[10px] font-bold uppercase text-[#8a96a8] mb-1">Booking Cost (ZAR)</label>
<input type="number" @bind="_newCost" class="w-full px-3 py-2 border border-[#c8d3e1] rounded-lg text-xs bg-white text-[#1f2a3a] outline-none focus:border-[#0066b3]" />
</div>
<div class="p-3 bg-[#e2eefb] text-[#0066b3] border border-blue-100 rounded-lg text-[11px] leading-relaxed">
This booking will be automatically synced with the global GDS network and reconciled against the corporate Lodge Card parameters.
</div>
</div>
<div class="flex gap-2 justify-end pt-4 mt-4 border-t border-[#e4eaf2]">
<button @onclick="@(() => _showNewBookingModal = false)" class="px-4 py-2 rounded-lg text-xs font-bold text-[#1f2a3a] bg-white hover:bg-[#f4f7fb] border border-[#e4eaf2] cursor-pointer">Discard</button>
<button @onclick="@(() => CreateBooking())" class="px-5 py-2 rounded-lg text-xs font-bold text-white bg-[#0066b3] hover:bg-[#003b71] cursor-pointer shadow-xs">Generate Booking</button>
</div>
</div>
</div>
}
@code {
private bool _isLoaded = false;
// KPI Data variables to manage count-up animation
private double _kpiSpend = 0;
private double _kpiSavings = 0;
private int _kpiTravellers = 0;
private int _kpiPolicy = 0;
// Dynamic Chart Data configuration
private readonly string[] Months = { "Dec", "Jan", "Feb", "Mar", "Apr", "May" };
// HTML Data
private readonly double[] AirData = { 820, 905, 870, 1020, 1150, 1240 };
private readonly double[] AccData = { 510, 560, 590, 640, 680, 720 };
private readonly double[] GroundData = { 180, 195, 210, 240, 260, 290 };
// Calculate maximums for scaling (approx 1400 based on data)
private double MaxValue => 1400;
// Chart dimensions mapped to SVG viewBox
private double ChartWidth = 800;
private double ChartHeight = 220;
// Recent Bookings nested model & active list
public class HomeBookingItem
{
public string Traveller { get; set; } = string.Empty;
public string Role { get; set; } = string.Empty;
public string Initials { get; set; } = string.Empty;
public string Details { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty; // Flight, Hotel, Car, Shuttle
public string Status { get; set; } = string.Empty; // Confirmed, Pending, Cancelled
public decimal Cost { get; set; }
}
private List<HomeBookingItem> _recentBookings = new()
{
new() { Traveller = "J. Mokoena", Role = "Executive", Initials = "JM", Details = "JNB → CPT, 02 Jun", Type = "Flight", Status = "Confirmed", Cost = 3240M },
new() { Traveller = "N. Ndlovu", Role = "Manager", Initials = "NN", Details = "Premier Hotel OR Tambo", Type = "Hotel", Status = "Confirmed", Cost = 1895M },
new() { Traveller = "S. Pillay", Role = "Coordinator", Initials = "SP", Details = "Avis Group B, 3 days", Type = "Car", Status = "Pending", Cost = 1420M },
new() { Traveller = "T. Maluleke", Role = "Director", Initials = "TM", Details = "JNB → LHR, 14 Jun", Type = "Flight", Status = "Confirmed", Cost = 18940M },
new() { Traveller = "L. Khumalo", Role = "Officer", Initials = "LK", Details = "Shuttle: Sandton ⇄ OR Tambo", Type = "Shuttle", Status = "Cancelled", Cost = 380M }
};
// Modal creation state bindings
private bool _showNewBookingModal = false;
private string _newTraveller = "J. Mokoena";
private string _newRole = "Executive";
private string _newDetails = "JNB → CPT, 18 Jun";
private string _newType = "Flight";
private decimal _newCost = 2500M;
private void OpenBookingModal()
{
_newTraveller = "";
_newRole = "Executive";
_newDetails = "";
_newType = "Flight";
_newCost = 2500M;
_showNewBookingModal = true;
}
private string GetInitials(string name)
{
if (string.IsNullOrWhiteSpace(name)) return "??";
var parts = name.Split(new[] { ' ', '.' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0) return "??";
if (parts.Length == 1) return parts[0].Substring(0, Math.Min(2, parts[0].Length)).ToUpper();
return (parts[0][0].ToString() + parts[parts.Length - 1][0].ToString()).ToUpper();
}
private void CreateBooking()
{
if (string.IsNullOrWhiteSpace(_newTraveller)) _newTraveller = "Anonymous";
if (string.IsNullOrWhiteSpace(_newDetails)) _newDetails = "Local Trip";
var newBooking = new HomeBookingItem
{
Traveller = _newTraveller,
Role = _newRole,
Initials = GetInitials(_newTraveller),
Details = _newDetails,
Type = _newType,
Status = "Confirmed",
Cost = _newCost
};
_recentBookings.Insert(0, newBooking);
// Update total spend dynamically
_kpiSpend += (double)_newCost;
_kpiTravellers += 1;
_showNewBookingModal = false;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await Task.Delay(50);
_isLoaded = true;
StateHasChanged();
// Run the KPI Count-up animation logic
int steps = 40;
double targetSpend = 2847310;
double targetSavings = 412580;
double targetTravellers = 238;
double targetPolicy = 7;
for (int i = 1; i <= steps; i++)
{
_kpiSpend = (targetSpend / steps) * i;
_kpiSavings = (targetSavings / steps) * i;
_kpiTravellers = (int)((targetTravellers / steps) * i);
_kpiPolicy = (int)((targetPolicy / steps) * i);
StateHasChanged();
await Task.Delay(22); // Replicates the Javascript animation timing
}
// Ensure exact final values
_kpiSpend = targetSpend;
_kpiSavings = targetSavings;
_kpiTravellers = (int)targetTravellers;
_kpiPolicy = (int)targetPolicy;
StateHasChanged();
}
}
private string GetPolylinePoints(double[] data)
{
var points = new List<string>();
for (int i = 0; i < data.Length; i++)
{
var coord = GetCoordinate(i, data[i]);
points.Add($"{coord.X.ToString(System.Globalization.CultureInfo.InvariantCulture)},{coord.Y.ToString(System.Globalization.CultureInfo.InvariantCulture)}");
}
return string.Join(" ", points);
}
private string GetPolygonPoints(double[] data)
{
var points = new List<string>();
points.Add($"0,{ChartHeight.ToString(System.Globalization.CultureInfo.InvariantCulture)}");
for (int i = 0; i < data.Length; i++)
{
var coord = GetCoordinate(i, data[i]);
points.Add($"{coord.X.ToString(System.Globalization.CultureInfo.InvariantCulture)},{coord.Y.ToString(System.Globalization.CultureInfo.InvariantCulture)}");
}
points.Add($"{ChartWidth.ToString(System.Globalization.CultureInfo.InvariantCulture)},{ChartHeight.ToString(System.Globalization.CultureInfo.InvariantCulture)}");
return string.Join(" ", points);
}
private (double X, double Y) GetCoordinate(int index, double value)
{
double xStep = ChartWidth / (Months.Length - 1);
double x = index * xStep;
double y = ChartHeight - ((value / MaxValue) * ChartHeight);
// 10% padding so max values don't clip the top of the SVG box
y = y * 0.90 + (ChartHeight * 0.10);
return (x, y);
}
}