Skip to content

Commit 77872c1

Browse files
committed
feat: Add account sales payment report functionality
- Implemented AccountSalesPaymentItemResult, AccountSalesPaymentItemSnapshot, AccountSalesPaymentPaymentApplicationResult, AccountSalesPaymentPaymentApplicationSnapshot, AccountSalesPaymentPaymentDetailResult, AccountSalesPaymentPaymentSnapshot, AccountSalesPaymentPeriodResult, AccountSalesPaymentSummaryResult classes for report data structure. - Developed GetAccountSalesPaymentReportHandler to handle report generation logic, including fetching invoices and payments, merging local and SAP data, and building report results. - Created GetAccountSalesPaymentReportQuery and GetAccountSalesPaymentReportResult records for request and response handling. - Added validation for report query parameters using FluentValidation.
1 parent f49f579 commit 77872c1

46 files changed

Lines changed: 4370 additions & 170 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

ShopInventory.Web/Common/Errors/Errors.Report.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ public static partial class Errors
66
{
77
public static class Report
88
{
9+
public static Error LoadAccountSalesPaymentsFailed(string message) =>
10+
Error.Failure("Report.LoadAccountSalesPaymentsFailed", message);
11+
912
public static Error LoadMerchandiserPurchaseOrdersFailed(string message) =>
1013
Error.Failure("Report.LoadMerchandiserPurchaseOrdersFailed", message);
1114

ShopInventory.Web/Components/Layout/NavMenu.razor

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,16 @@
584584
</NavLink>
585585
</Authorized>
586586
</AuthorizeView>
587+
<AuthorizeView Roles="Admin,Cashier,StockController,DepotController,Manager" Context="accountSalesPaymentsCtx">
588+
<Authorized>
589+
<NavLink class="snav-link sub-link" href="/reports/account-sales-payments" Match="NavLinkMatch.Prefix">
590+
<span class="snav-icon">
591+
<MudIcon Icon="@Icons.Material.Filled.Payments" />
592+
</span>
593+
<span class="snav-text">Account Sales & Payments</span>
594+
</NavLink>
595+
</Authorized>
596+
</AuthorizeView>
587597

588598
<AuthorizeView Roles="Admin,Manager,Merchandiser" Context="timesheetsAdminCtx">
589599
<Authorized>
@@ -681,7 +691,7 @@
681691
</AuthorizeView>
682692
</Authorized>
683693
</AuthorizeView>
684-
<AuthorizeView Roles="Admin,Manager,Merchandiser,PodOperator,Driver" Context="crateCtx">
694+
<AuthorizeView Roles="Admin,Manager,Merchandiser,PodOperator,Driver,SalesRep" Context="crateCtx">
685695
<Authorized>
686696
<div class="nav-section-label">Crates</div>
687697
<NavLink class="snav-link sub-link" href="/crates/pods" Match="NavLinkMatch.All">

ShopInventory.Web/Components/Pages/AccountSalesPaymentReport.razor

Lines changed: 330 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
using MediatR;
2+
using Microsoft.AspNetCore.Components;
3+
using Microsoft.JSInterop;
4+
using ShopInventory.Web.Data;
5+
using ShopInventory.Web.Features.Reports.Queries.GetAccountSalesPaymentReport;
6+
using ShopInventory.Web.Services;
7+
8+
namespace ShopInventory.Web.Components.Pages;
9+
10+
public partial class AccountSalesPaymentReport : IDisposable
11+
{
12+
private const string DefaultAccountCodesText = "CIS006, COR006, COR007, MAC006, MAC009, COR008, COR011, VAN008-019";
13+
14+
[Inject] private IMediator Mediator { get; set; } = default!;
15+
[Inject] private IAuditService AuditService { get; set; } = default!;
16+
[Inject] private IJSRuntime JS { get; set; } = default!;
17+
[Inject] private IReportExportService ExportService { get; set; } = default!;
18+
[Inject] private ILogger<AccountSalesPaymentReport> Logger { get; set; } = default!;
19+
20+
private GetAccountSalesPaymentReportResult reportResult = new();
21+
private CancellationTokenSource loadCts = new();
22+
private bool isDisposed;
23+
private bool hasLoggedView;
24+
private bool hasRequestedReport;
25+
private bool isExporting;
26+
private bool isLoading;
27+
private string? errorMessage;
28+
private DateTime? fromDate = DateTime.UtcNow.Date.AddDays(-30);
29+
private DateTime? toDate = DateTime.UtcNow.Date;
30+
private AccountSalesPaymentGrouping selectedGrouping = AccountSalesPaymentGrouping.Daily;
31+
private string accountCodesText = DefaultAccountCodesText;
32+
33+
private List<AccountSalesPaymentPeriodResult> VisiblePeriods => reportResult.Periods
34+
.Where(period => period.InvoiceCount > 0 || period.PaymentCount > 0 || period.TotalQuantitySold > 0)
35+
.OrderByDescending(period => period.PeriodStartUtc)
36+
.ToList();
37+
38+
private IEnumerable<string> DisplayedRequestedAccounts => reportResult.RequestedAccountCodes.Any()
39+
? reportResult.RequestedAccountCodes
40+
: SplitAccountCodes(accountCodesText);
41+
42+
private string SelectedGroupingLabel => selectedGrouping switch
43+
{
44+
AccountSalesPaymentGrouping.Daily => "Daily",
45+
AccountSalesPaymentGrouping.Weekly => "Weekly",
46+
AccountSalesPaymentGrouping.Monthly => "Monthly",
47+
_ => "Daily"
48+
};
49+
50+
private string GroupingPeriodNoun => selectedGrouping switch
51+
{
52+
AccountSalesPaymentGrouping.Daily => "days",
53+
AccountSalesPaymentGrouping.Weekly => "weeks",
54+
AccountSalesPaymentGrouping.Monthly => "months",
55+
_ => "days"
56+
};
57+
58+
private string GroupingPeriodSingular => selectedGrouping switch
59+
{
60+
AccountSalesPaymentGrouping.Daily => "day",
61+
AccountSalesPaymentGrouping.Weekly => "week",
62+
AccountSalesPaymentGrouping.Monthly => "month",
63+
_ => "day"
64+
};
65+
66+
private string PrimaryActionLabel => hasRequestedReport ? "Refresh report" : "Load report";
67+
68+
private bool CanExportReport => hasRequestedReport && reportResult.GeneratedAtUtc != default;
69+
70+
private async Task LoadReportAsync()
71+
{
72+
var cancellationToken = BeginLoad();
73+
hasRequestedReport = true;
74+
isLoading = true;
75+
errorMessage = null;
76+
77+
try
78+
{
79+
var result = await Mediator.Send(
80+
new GetAccountSalesPaymentReportQuery(
81+
fromDate,
82+
toDate,
83+
selectedGrouping,
84+
accountCodesText),
85+
cancellationToken);
86+
87+
if (cancellationToken.IsCancellationRequested || isDisposed)
88+
{
89+
return;
90+
}
91+
92+
result.SwitchFirst(
93+
value => reportResult = value,
94+
error =>
95+
{
96+
reportResult = new GetAccountSalesPaymentReportResult();
97+
errorMessage = error.Description;
98+
});
99+
100+
if (!result.IsError && !hasLoggedView)
101+
{
102+
hasLoggedView = true;
103+
await AuditService.LogAsync(AuditActions.ViewReports, "Report", nameof(AccountSalesPaymentReport));
104+
}
105+
}
106+
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
107+
{
108+
}
109+
catch (Exception ex)
110+
{
111+
Logger.LogError(ex, "Failed to load account sales and incoming payment report page");
112+
reportResult = new GetAccountSalesPaymentReportResult();
113+
errorMessage = "Failed to load the account sales and incoming payment report.";
114+
}
115+
finally
116+
{
117+
if (!isDisposed)
118+
{
119+
isLoading = false;
120+
await InvokeAsync(StateHasChanged);
121+
}
122+
}
123+
}
124+
125+
private async Task ApplyFiltersAsync() => await LoadReportAsync();
126+
127+
private async Task ResetFiltersAsync()
128+
{
129+
fromDate = DateTime.UtcNow.Date.AddDays(-30);
130+
toDate = DateTime.UtcNow.Date;
131+
selectedGrouping = AccountSalesPaymentGrouping.Daily;
132+
accountCodesText = DefaultAccountCodesText;
133+
await LoadReportAsync();
134+
}
135+
136+
private async Task SetGroupingAsync(AccountSalesPaymentGrouping grouping)
137+
{
138+
if (selectedGrouping == grouping)
139+
{
140+
return;
141+
}
142+
143+
selectedGrouping = grouping;
144+
await LoadReportAsync();
145+
}
146+
147+
private async Task ExportToExcelAsync()
148+
{
149+
if (!CanExportReport)
150+
{
151+
return;
152+
}
153+
154+
isExporting = true;
155+
errorMessage = null;
156+
157+
try
158+
{
159+
var bytes = ExportService.ExportAccountSalesPaymentReportToExcel(reportResult);
160+
var base64 = Convert.ToBase64String(bytes);
161+
await JS.InvokeVoidAsync(
162+
"downloadFile",
163+
$"Account_Sales_And_Payments_{IAuditService.ToCAT(DateTime.UtcNow):yyyyMMdd_HHmm}.xlsx",
164+
base64);
165+
}
166+
catch (Exception ex)
167+
{
168+
Logger.LogError(ex, "Failed to export account sales and incoming payment report to Excel");
169+
errorMessage = "Failed to export the account sales and incoming payment report to Excel.";
170+
}
171+
finally
172+
{
173+
isExporting = false;
174+
}
175+
}
176+
177+
private string GetGroupingButtonClass(AccountSalesPaymentGrouping grouping) =>
178+
selectedGrouping == grouping
179+
? "aspr-grouping-button aspr-grouping-button--active"
180+
: "aspr-grouping-button";
181+
182+
private string FormatGeneratedAt() =>
183+
reportResult.GeneratedAtUtc == default
184+
? string.Empty
185+
: IAuditService.ToCAT(reportResult.GeneratedAtUtc).ToString("dd MMM yyyy HH:mm 'CAT'");
186+
187+
private static string FormatAmounts(decimal usd, decimal zig)
188+
{
189+
var parts = new List<string>();
190+
if (usd != 0 || zig == 0)
191+
{
192+
parts.Add($"USD {usd:N2}");
193+
}
194+
195+
if (zig != 0 || usd == 0)
196+
{
197+
parts.Add($"ZiG {zig:N2}");
198+
}
199+
200+
return string.Join(" • ", parts);
201+
}
202+
203+
private static string FormatRates(decimal usdRate, decimal zigRate)
204+
{
205+
var parts = new List<string>();
206+
if (usdRate != 0 || zigRate == 0)
207+
{
208+
parts.Add($"USD {usdRate:N2}%");
209+
}
210+
211+
if (zigRate != 0 || usdRate == 0)
212+
{
213+
parts.Add($"ZiG {zigRate:N2}%");
214+
}
215+
216+
return string.Join(" • ", parts);
217+
}
218+
219+
private static IEnumerable<AccountSalesPaymentAccountResult> GetOrderedAccounts(
220+
IEnumerable<AccountSalesPaymentAccountResult> accounts,
221+
bool includeZeroActivity = false)
222+
{
223+
var filteredAccounts = includeZeroActivity
224+
? accounts
225+
: accounts.Where(account => account.InvoiceCount > 0 || account.PaymentCount > 0 || account.TotalQuantitySold > 0);
226+
227+
return filteredAccounts
228+
.OrderByDescending(account => account.TotalSalesUsd + account.TotalSalesZig + account.IncomingPaymentsUsd + account.IncomingPaymentsZig)
229+
.ThenBy(account => account.CardCode, StringComparer.OrdinalIgnoreCase);
230+
}
231+
232+
private static List<string> SplitAccountCodes(string value) =>
233+
value
234+
.Split(new[] { ',', ';', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
235+
.Where(accountCode => !string.IsNullOrWhiteSpace(accountCode))
236+
.Distinct(StringComparer.OrdinalIgnoreCase)
237+
.ToList();
238+
239+
private CancellationToken BeginLoad()
240+
{
241+
loadCts.Cancel();
242+
loadCts.Dispose();
243+
loadCts = new CancellationTokenSource();
244+
return loadCts.Token;
245+
}
246+
247+
public void Dispose()
248+
{
249+
isDisposed = true;
250+
loadCts.Cancel();
251+
loadCts.Dispose();
252+
}
253+
}

0 commit comments

Comments
 (0)