Skip to content

Commit 5bbf473

Browse files
Merge pull request #828 from TransactionProcessing/copilot/add-merchant-opening-hours-screen
Add merchant opening-hours maintenance to the merchant view and edit flows
2 parents 2294f9d + 5c571b1 commit 5bbf473

21 files changed

Lines changed: 725 additions & 26 deletions

File tree

EstateManagementUI.BlazorServer.Tests/EstateManagementUI.BlazorServer.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
</PackageReference>
2424
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
2525
<PackageReference Include="Moq" Version="4.20.72" />
26+
<PackageReference Include="Shared" Version="2026.3.1" />
2627
<PackageReference Include="Shouldly" Version="4.3.0" />
2728
<PackageReference Include="xunit" Version="2.9.3" />
2829
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4">

EstateManagementUI.BlazorServer.Tests/Pages/Merchants/MerchantsEditPageTests.cs

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,26 @@ public void MerchantsEdit_TabSwitch_ToContact_UpdatesActiveTab()
121121
cut.WaitForAssertion(() => cut.Markup.ShouldContain("Contact Name"), timeout: TimeSpan.FromSeconds(5));
122122
}
123123

124+
[Fact]
125+
public void MerchantsEdit_TabSwitch_ToOpeningHours_UpdatesActiveTab()
126+
{
127+
var merchantId = Guid.NewGuid();
128+
SetupSuccessfulDataLoad(merchantId);
129+
IRenderedComponent<MerchantsEdit> cut = RenderComponent<MerchantsEdit>(parameters => parameters
130+
.Add(p => p.MerchantId, merchantId));
131+
cut.WaitForState(() => !cut.Markup.Contains("animate-spin"), TimeSpan.FromSeconds(5));
132+
133+
IRefreshableElementCollection<IElement> buttons = cut.FindAll("button");
134+
IElement? openingHoursButton = buttons.FirstOrDefault(b => b.TextContent.Contains("Opening Hours"));
135+
openingHoursButton?.Click();
136+
137+
cut.WaitForAssertion(() => cut.Markup.ShouldContain("Save Opening Hours"), timeout: TimeSpan.FromSeconds(5));
138+
cut.Markup.IndexOf("Monday", StringComparison.Ordinal).ShouldBeLessThan(cut.Markup.IndexOf("Sunday", StringComparison.Ordinal));
139+
cut.Markup.IndexOf("Saturday", StringComparison.Ordinal).ShouldBeLessThan(cut.Markup.IndexOf("Sunday", StringComparison.Ordinal));
140+
cut.Markup.ShouldContain("Sunday");
141+
cut.Markup.ShouldContain("Saturday");
142+
}
143+
124144
[Fact]
125145
public void MerchantsEdit_TabSwitch_ToOperators_UpdatesActiveTab()
126146
{
@@ -870,6 +890,96 @@ public void MerchantsEdit_SaveAllChanges_Failure_ShowsErrorMessage()
870890
this.MerchantUIService.Verify(m => m.UpdateMerchant(It.IsAny<CorrelationId>(), It.IsAny<Guid>(), merchantId, It.IsAny<MerchantModels.MerchantEditModel>()), Times.Once);
871891
}
872892

