RetailDash
Multi-location retail analytics and reporting platform
Overview
A comprehensive retail analytics and reporting platform for multi-location businesses. Generates various reports including sales figures, stock levels, newsletter performance, and inventory analysis across multiple store locations.
- Sales performance reports with weekly and monthly views
- Low stock alerts with configurable thresholds
- Newsletter effectiveness tracking via Google Analytics integration
- Stock location tracking across multiple retail locations
- Excel export with professional formatting using ClosedXML
- Automated email report delivery with MailKit
- SQLite local database for offline data storage
Key Features
Entity Framework with Complex Queries
Uses EF Core with eager loading to efficiently fetch products across multiple related tables, including brands, variants, stock levels, deliveries, and sales data with date filtering.
public static async Task<List<ProductDataOutModel>> QueryData(bool isMonthSales = false)
{
await using var dbContext = new AppDbContext();
var dateRange = GetDateRange(isMonthSales);
var products = await dbContext.Products
.AsNoTracking()
.Where(p => !p.Brand.Name.ToLower().Contains(excludedBrand))
.Include(product => product.Brand)
.Include(product => product.Variants!)
.ThenInclude(variant => variant.Stocks!
.Where(s => s.LocationName == "Main Street" || s.LocationName == "Town Centre"))
.Include(product => product.Variants!)
.ThenInclude(variant => variant.Deliveries!)
.Include(product => product.Variants!)
.ThenInclude(variant => variant.Sales!
.Where(s => s.Date >= dateRange.Start && s.Date <= dateRange.End))
.Where(p => p.Variants!.Any(v => v.Sales!.Any()))
.ToListAsync();
return products
.SelectMany(p => ProcessProductVariants(p, dateRange))
.OrderByDescending(p => p.ProductVariants!
.Sum(v => v.VariantSales!.Sum(s => s.SaleQuantity)))
.ToList();
}
Google Analytics 4 Integration
Integrates with Google Analytics Data API to track newsletter campaign performance, including purchases, revenue, and item-level attribution data.
public async static Task<List<NewsletterDataModel>> QueryData()
{
string credentialPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "service-account.json");
Environment.SetEnvironmentVariable("GOOGLE_APPLICATION_CREDENTIALS", credentialPath);
var client = await BetaAnalyticsDataClient.CreateAsync();
RunReportRequest request = new()
{
Property = "properties/" + _propertyId,
Dimensions = { new Dimension { Name = "sessionSourceMedium" }, },
Metrics = { new Metric { Name = "totalPurchasers" }, new Metric { Name = "totalRevenue" }, },
DateRanges = { new DateRange { StartDate = "2023-08-01", EndDate = "today" }, },
};
var response = await client.RunReportAsync(request);
foreach (Row row in response.Rows)
{
var ga4Data = new Ga4DataModel
{
SourceMedium = ParseSourceMedium(row.DimensionValues[0].Value),
EcommercePurchases = int.Parse(row.MetricValues[0].Value),
PurchaseRevenue = decimal.Parse(row.MetricValues[1].Value),
};
_ga4DataList.Add(ga4Data);
}
}
Professional Excel Report Generation
Uses ClosedXML to generate formatted Excel reports with styled headers, borders, and proper page formatting for printing.
public static void SaveToExcel(List<ProductDataOutModel> reportData, bool isMonthSales)
{
using var workbook = new XLWorkbook();
var worksheet = workbook.Worksheets.Add("Sales Report");
ExcelHelpers.AddTitleRow(worksheet, titleText, 7);
worksheet.Cell(2, 1).Value = "Season";
worksheet.Cell(2, 2).Value = "Brand";
worksheet.Cell(2, 3).Value = "Description";
worksheet.Cell(2, 4).Value = "Colour";
worksheet.Cell(2, 5).Value = "Sales";
worksheet.Cell(2, 6).Value = "Stock";
worksheet.Cell(2, 7).Value = "Last Delivered";
ExcelHelpers.StyleHeaderRow(worksheet, 7);
foreach (var item in reportData)
{
worksheet.Cell(row, 1).Value = item.ProductSeason;
worksheet.Cell(row, 2).Value = item.ProductBrand;
worksheet.Cell(row, 3).Value = item.ProductDescription;
// ... additional cells
row++;
}
ExcelHelpers.ApplyDataBorders(worksheet, worksheet.LastRowUsed()!.RowNumber(), 7);
ExcelHelpers.ApplyWorksheetFormatting(worksheet, XLPageOrientation.Portrait);
workbook.SaveAs(filePath);
}
Automated Email Report Delivery
Sends generated Excel reports via SMTP with MailKit, supporting TLS and multiple file attachments for weekly automated deliveries.
public static async Task SendExcelReportAsync(string toEmail)
{
var config = LoadConfiguration();
var smtpHost = config["SmtpHost"];
var smtpPort = int.Parse(config["SmtpPort"]!);
var filePaths = Directory.GetFiles(ZenkiFileManager.GetWeeklyReportsFolder());
var message = new MimeMessage();
message.From.Add(new MailboxAddress(fromName, fromEmail));
message.To.Add(MailboxAddress.Parse(toEmail));
message.Subject = $"Weekly Reports – {DateTime.Now:dd/MM/yyyy}";
var multipart = new Multipart("mixed");
foreach (var file in filePaths)
{
var attachment = new MimePart("application", "vnd.openxmlformats-officedocument.spreadsheetml.sheet")
{
Content = new MimeContent(File.OpenRead(file)),
FileName = Path.GetFileName(file)
};
multipart.Add(attachment);
}
using var client = new SmtpClient();
await client.ConnectAsync(smtpHost, smtpPort, SecureSocketOptions.StartTls);
await client.AuthenticateAsync(smtpUsername, smtpPassword);
await client.SendAsync(message);
await client.DisconnectAsync(true);
}
Multi-Location Stock Analysis
Tracks stock levels across multiple retail locations with configurable product type filtering and season-based analysis.
public static async Task<List<ProductDataOutModel>> QueryData(string? selectedSeason, bool excludeSeason, bool matchSeasonType)
{
await using var dbContext = new AppDbContext();
var allowedTypes = new[] { "boots", "sandals", "shoes", "slippers", "trainers" };
var products = await dbContext.Products
.Where(p => new[] { 2, 3 }.Contains(p.Variants!
.SelectMany(v => v.Stocks!
.Where(s => s.LocationName == "Main Street" || s.LocationName == "Town Centre"))
.Sum(s => s.StockQuantity)) && allowedTypes.Contains(p.Type!.ToLower()))
.Include(p => p.Brand)
.Include(p => p.Variants)!.ThenInclude(v => v.Stocks)
.Include(p => p.Variants)!.ThenInclude(v => v.Deliveries)
.ToListAsync();
if (matchSeasonType && selectedSeason != "All Seasons")
{
var seasonType = selectedSeason?.Split('/')[0].Trim();
products = products.Where(p => p.Season!.Contains(seasonType!)).ToList();
}
return products.SelectMany(p => /* transform to output model */).ToList();
}
Challenges & Solutions
- Complex data relationships: Used EF Core eager loading with multiple Include/ThenInclude chains to efficiently fetch related data across products, variants, stocks, sales, and deliveries
- Google Analytics API authentication: Used service account credentials with environment variable configuration for secure API access
- Report scheduling: Implemented file-based output with timestamped filenames for tracking historical report generations
- Multi-location filtering: Built flexible queries that can filter stock data by specific store locations while maintaining performance with in-memory post-filtering