893+
[Fact]
894+
public void MerchantsEdit_SaveOpeningHours_Success_ShowsSuccessMessageAndCallsService()
895+
{
896+
var merchantId = Guid.NewGuid();
897+
SetupSuccessfulDataLoad(merchantId);
898+
899+
this.MerchantUIService.Setup(m => m.UpdateMerchantOpeningHours(It.IsAny<CorrelationId>(), It.IsAny<Guid>(), merchantId, It.IsAny<MerchantModels.MerchantOpeningHoursModel>()))
900+
.ReturnsAsync(Result.Success);
901+
902+
IRenderedComponent<MerchantsEdit> cut = RenderComponent<MerchantsEdit>(parameters => parameters
903+
.Add(p => p.MerchantId, merchantId));
904+
cut.WaitForState(() => !cut.Markup.Contains("animate-spin"), TimeSpan.FromSeconds(5));
905+
906+
IElement? openingHoursButton = cut.FindAll("button").FirstOrDefault(b => b.TextContent.Contains("Opening Hours"));
907+
openingHoursButton?.Click();
908+
909+
cut.Find("#mondayOpening").Change("0800");
910+
cut.Find("#mondayClosing").Change("1700");
911+
cut.Find("#tuesdayOpening").Change("0800");
912+
cut.Find("#tuesdayClosing").Change("1700");
913+
cut.Find("#wednesdayOpening").Change("0800");
914+
cut.Find("#wednesdayClosing").Change("1700");
915+
cut.Find("#thursdayOpening").Change("0800");
916+
cut.Find("#thursdayClosing").Change("1700");
917+
cut.Find("#fridayOpening").Change("0800");
918+
cut.Find("#fridayClosing").Change("1700");
919+
cut.Find("#saturdayOpening").Change("0900");
920+
cut.Find("#saturdayClosing").Change("1600");
921+
cut.Find("#sundayOpening").Change("8:00");
922+
cut.Find("#sundayClosing").Change("1800");
923+
924+
cut.Find("#saveOpeningHoursButton").Click();
925+
926+
cut.WaitForAssertion(() => cut.Markup.ShouldContain("Merchant opening hours updated successfully"), timeout: TimeSpan.FromSeconds(10));
927+
this.MerchantUIService.Verify(m => m.UpdateMerchantOpeningHours(
928+
It.IsAny<CorrelationId>(),
929+
It.IsAny<Guid>(),
930+
merchantId,
931+
It.Is<MerchantModels.MerchantOpeningHoursModel>(hours =>
932+
hours.Sunday.Opening == "0800" &&
933+
hours.Sunday.Closing == "1800" &&
934+
hours.Saturday.Opening == "0900" &&
935+
hours.Saturday.Closing == "1600")),
936+
Times.Once);
937+
}
938+
939+
[Fact]
940+
public void MerchantsEdit_SaveOpeningHours_InvalidClosingTimeAbove2359_ShowsErrorAndDoesNotCallService()
941+
{
942+
var merchantId = Guid.NewGuid();
943+
SetupSuccessfulDataLoad(merchantId);
944+
945+
IRenderedComponent<MerchantsEdit> cut = RenderComponent<MerchantsEdit>(parameters => parameters
946+
.Add(p => p.MerchantId, merchantId));
947+
cut.WaitForState(() => !cut.Markup.Contains("animate-spin"), TimeSpan.FromSeconds(5));
948+
949+
IElement? openingHoursButton = cut.FindAll("button").FirstOrDefault(b => b.TextContent.Contains("Opening Hours"));
950+
openingHoursButton?.Click();
951+
952+
PopulateValidOpeningHours(cut);
953+
cut.Find("#sundayClosing").Change("2400");
954+
955+
cut.Find("#saveOpeningHoursButton").Click();
956+
957+
cut.WaitForAssertion(() => cut.Markup.ShouldContain("Sunday closing time must be entered in HHmm format."), timeout: TimeSpan.FromSeconds(10));
958+
this.MerchantUIService.Verify(m => m.UpdateMerchantOpeningHours(It.IsAny<CorrelationId>(), It.IsAny<Guid>(), merchantId, It.IsAny<MerchantModels.MerchantOpeningHoursModel>()), Times.Never);
959+
}
960+
961+
[Fact]
962+
public void MerchantsEdit_SaveOpeningHours_InvalidOpeningTimeBelow0000_ShowsErrorAndDoesNotCallService()
963+
{
964+
var merchantId = Guid.NewGuid();
965+
SetupSuccessfulDataLoad(merchantId);
966+
967+
IRenderedComponent<MerchantsEdit> cut = RenderComponent<MerchantsEdit>(parameters => parameters
968+
.Add(p => p.MerchantId, merchantId));
969+
cut.WaitForState(() => !cut.Markup.Contains("animate-spin"), TimeSpan.FromSeconds(5));
970+
971+
IElement? openingHoursButton = cut.FindAll("button").FirstOrDefault(b => b.TextContent.Contains("Opening Hours"));
972+
openingHoursButton?.Click();
973+
974+
PopulateValidOpeningHours(cut);
975+
cut.Find("#mondayOpening").Change("-100");
976+
977+
cut.Find("#saveOpeningHoursButton").Click();
978+
979+
cut.WaitForAssertion(() => cut.Markup.ShouldContain("Monday opening time must be entered in HHmm format."), timeout: TimeSpan.FromSeconds(10));
980+
this.MerchantUIService.Verify(m => m.UpdateMerchantOpeningHours(It.IsAny<CorrelationId>(), It.IsAny<Guid>(), merchantId, It.IsAny<MerchantModels.MerchantOpeningHoursModel>()), Times.Never);
981+
}
982+
873983
[Fact]
874984
public void MerchantsEdit_SwapDeviceConfirm_Success_ShowsSuccessMessage()
875985
{
@@ -1122,4 +1232,22 @@ private void SetupSuccessfulDataLoadWithContracts(Guid merchantId, List<Contract
11221232

11231233
private void SetupSuccessfulDataLoadWithAssignedContracts(Guid merchantId, List<MerchantModels.MerchantContractModel> assignedContracts)
11241234
=> SetupSuccessfulDataLoad(merchantId, assignedContracts: assignedContracts);
1235+
1236+
private static void PopulateValidOpeningHours(IRenderedComponent<MerchantsEdit> cut)
1237+
{
1238+
cut.Find("#mondayOpening").Change("0800");
1239+
cut.Find("#mondayClosing").Change("1700");
1240+
cut.Find("#tuesdayOpening").Change("0800");
1241+
cut.Find("#tuesdayClosing").Change("1700");
1242+
cut.Find("#wednesdayOpening").Change("0800");
1243+
cut.Find("#wednesdayClosing").Change("1700");
1244+
cut.Find("#thursdayOpening").Change("0800");
1245+
cut.Find("#thursdayClosing").Change("1700");
1246+
cut.Find("#fridayOpening").Change("0800");
1247+
cut.Find("#fridayClosing").Change("1700");
1248+
cut.Find("#saturdayOpening").Change("0900");
1249+
cut.Find("#saturdayClosing").Change("1600");
1250+
cut.Find("#sundayOpening").Change("0800");
1251+
cut.Find("#sundayClosing").Change("1800");
1252+
}
11251253
}

EstateManagementUI.BlazorServer.Tests/Pages/Merchants/MerchantsViewPageTests.cs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ public void MerchantsView_DisplaysContracts_WhenPresent()
304304
{
305305
ProductId = Guid.NewGuid(),
306306
ProductName = "Test Product",
307-
DisplayText = "£10 Topup"
307+
DisplayText = "10 Topup"
308308
}
309309
}
310310
}
@@ -503,6 +503,46 @@ public void MerchantsView_DisplaysContactDetails_WhenPresent()
503503
}, timeout: TimeSpan.FromSeconds(5));
504504
}
505505

506+
[Fact]
507+
public void MerchantsView_DisplaysOpeningHours_WhenPresent()
508+
{
509+
var merchant = new MerchantModels.MerchantModel
510+
{
511+
MerchantId = Guid.NewGuid(),
512+
MerchantName = "Test Merchant",
513+
MerchantReference = "REF001",
514+
OpeningHours = new MerchantModels.MerchantOpeningHoursModel
515+
{
516+
Monday = new MerchantModels.DayOpeningHoursModel { Opening = "0800", Closing = "1700" },
517+
Tuesday = new MerchantModels.DayOpeningHoursModel { Opening = "0800", Closing = "1700" },
518+
Wednesday = new MerchantModels.DayOpeningHoursModel { Opening = "0800", Closing = "1700" },
519+
Thursday = new MerchantModels.DayOpeningHoursModel { Opening = "0800", Closing = "1700" },
520+
Friday = new MerchantModels.DayOpeningHoursModel { Opening = "0800", Closing = "1700" },
521+
Saturday = new MerchantModels.DayOpeningHoursModel { Opening = "0900", Closing = "1600" },
522+
Sunday = new MerchantModels.DayOpeningHoursModel { Opening = "1000", Closing = "1500" }
523+
}
524+
};
525+
526+
SetupSuccessfulDataLoadWithMerchant(merchant);
527+
528+
var cut = RenderComponent<View>(parameters => parameters
529+
.Add(p => p.MerchantId, merchant.MerchantId));
530+
cut.WaitForState(() => !cut.Markup.Contains("animate-spin"), TimeSpan.FromSeconds(5));
531+
532+
IRefreshableElementCollection<IElement> buttons = cut.FindAll("button");
533+
IElement? openingHoursButton = buttons.FirstOrDefault(b => b.TextContent.Contains("Opening Hours"));
534+
openingHoursButton?.Click();
535+
536+
cut.WaitForAssertion(() => {
537+
cut.Markup.ShouldContain("Monday");
538+
cut.Markup.ShouldContain("Opening: 0800");
539+
cut.Markup.ShouldContain("Closing: 1700");
540+
cut.Markup.ShouldContain("Sunday");
541+
cut.Markup.ShouldContain("Opening: 1000");
542+
cut.Markup.ShouldContain("Closing: 1500");
543+
}, timeout: TimeSpan.FromSeconds(5));
544+
}
545+
506546
[Fact]
507547
public void MerchantsView_BackButton_NavigatesToMerchantsList()
508548
{

EstateManagementUI.BlazorServer.Tests/RequestHandlers/MerchantRequestHandlerTests.cs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,52 @@ public async Task Handle_MakeMerchantDepositCommand_ReturnsFailure_WhenApiClient
466466

467467
#endregion
468468

469+
#region UpdateMerchantOpeningHoursCommand
470+
471+
[Fact]
472+
public async Task Handle_UpdateMerchantOpeningHoursCommand_ReturnsSuccess_WhenApiClientSucceeds()
473+
{
474+
var command = new MerchantCommands.UpdateMerchantOpeningHoursCommand(
475+
CorrelationIdHelper.New(),
476+
Guid.NewGuid(),
477+
Guid.NewGuid(),
478+
new(new MerchantCommands.OpeningHours("0800","1700"), new MerchantCommands.OpeningHours("0800", "1700"),
479+
new MerchantCommands.OpeningHours("0800", "1700"), new MerchantCommands.OpeningHours("0800", "1700"),
480+
new MerchantCommands.OpeningHours("0800", "1700"), new MerchantCommands.OpeningHours("0800", "1700"),
481+
new MerchantCommands.OpeningHours("0800", "1700")));
482+
483+
_mockApiClient.Setup(c => c.UpdateMerchantOpeningHours(command, It.IsAny<CancellationToken>()))
484+
.ReturnsAsync(Result.Success());
485+
486+
var result = await _handler.Handle(command, CancellationToken.None);
487+
488+
result.IsSuccess.ShouldBeTrue();
489+
_mockApiClient.Verify(c => c.UpdateMerchantOpeningHours(command, It.IsAny<CancellationToken>()), Times.Once);
490+
}
491+
492+
[Fact]
493+
public async Task Handle_UpdateMerchantOpeningHoursCommand_ReturnsFailure_WhenApiClientFails()
494+
{
495+
var command = new MerchantCommands.UpdateMerchantOpeningHoursCommand(
496+
CorrelationIdHelper.New(),
497+
Guid.NewGuid(),
498+
Guid.NewGuid(),
499+
new(new MerchantCommands.OpeningHours("0800", "1700"), new MerchantCommands.OpeningHours("0800", "1700"),
500+
new MerchantCommands.OpeningHours("0800", "1700"), new MerchantCommands.OpeningHours("0800", "1700"),
501+
new MerchantCommands.OpeningHours("0800", "1700"), new MerchantCommands.OpeningHours("0800", "1700"),
502+
new MerchantCommands.OpeningHours("0800", "1700")));
503+
504+
_mockApiClient.Setup(c => c.UpdateMerchantOpeningHours(command, It.IsAny<CancellationToken>()))
505+
.ReturnsAsync(Result.Failure("api error"));
506+
507+
var result = await _handler.Handle(command, CancellationToken.None);
508+
509+
result.IsFailed.ShouldBeTrue();
510+
_mockApiClient.Verify(c => c.UpdateMerchantOpeningHours(command, It.IsAny<CancellationToken>()), Times.Once);
511+
}
512+
513+
#endregion
514+
469515
#region RemoveContractFromMerchantCommand
470516

471517
[Fact]

EstateManagementUI.BlazorServer.Tests/UIServices/MerchantUIServiceTests.cs

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,51 @@ public async Task UpdateMerchant_ReturnsFailure_WhenMediatorFails()
320320
result.IsFailed.ShouldBeTrue();
321321
}
322322

323+
[Fact]
324+
public async Task UpdateMerchantOpeningHours_SendsUpdateCommand_AndReturnsSuccess()
325+
{
326+
var estateId = Guid.NewGuid();
327+
var merchantId = Guid.NewGuid();
328+
var openingHours = new BlazorServer.Models.MerchantModels.MerchantOpeningHoursModel
329+
{
330+
Sunday = new() { Opening = "0800", Closing = "1800" },
331+
Monday = new() { Opening = "0800", Closing = "1700" },
332+
Tuesday = new() { Opening = "0800", Closing = "1700" },
333+
Wednesday = new() { Opening = "0800", Closing = "1700" },
334+
Thursday = new() { Opening = "0800", Closing = "1700" },
335+
Friday = new() { Opening = "0800", Closing = "1700" },
336+
Saturday = new() { Opening = "0900", Closing = "1600" }
337+
};
338+
339+
_mockMediator
340+
.Setup(m => m.Send(It.IsAny<MerchantCommands.UpdateMerchantOpeningHoursCommand>(), It.IsAny<CancellationToken>()))
341+
.ReturnsAsync(Result.Success);
342+
343+
var result = await _service.UpdateMerchantOpeningHours(CorrelationIdHelper.New(), estateId, merchantId, openingHours);
344+
345+
result.IsSuccess.ShouldBeTrue();
346+
_mockMediator.Verify(m => m.Send(It.Is<MerchantCommands.UpdateMerchantOpeningHoursCommand>(c =>
347+
c.EstateId == estateId &&
348+
c.MerchantId == merchantId &&
349+
c.OpeningHours.Sunday.Opening == "0800" &&
350+
c.OpeningHours.Sunday.Closing == "1800" &&
351+
c.OpeningHours.Saturday.Opening == "0900" &&
352+
c.OpeningHours.Saturday.Closing == "1600"
353+
), It.IsAny<CancellationToken>()), Times.Once);
354+
}
355+
356+
[Fact]
357+
public async Task UpdateMerchantOpeningHours_ReturnsFailure_WhenMediatorFails()
358+
{
359+
_mockMediator
360+
.Setup(m => m.Send(It.IsAny<MerchantCommands.UpdateMerchantOpeningHoursCommand>(), It.IsAny<CancellationToken>()))
361+
.ReturnsAsync(Result.Failure);
362+
363+
var result = await _service.UpdateMerchantOpeningHours(CorrelationIdHelper.New(), Guid.NewGuid(), Guid.NewGuid(), new BlazorServer.Models.MerchantModels.MerchantOpeningHoursModel());
364+
365+
result.IsFailed.ShouldBeTrue();
366+
}
367+
323368
[Fact]
324369
public async Task AddAndRemoveOperatorToMerchant_SendCorrectCommands()
325370
{
@@ -628,4 +673,4 @@ public async Task MakeMerchantDeposit_ReturnsFailure_WhenMediatorFails()
628673
_mockMediator.Verify(m => m.Send(It.IsAny<MerchantCommands.MakeMerchantDepositCommand>(), It.IsAny<CancellationToken>()), Times.Once);
629674
}
630675
}
631-
}
676+
}

EstateManagementUI.BlazorServer/Components/Pages/Merchants/Edit.razor

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@
6666
<button type="button" @onclick='() => SetActiveTab("contact")' class="@GetTabClass("contact")">
6767
Contact Details
6868
</button>
69+
<button type="button" @onclick='() => SetActiveTab("openingHours")' class="@GetTabClass("openingHours")">
70+
Opening Hours
71+
</button>
6972
<button type="button" @onclick='() => SetActiveTab("operators")' class="@GetTabClass("operators")">
7073
Assigned Operators
7174
</button>
@@ -178,6 +181,32 @@
178181
</div>
179182
</div>
180183
}
184+
else if (activeTab == "openingHours") {
185+
<div class="space-y-4">
186+
<div class="rounded-lg bg-blue-50 border border-blue-100 p-4">
187+
<p class="text-sm text-blue-800">Enter merchant opening and closing times in HHmm format, for example 0800 and 1700.</p>
188+
</div>
189+
190+
<div class="space-y-3">
191+
@foreach (var day in GetOpeningHoursRows()) {
192+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-end rounded-lg border border-gray-200 p-4">
193+
<div>
194+
<label class="block text-sm font-medium text-gray-700 mb-1">@day.DayName</label>
195+
<p class="text-sm text-gray-500">Opening and closing times</p>
196+
</div>
197+
<div>
198+
<label class="block text-sm font-medium text-gray-700 mb-1">Opening</label>
199+
<InputText id="@($"{day.DayName.ToLowerInvariant()}Opening")" @bind-Value="day.Hours.Opening" class="input w-full" maxlength="4" placeholder="0800" />
200+
</div>
201+
<div>
202+
<label class="block text-sm font-medium text-gray-700 mb-1">Closing</label>
203+
<InputText id="@($"{day.DayName.ToLowerInvariant()}Closing")" @bind-Value="day.Hours.Closing" class="input w-full" maxlength="4" placeholder="1700" />
204+
</div>
205+
</div>
206+
}
207+
</div>
208+
</div>
209+
}
181210
else if (activeTab == "operators") {
182211
<div class="space-y-4">
183212
<div class="flex justify-between items-center mb-4">
@@ -393,6 +422,21 @@
393422
</div>
394423
</div>
395424
}
425+
else if (activeTab == "openingHours") {
426+
<div class="px-6 pb-6 pt-0 border-t">
427+
<div class="flex justify-end pt-4">
428+
<button id="saveOpeningHoursButton" type="button" class="btn btn-primary" disabled="@isSaving" @onclick="SaveOpeningHours">
429+
@if (isSaving) {
430+
<span class="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></span>
431+
<span>Saving...</span>
432+
}
433+
else {
434+
<span>Save Opening Hours</span>
435+
}
436+
</button>
437+
</div>
438+
</div>
439+
}
396440
</div>
397441
</EditForm>
398442
}
@@ -402,4 +446,4 @@
402446
<button class="btn btn-primary mt-4" @onclick="BackToList">Back to List</button>
403447
</div>
404448
}
405-
</div>
449+
</div>

0 commit comments

Comments
 (0)