diff --git a/.codacy/cli.sh b/.codacy/cli.sh
new file mode 100755
index 00000000..7057e3bf
--- /dev/null
+++ b/.codacy/cli.sh
@@ -0,0 +1,149 @@
+#!/usr/bin/env bash
+
+
+set -e +o pipefail
+
+# Set up paths first
+bin_name="codacy-cli-v2"
+
+# Determine OS-specific paths
+os_name=$(uname)
+arch=$(uname -m)
+
+case "$arch" in
+"x86_64")
+ arch="amd64"
+ ;;
+"x86")
+ arch="386"
+ ;;
+"aarch64"|"arm64")
+ arch="arm64"
+ ;;
+esac
+
+if [ -z "$CODACY_CLI_V2_TMP_FOLDER" ]; then
+ if [ "$(uname)" = "Linux" ]; then
+ CODACY_CLI_V2_TMP_FOLDER="$HOME/.cache/codacy/codacy-cli-v2"
+ elif [ "$(uname)" = "Darwin" ]; then
+ CODACY_CLI_V2_TMP_FOLDER="$HOME/Library/Caches/Codacy/codacy-cli-v2"
+ else
+ CODACY_CLI_V2_TMP_FOLDER=".codacy-cli-v2"
+ fi
+fi
+
+version_file="$CODACY_CLI_V2_TMP_FOLDER/version.yaml"
+
+
+get_version_from_yaml() {
+ if [ -f "$version_file" ]; then
+ local version=$(grep -o 'version: *"[^"]*"' "$version_file" | cut -d'"' -f2)
+ if [ -n "$version" ]; then
+ echo "$version"
+ return 0
+ fi
+ fi
+ return 1
+}
+
+get_latest_version() {
+ local response
+ if [ -n "$GH_TOKEN" ]; then
+ response=$(curl -Lq --header "Authorization: Bearer $GH_TOKEN" "https://api.github.com/repos/codacy/codacy-cli-v2/releases/latest" 2>/dev/null)
+ else
+ response=$(curl -Lq "https://api.github.com/repos/codacy/codacy-cli-v2/releases/latest" 2>/dev/null)
+ fi
+
+ handle_rate_limit "$response"
+ local version=$(echo "$response" | grep -m 1 tag_name | cut -d'"' -f4)
+ echo "$version"
+}
+
+handle_rate_limit() {
+ local response="$1"
+ if echo "$response" | grep -q "API rate limit exceeded"; then
+ fatal "Error: GitHub API rate limit exceeded. Please try again later"
+ fi
+}
+
+download_file() {
+ local url="$1"
+
+ echo "Downloading from URL: ${url}"
+ if command -v curl > /dev/null 2>&1; then
+ curl -# -LS "$url" -O
+ elif command -v wget > /dev/null 2>&1; then
+ wget "$url"
+ else
+ fatal "Error: Could not find curl or wget, please install one."
+ fi
+}
+
+download() {
+ local url="$1"
+ local output_folder="$2"
+
+ ( cd "$output_folder" && download_file "$url" )
+}
+
+download_cli() {
+ # OS name lower case
+ suffix=$(echo "$os_name" | tr '[:upper:]' '[:lower:]')
+
+ local bin_folder="$1"
+ local bin_path="$2"
+ local version="$3"
+
+ if [ ! -f "$bin_path" ]; then
+ echo "đĨ Downloading CLI version $version..."
+
+ remote_file="codacy-cli-v2_${version}_${suffix}_${arch}.tar.gz"
+ url="https://github.com/codacy/codacy-cli-v2/releases/download/${version}/${remote_file}"
+
+ download "$url" "$bin_folder"
+ tar xzfv "${bin_folder}/${remote_file}" -C "${bin_folder}"
+ fi
+}
+
+# Warn if CODACY_CLI_V2_VERSION is set and update is requested
+if [ -n "$CODACY_CLI_V2_VERSION" ] && [ "$1" = "update" ]; then
+ echo "â ī¸ Warning: Performing update with forced version $CODACY_CLI_V2_VERSION"
+ echo " Unset CODACY_CLI_V2_VERSION to use the latest version"
+fi
+
+# Ensure version.yaml exists and is up to date
+if [ ! -f "$version_file" ] || [ "$1" = "update" ]; then
+ echo "âšī¸ Fetching latest version..."
+ version=$(get_latest_version)
+ mkdir -p "$CODACY_CLI_V2_TMP_FOLDER"
+ echo "version: \"$version\"" > "$version_file"
+fi
+
+# Set the version to use
+if [ -n "$CODACY_CLI_V2_VERSION" ]; then
+ version="$CODACY_CLI_V2_VERSION"
+else
+ version=$(get_version_from_yaml)
+fi
+
+
+# Set up version-specific paths
+bin_folder="${CODACY_CLI_V2_TMP_FOLDER}/${version}"
+
+mkdir -p "$bin_folder"
+bin_path="$bin_folder"/"$bin_name"
+
+# Download the tool if not already installed
+download_cli "$bin_folder" "$bin_path" "$version"
+chmod +x "$bin_path"
+
+run_command="$bin_path"
+if [ -z "$run_command" ]; then
+ fatal "Codacy cli v2 binary could not be found."
+fi
+
+if [ "$#" -eq 1 ] && [ "$1" = "download" ]; then
+ echo "Codacy cli v2 download succeeded"
+else
+ eval "$run_command $*"
+fi
\ No newline at end of file
diff --git a/.codacy/codacy.yaml b/.codacy/codacy.yaml
new file mode 100644
index 00000000..0ea27a63
--- /dev/null
+++ b/.codacy/codacy.yaml
@@ -0,0 +1,2 @@
+tools:
+ - trivy@0.66.0
diff --git a/.gitignore b/.gitignore
index 154e1272..ac78fb2e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -475,3 +475,7 @@ $RECYCLE.BIN/
# Windows shortcuts
*.lnk
+
+
+#Ignore vscode AI rules
+.github/instructions/codacy.instructions.md
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 00000000..1bcf7b2f
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,5 @@
+{
+ "cSpell.words": [
+ "buildtransitive"
+ ]
+}
\ No newline at end of file
diff --git a/ConsoleClient/ECommerceApp.ConsoleClient.csproj b/ConsoleClient/ECommerceApp.ConsoleClient.csproj
new file mode 100644
index 00000000..0c829917
--- /dev/null
+++ b/ConsoleClient/ECommerceApp.ConsoleClient.csproj
@@ -0,0 +1,12 @@
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
diff --git a/ConsoleClient/Handlers/CategoryMenuHandler.cs b/ConsoleClient/Handlers/CategoryMenuHandler.cs
new file mode 100644
index 00000000..84493c74
--- /dev/null
+++ b/ConsoleClient/Handlers/CategoryMenuHandler.cs
@@ -0,0 +1,399 @@
+using ECommerceApp.ConsoleClient.Helpers;
+using ECommerceApp.ConsoleClient.Interfaces;
+using ECommerceApp.ConsoleClient.Models;
+using ECommerceApp.ConsoleClient.Utilities;
+using Spectre.Console;
+
+namespace ECommerceApp.ConsoleClient.Handlers;
+
+///
+/// Handles category menu operations.
+/// Encapsulates all category-related UI logic following Single Responsibility Principle.
+///
+public class CategoryMenuHandler : IConsoleMenuHandler
+{
+ public string MenuName => "Categories";
+
+ public async Task ExecuteAsync(HttpClient http)
+ {
+ var actions = new Dictionary>
+ {
+ { "List", ListAsync },
+ { "Get by Id", GetByIdAsync },
+ { "Get by Name", GetByNameAsync },
+ { "Create", CreateAsync },
+ { "Update", UpdateAsync },
+ { "Delete", DeleteAsync },
+ };
+
+ while (true)
+ {
+ var choice = AnsiConsole.Prompt(
+ new SelectionPrompt()
+ .Title("[green]Categories[/] â Choose an action:")
+ .AddChoices(actions.Keys.Concat(new[] { "Back" }).ToList())
+ );
+
+ if (choice == "Back")
+ return;
+
+ if (actions.TryGetValue(choice, out var action))
+ await action(http);
+ }
+ }
+
+ private static async Task ListAsync(HttpClient http)
+ {
+ var pageSize = ConsoleInputHelper.PromptPositiveInt("Items per page");
+
+ // Ask if user wants to apply filters
+ var applyFilters = AnsiConsole.Confirm("Apply filters?", false);
+
+ string? search = null;
+ string? sortBy = null;
+ string? sortDirection = null;
+
+ if (applyFilters)
+ {
+ search = ConsoleInputHelper.PromptOptional("Search");
+ var (sortByResult, sortDirectionResult) = PromptSortOptions(
+ new[] { "(none)", "name", "createdat" }
+ );
+ sortBy = sortByResult;
+ sortDirection = sortDirectionResult;
+ }
+
+ var query = new CategoryListQuery
+ {
+ Page = 1,
+ PageSize = pageSize,
+ Search = search,
+ SortBy = sortBy,
+ SortDirection = sortDirection,
+ };
+
+ await BuildAndExecuteListQueryWithPagination(http, query);
+ }
+
+ private static async Task BuildAndExecuteListQueryWithPagination(
+ HttpClient http,
+ CategoryListQuery query
+ )
+ {
+ while (true)
+ {
+ var response = await FetchCategoryPageAsync(http, query);
+ if (!HasCategoryResults(response))
+ {
+ AnsiConsole.MarkupLine("[yellow]No categories found[/]");
+ break;
+ }
+
+ var pagination = new PaginationState
+ {
+ CurrentPage = query.Page,
+ PageSize = query.PageSize,
+ TotalCount = response!.TotalCount,
+ };
+
+ TableRenderer.DisplayTable(
+ response.Data!,
+ $"Categories (Page {pagination.CurrentPage}/{pagination.TotalPages}, Total: {pagination.TotalCount})",
+ pagination.IndexOffset
+ );
+
+ if (!HandlePaginationNavigation(query, pagination))
+ break;
+ }
+ }
+
+ private static async Task?> FetchCategoryPageAsync(
+ HttpClient http,
+ CategoryListQuery query
+ )
+ {
+ var qs = new QueryStringBuilder()
+ .Add("page", query.Page.ToString())
+ .Add("pageSize", query.PageSize.ToString())
+ .Add("search", query.Search)
+ .Add("sortBy", query.SortBy == "(none)" || query.SortBy == null ? null : query.SortBy)
+ .Add("sortDirection", query.SortDirection)
+ .Build();
+
+ return await ApiClient.FetchPaginatedAsync(http, $"/api/categories{qs}");
+ }
+
+ private static bool HandlePaginationNavigation(
+ CategoryListQuery query,
+ PaginationState pagination
+ )
+ {
+ var navChoice = AnsiConsole.Prompt(
+ new SelectionPrompt()
+ .Title("Navigation:")
+ .AddChoices(pagination.GetNavigationChoices())
+ );
+
+ return navChoice switch
+ {
+ "Back to Menu" => false,
+ "Next Page" => IncrementPage(query),
+ "Previous Page" => DecrementPage(query),
+ "Jump to Page" => JumpToPage(query, pagination),
+ _ => true,
+ };
+ }
+
+ private static bool JumpToPage(CategoryListQuery query, PaginationState pagination)
+ {
+ int targetPage = ConsoleInputHelper.PromptPositiveInt(
+ $"Enter page number (1-{pagination.TotalPages})"
+ );
+ if (targetPage >= 1 && targetPage <= pagination.TotalPages)
+ {
+ query.Page = targetPage;
+ return true;
+ }
+
+ AnsiConsole.MarkupLine(
+ $"[red]Invalid page number. Valid range: 1-{pagination.TotalPages}[/]"
+ );
+ return true;
+ }
+
+ private static bool IncrementPage(CategoryListQuery query)
+ {
+ query.Page++;
+ return true;
+ }
+
+ private static bool DecrementPage(CategoryListQuery query)
+ {
+ query.Page--;
+ return true;
+ }
+
+ private static bool HasCategoryResults(PaginatedResponse? response)
+ {
+ return response?.Data != null && response.Data.Count > 0;
+ }
+
+ private static async Task GetByIdAsync(HttpClient http)
+ {
+ var response = await ApiClient.FetchPaginatedAsync(
+ http,
+ "/api/categories?page=1&pageSize=32"
+ );
+ if (response?.Data == null || response.Data.Count == 0)
+ {
+ AnsiConsole.MarkupLine("[yellow]No categories available[/]");
+ return;
+ }
+
+ var selected = await TableRenderer.SelectFromPromptAsync(
+ async (pageNum) =>
+ {
+ var pageResponse = await ApiClient.FetchPaginatedAsync(
+ http,
+ $"/api/categories?page={pageNum}&pageSize=32"
+ );
+ return pageResponse?.Data ?? new List();
+ },
+ response.TotalCount,
+ 32,
+ "Select a Category",
+ cat => cat.Name
+ );
+
+ if (selected != null)
+ {
+ TableRenderer.DisplayTable(new[] { selected }.ToList(), "Category Details");
+ }
+ }
+
+ private static async Task GetByNameAsync(HttpClient http)
+ {
+ var name = ConsoleInputHelper.PromptRequired("Name");
+ var response = await ApiClient.FetchEntityAsync(
+ http,
+ $"/api/categories/name/{Uri.EscapeDataString(name)}"
+ );
+ if (response?.Data != null)
+ {
+ TableRenderer.DisplayTable(new[] { response.Data }.ToList(), "Category Details");
+ }
+ else
+ {
+ AnsiConsole.MarkupLine("[yellow]Category not found[/]");
+ }
+ }
+
+ private static async Task CreateAsync(HttpClient http)
+ {
+ var name = ConsoleInputHelper.PromptRequired("Category Name");
+ var description = ConsoleInputHelper.PromptRequired("Description");
+
+ var category = new { name, description };
+ var payload = new { payload = category };
+ await ApiClient.PostAsync(http, "/api/categories", payload);
+ }
+
+ private static async Task UpdateAsync(HttpClient http)
+ {
+ var response = await ApiClient.FetchPaginatedAsync(
+ http,
+ "/api/categories?page=1&pageSize=32"
+ );
+ if (!HasCategoryResults(response))
+ {
+ AnsiConsole.MarkupLine("[yellow]No categories available[/]");
+ return;
+ }
+
+ var selected = await SelectCategoryAsync(http, response!.TotalCount);
+ if (selected == null)
+ return;
+
+ var current = await FetchCategoryDetailsAsync(http, selected.CategoryId);
+ if (current == null)
+ {
+ ConsoleInputHelper.DisplayError("Category not found");
+ return;
+ }
+
+ DisplayCurrentCategory(current);
+ var (name, description) = PromptCategoryUpdates(current);
+ await ApiClient.PutAsync(
+ http,
+ $"/api/categories/{selected.CategoryId}",
+ BuildCategoryUpdatePayload(selected.CategoryId, name, description)
+ );
+ }
+
+ private static async Task FetchCategoryDetailsAsync(
+ HttpClient http,
+ int categoryId
+ )
+ {
+ var categoryResponse = await ApiClient.FetchEntityAsync(
+ http,
+ $"/api/categories/{categoryId}"
+ );
+ return categoryResponse?.Data;
+ }
+
+ private static async Task SelectCategoryAsync(HttpClient http, int totalCount)
+ {
+ return await TableRenderer.SelectFromPromptAsync(
+ async pageNum =>
+ {
+ var pageResponse = await ApiClient.FetchPaginatedAsync(
+ http,
+ $"/api/categories?page={pageNum}&pageSize=32"
+ );
+ return pageResponse?.Data ?? new List();
+ },
+ totalCount,
+ 32,
+ "Select a Category to Update",
+ cat => cat.Name
+ );
+ }
+
+ private static void DisplayCurrentCategory(CategoryDto current)
+ {
+ AnsiConsole.MarkupLine("[yellow]Current values:[/]");
+ AnsiConsole.MarkupLine($" Name: {current.Name}");
+ AnsiConsole.MarkupLine($" Description: {current.Description}");
+ }
+
+ private static (string? Name, string? Description) PromptCategoryUpdates(CategoryDto current)
+ {
+ var name = PromptOptionalField("Category Name", current.Name);
+ var description = PromptOptionalField("Description", current.Description);
+ return (name, description);
+ }
+
+ private static object BuildCategoryUpdatePayload(
+ int categoryId,
+ string? name,
+ string? description
+ )
+ {
+ return new
+ {
+ payload = new
+ {
+ categoryId,
+ name,
+ description,
+ },
+ };
+ }
+
+ private static async Task DeleteAsync(HttpClient http)
+ {
+ var response = await ApiClient.FetchPaginatedAsync(
+ http,
+ "/api/categories?page=1&pageSize=32"
+ );
+ if (response?.Data == null || response.Data.Count == 0)
+ {
+ AnsiConsole.MarkupLine("[yellow]No categories available[/]");
+ return;
+ }
+
+ var selected = await TableRenderer.SelectFromPromptAsync(
+ async (pageNum) =>
+ {
+ var pageResponse = await ApiClient.FetchPaginatedAsync(
+ http,
+ $"/api/categories?page={pageNum}&pageSize=32"
+ );
+ return pageResponse?.Data ?? new List();
+ },
+ response.TotalCount,
+ 32,
+ "Select a Category to Delete",
+ cat => cat.Name
+ );
+
+ if (selected == null)
+ return;
+
+ if (!AnsiConsole.Confirm($"[red]Are you sure you want to delete '{selected.Name}'?[/]"))
+ return;
+
+ await ApiClient.DeleteAsync(http, $"/api/categories/{selected.CategoryId}");
+ }
+
+ private static string? PromptOptionalField(string label, string? currentValue)
+ {
+ var input = AnsiConsole.Ask($"{label} (leave blank to keep):", string.Empty);
+ return string.IsNullOrWhiteSpace(input) ? currentValue : input;
+ }
+
+ private static (string SortBy, string? SortDirection) PromptSortOptions(string[] options)
+ {
+ var sortBy = AnsiConsole.Prompt(
+ new SelectionPrompt().Title("Sort By (optional)").AddChoices(options)
+ );
+
+ string? sortDirection = null;
+ if (sortBy != "(none)")
+ {
+ sortDirection = AnsiConsole.Prompt(
+ new SelectionPrompt().Title("Sort Direction").AddChoices("asc", "desc")
+ );
+ }
+
+ return (sortBy, sortDirection);
+ }
+
+ private sealed record CategoryDto
+ {
+ public int CategoryId { get; init; }
+ public string Name { get; init; } = string.Empty;
+ public string? Description { get; init; }
+ }
+}
diff --git a/ConsoleClient/Handlers/ProductMenuHandler.Models.cs b/ConsoleClient/Handlers/ProductMenuHandler.Models.cs
new file mode 100644
index 00000000..876f68b9
--- /dev/null
+++ b/ConsoleClient/Handlers/ProductMenuHandler.Models.cs
@@ -0,0 +1,56 @@
+namespace ECommerceApp.ConsoleClient.Handlers;
+
+public partial class ProductMenuHandler
+{
+ private sealed record ProductListQuery
+ {
+ public int Page { get; set; }
+ public int PageSize { get; set; }
+ public string? Search { get; set; }
+ public decimal? MinPrice { get; set; }
+ public decimal? MaxPrice { get; set; }
+ public int? CategoryId { get; set; }
+ public string SortBy { get; set; } = "(none)";
+ public string? SortDirection { get; set; }
+ }
+
+ private sealed record ProductFilters
+ {
+ public static ProductFilters Empty => new() { SortBy = "(none)" };
+
+ public string? Search { get; init; }
+ public decimal? MinPrice { get; init; }
+ public decimal? MaxPrice { get; init; }
+ public int? CategoryId { get; init; }
+ public string SortBy { get; init; } = "(none)";
+ public string? SortDirection { get; init; }
+ }
+
+ private sealed record ProductUpdate
+ {
+ public string? Name { get; init; }
+ public string? Description { get; init; }
+ public decimal Price { get; init; }
+ public int Stock { get; init; }
+ public int CategoryId { get; init; }
+ public bool IsActive { get; init; }
+ }
+
+ private sealed record CategoryDto
+ {
+ public int CategoryId { get; init; }
+ public string Name { get; init; } = string.Empty;
+ public string? Description { get; init; }
+ }
+
+ private sealed record ProductDto
+ {
+ public int ProductId { get; init; }
+ public string Name { get; init; } = string.Empty;
+ public string? Description { get; init; }
+ public decimal Price { get; init; }
+ public int Stock { get; init; }
+ public bool IsActive { get; init; }
+ public int CategoryId { get; init; }
+ }
+}
diff --git a/ConsoleClient/Handlers/ProductMenuHandler.cs b/ConsoleClient/Handlers/ProductMenuHandler.cs
new file mode 100644
index 00000000..b80df544
--- /dev/null
+++ b/ConsoleClient/Handlers/ProductMenuHandler.cs
@@ -0,0 +1,537 @@
+using ECommerceApp.ConsoleClient.Helpers;
+using ECommerceApp.ConsoleClient.Interfaces;
+using ECommerceApp.ConsoleClient.Models;
+using ECommerceApp.ConsoleClient.Utilities;
+using Spectre.Console;
+
+namespace ECommerceApp.ConsoleClient.Handlers;
+
+///
+/// Handles product menu operations.
+/// Encapsulates all product-related UI logic following Single Responsibility Principle.
+///
+public partial class ProductMenuHandler : IConsoleMenuHandler
+{
+ public string MenuName => "Products";
+
+ public async Task ExecuteAsync(HttpClient http)
+ {
+ var actions = new Dictionary>
+ {
+ { "List", ListAsync },
+ { "Get by Id", GetByIdAsync },
+ { "By Category", GetByCategoryAsync },
+ { "Create", CreateAsync },
+ { "Update", UpdateAsync },
+ { "Delete", DeleteAsync },
+ };
+
+ while (true)
+ {
+ var choice = AnsiConsole.Prompt(
+ new SelectionPrompt()
+ .Title("[green]Products[/] â Choose an action:")
+ .AddChoices(actions.Keys.Concat(new[] { "Back" }).ToList())
+ );
+
+ if (choice == "Back")
+ return;
+
+ if (actions.TryGetValue(choice, out var action))
+ await action(http);
+ }
+ }
+
+ private static async Task GetByIdAsync(HttpClient http)
+ {
+ // First, show a list for the user to select from
+ var response = await ApiClient.FetchPaginatedAsync(
+ http,
+ "/api/product?page=1&pageSize=32"
+ );
+ if (response?.Data == null || response.Data.Count == 0)
+ {
+ AnsiConsole.MarkupLine("[yellow]No products available[/]");
+ return;
+ }
+
+ var selected = await TableRenderer.SelectFromPromptAsync(
+ async (pageNum) =>
+ {
+ var pageResponse = await ApiClient.FetchPaginatedAsync(
+ http,
+ $"/api/product?page={pageNum}&pageSize=32"
+ );
+ return pageResponse?.Data ?? new List();
+ },
+ response.TotalCount,
+ 32,
+ "Select a Product",
+ product => product.Name
+ );
+
+ if (selected != null)
+ {
+ // Display the selected product directly from the list
+ TableRenderer.DisplayTable(
+ new[] { selected }.ToList(),
+ "Product Details",
+ 0,
+ "CategoryId"
+ );
+ }
+ }
+
+ private static async Task GetByCategoryAsync(HttpClient http)
+ {
+ int categoryId = ConsoleInputHelper.PromptPositiveInt("Category ID");
+ var qs = new QueryStringBuilder().Add("page", "1").Add("pageSize", "50").Build();
+
+ var response = await ApiClient.FetchPaginatedAsync(
+ http,
+ $"/api/product/category/{categoryId}{qs}"
+ );
+ if (response?.Data != null && response.Data.Count > 0)
+ {
+ TableRenderer.DisplayTable(
+ response.Data,
+ $"Products in Category {categoryId}",
+ 0,
+ "CategoryId"
+ );
+ }
+ else
+ {
+ AnsiConsole.MarkupLine("[yellow]No products found in this category[/]");
+ }
+ }
+
+ private static async Task ListAsync(HttpClient http)
+ {
+ var pageSize = ConsoleInputHelper.PromptPositiveInt("Items per page");
+
+ var filters = AnsiConsole.Confirm("Apply filters?", false)
+ ? PromptProductFilters()
+ : ProductFilters.Empty;
+
+ var query = new ProductListQuery
+ {
+ Page = 1,
+ PageSize = pageSize,
+ Search = filters.Search,
+ MinPrice = filters.MinPrice,
+ MaxPrice = filters.MaxPrice,
+ CategoryId = filters.CategoryId,
+ SortBy = filters.SortBy,
+ SortDirection = filters.SortDirection,
+ };
+
+ await BuildAndExecuteListQueryWithPagination(http, query);
+ }
+
+ private static async Task BuildAndExecuteListQueryWithPagination(
+ HttpClient http,
+ ProductListQuery query
+ )
+ {
+ while (true)
+ {
+ var response = await FetchProductPageAsync(http, query);
+ if (!HasProductResults(response))
+ {
+ AnsiConsole.MarkupLine("[yellow]No products found[/]");
+ break;
+ }
+
+ var pagination = new PaginationState
+ {
+ CurrentPage = query.Page,
+ PageSize = query.PageSize,
+ TotalCount = response!.TotalCount,
+ };
+
+ TableRenderer.DisplayTable(
+ response.Data!,
+ $"Products (Page {pagination.CurrentPage}/{pagination.TotalPages}, Total: {pagination.TotalCount})",
+ pagination.IndexOffset,
+ "CategoryId"
+ );
+
+ if (!HandlePaginationNavigation(pagination, query))
+ break;
+ }
+ }
+
+ private static bool HandlePaginationNavigation(
+ PaginationState pagination,
+ ProductListQuery query
+ )
+ {
+ var navChoice = AnsiConsole.Prompt(
+ new SelectionPrompt()
+ .Title("Navigation:")
+ .AddChoices(pagination.GetNavigationChoices())
+ );
+
+ if (navChoice == "Back to Menu")
+ return false;
+
+ if (navChoice == "Next Page")
+ {
+ query.Page++;
+ }
+ else if (navChoice == "Previous Page")
+ {
+ query.Page--;
+ }
+ else if (navChoice == "Jump to Page")
+ {
+ int targetPage = ConsoleInputHelper.PromptPositiveInt(
+ $"Enter page number (1-{pagination.TotalPages})"
+ );
+ if (targetPage >= 1 && targetPage <= pagination.TotalPages)
+ query.Page = targetPage;
+ else
+ AnsiConsole.MarkupLine(
+ $"[red]Invalid page number. Valid range: 1-{pagination.TotalPages}[/]"
+ );
+ }
+
+ return true;
+ }
+
+ private static async Task CreateAsync(HttpClient http)
+ {
+ // Show list of categories to select from
+ var categoriesResponse = await ApiClient.FetchPaginatedAsync(
+ http,
+ "/api/categories?page=1&pageSize=32"
+ );
+ if (categoriesResponse?.Data == null || categoriesResponse.Data.Count == 0)
+ {
+ AnsiConsole.MarkupLine(
+ "[yellow]No categories available. Please create a category first.[/]"
+ );
+ return;
+ }
+
+ var selectedCategory = await TableRenderer.SelectFromPromptAsync(
+ async (pageNum) =>
+ {
+ var pageResponse = await ApiClient.FetchPaginatedAsync(
+ http,
+ $"/api/categories?page={pageNum}&pageSize=32"
+ );
+ return pageResponse?.Data ?? new List();
+ },
+ categoriesResponse.TotalCount,
+ 32,
+ "Select a Category",
+ cat => cat.Name
+ );
+
+ if (selectedCategory == null)
+ return;
+
+ var name = ConsoleInputHelper.PromptRequired("Product Name");
+ var description = ConsoleInputHelper.PromptRequired("Description");
+ var price = ConsoleInputHelper.PromptPositiveDecimal("Price");
+ var stock = ConsoleInputHelper.PromptNonNegativeInt("Stock");
+
+ var product = new
+ {
+ name,
+ description,
+ price,
+ stock,
+ categoryId = selectedCategory.CategoryId,
+ isActive = true,
+ };
+
+ var payload = new { payload = product };
+ await ApiClient.PostAsync(http, "/api/product", payload);
+ }
+
+ private static async Task UpdateAsync(HttpClient http)
+ {
+ var response = await ApiClient.FetchPaginatedAsync(
+ http,
+ "/api/product?page=1&pageSize=32"
+ );
+ if (!HasProductResults(response))
+ {
+ AnsiConsole.MarkupLine("[yellow]No products available[/]");
+ return;
+ }
+
+ var selected = await SelectProductAsync(http, response!.TotalCount);
+ if (selected == null)
+ return;
+
+ var current = await FetchProductDetailsAsync(http, selected.ProductId);
+ if (current == null)
+ {
+ ConsoleInputHelper.DisplayError("Product not found");
+ return;
+ }
+
+ DisplayCurrentValues(current);
+
+ var categoryId = await DetermineCategoryAsync(http, current.CategoryId);
+ var update = PromptProductUpdates(current, categoryId);
+
+ await ApiClient.PutAsync(
+ http,
+ $"/api/product/{selected.ProductId}",
+ BuildProductUpdatePayload(selected.ProductId, update)
+ );
+ }
+
+ private static ProductFilters PromptProductFilters()
+ {
+ var search = ConsoleInputHelper.PromptOptional("Search");
+ var minPrice = ConsoleInputHelper.PromptOptionalDecimal("Min Price");
+ var maxPrice = ConsoleInputHelper.PromptOptionalDecimal("Max Price");
+ var categoryId = ConsoleInputHelper.PromptOptionalInt("Category Id");
+ var (sortByResult, sortDirectionResult) = PromptSortOptions(
+ new[] { "(none)", "name", "price", "stock", "createdat", "category" }
+ );
+
+ return new ProductFilters
+ {
+ Search = search,
+ MinPrice = minPrice,
+ MaxPrice = maxPrice,
+ CategoryId = categoryId,
+ SortBy = sortByResult,
+ SortDirection = sortDirectionResult,
+ };
+ }
+
+ private static async Task?> FetchProductPageAsync(
+ HttpClient http,
+ ProductListQuery query
+ )
+ {
+ var qs = new QueryStringBuilder()
+ .Add("page", query.Page.ToString())
+ .Add("pageSize", query.PageSize.ToString())
+ .Add("search", query.Search)
+ .Add("minPrice", query.MinPrice?.ToString())
+ .Add("maxPrice", query.MaxPrice?.ToString())
+ .Add("categoryId", query.CategoryId?.ToString())
+ .Add("sortBy", query.SortBy == "(none)" || query.SortBy == null ? null : query.SortBy)
+ .Add("sortDirection", query.SortDirection)
+ .Build();
+
+ return await ApiClient.FetchPaginatedAsync(http, $"/api/product{qs}");
+ }
+
+ private static bool HasProductResults(PaginatedResponse? response)
+ {
+ return response?.Data != null && response.Data.Count > 0;
+ }
+
+ private static async Task SelectProductAsync(HttpClient http, int totalCount)
+ {
+ return await TableRenderer.SelectFromPromptAsync(
+ async pageNum =>
+ {
+ var pageResponse = await ApiClient.FetchPaginatedAsync(
+ http,
+ $"/api/product?page={pageNum}&pageSize=32"
+ );
+ return pageResponse?.Data ?? new List();
+ },
+ totalCount,
+ 32,
+ "Select a Product to Update",
+ product => product.Name
+ );
+ }
+
+ private static async Task FetchProductDetailsAsync(HttpClient http, int productId)
+ {
+ var productResponse = await ApiClient.FetchEntityAsync(
+ http,
+ $"/api/product/{productId}"
+ );
+ return productResponse?.Data;
+ }
+
+ private static async Task DetermineCategoryAsync(HttpClient http, int currentCategoryId)
+ {
+ var categoriesResponse = await ApiClient.FetchPaginatedAsync(
+ http,
+ "/api/categories?page=1&pageSize=32"
+ );
+ if (!HasCategoryResults(categoriesResponse))
+ return currentCategoryId;
+
+ if (!AnsiConsole.Confirm("Change category?", false))
+ return currentCategoryId;
+
+ var selectedCategory = await PromptCategorySelectionAsync(
+ http,
+ categoriesResponse!.TotalCount
+ );
+ return selectedCategory?.CategoryId ?? currentCategoryId;
+ }
+
+ private static async Task PromptCategorySelectionAsync(
+ HttpClient http,
+ int totalCount
+ )
+ {
+ return await TableRenderer.SelectFromPromptAsync(
+ pageNum => FetchCategoryPageAsync(http, pageNum),
+ totalCount,
+ 32,
+ "Select a Category",
+ cat => cat.Name
+ );
+ }
+
+ private static async Task> FetchCategoryPageAsync(
+ HttpClient http,
+ int pageNum
+ )
+ {
+ var pageResponse = await ApiClient.FetchPaginatedAsync(
+ http,
+ $"/api/categories?page={pageNum}&pageSize=32"
+ );
+ return pageResponse?.Data ?? new List();
+ }
+
+ private static bool HasCategoryResults(PaginatedResponse? response)
+ {
+ return response?.Data != null && response.Data.Count > 0;
+ }
+
+ private static ProductUpdate PromptProductUpdates(ProductDto current, int categoryId)
+ {
+ var name = PromptOptionalField("Product Name", current.Name);
+ var description = PromptOptionalField("Description", current.Description);
+ var priceStr = AnsiConsole.Ask("Price (leave blank to keep):", string.Empty);
+ var stockStr = AnsiConsole.Ask("Stock (leave blank to keep):", string.Empty);
+ var isActiveStr = AnsiConsole.Ask(
+ "Active? (yes/no, leave blank to keep):",
+ string.Empty
+ );
+
+ return new ProductUpdate
+ {
+ Name = name,
+ Description = description,
+ Price = TryParseDecimal(priceStr, current.Price),
+ Stock = TryParseInt(stockStr, current.Stock),
+ CategoryId = categoryId,
+ IsActive = TryParseBool(isActiveStr, current.IsActive),
+ };
+ }
+
+ private static object BuildProductUpdatePayload(int productId, ProductUpdate update)
+ {
+ return new
+ {
+ payload = new
+ {
+ productId,
+ name = update.Name,
+ description = update.Description,
+ price = update.Price,
+ stock = update.Stock,
+ categoryId = update.CategoryId,
+ isActive = update.IsActive,
+ },
+ };
+ }
+
+ private static decimal TryParseDecimal(string value, decimal fallback)
+ {
+ return decimal.TryParse(value, out var parsed) ? parsed : fallback;
+ }
+
+ private static int TryParseInt(string value, int fallback)
+ {
+ return int.TryParse(value, out var parsed) ? parsed : fallback;
+ }
+
+ private static bool TryParseBool(string value, bool fallback)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ return fallback;
+
+ return value.Equals("yes", StringComparison.OrdinalIgnoreCase)
+ || value.Equals("true", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static async Task DeleteAsync(HttpClient http)
+ {
+ // Show list for selection
+ var qs = new QueryStringBuilder().Add("page", "1").Add("pageSize", "32").Build();
+
+ var response = await ApiClient.FetchPaginatedAsync(http, $"/api/product{qs}");
+ if (response?.Data == null || response.Data.Count == 0)
+ {
+ AnsiConsole.MarkupLine("[yellow]No products available[/]");
+ return;
+ }
+
+ var selected = await TableRenderer.SelectFromPromptAsync(
+ async (pageNum) =>
+ {
+ var pageResponse = await ApiClient.FetchPaginatedAsync(
+ http,
+ $"/api/product?page={pageNum}&pageSize=32"
+ );
+ return pageResponse?.Data ?? new List();
+ },
+ response.TotalCount,
+ 32,
+ "Select a Product to Delete",
+ product => product.Name
+ );
+ if (selected == null)
+ return;
+
+ if (!AnsiConsole.Confirm($"[red]Are you sure you want to delete '{selected.Name}'?[/]"))
+ return;
+ await ApiClient.DeleteAsync(http, $"/api/product/{selected.ProductId}");
+ }
+
+ private static void DisplayCurrentValues(ProductDto current)
+ {
+ AnsiConsole.MarkupLine($"[yellow]Current values:[/]");
+ AnsiConsole.MarkupLine($" Name: {current.Name}");
+ AnsiConsole.MarkupLine($" Description: {current.Description}");
+ AnsiConsole.MarkupLine($" Price: {current.Price:0.00}");
+ AnsiConsole.MarkupLine($" Stock: {current.Stock}");
+ AnsiConsole.MarkupLine($" Active: {current.IsActive}");
+ AnsiConsole.MarkupLine($" Category ID: {current.CategoryId}");
+ }
+
+ private static string? PromptOptionalField(string label, string? currentValue)
+ {
+ var input = AnsiConsole.Ask($"{label} (leave blank to keep):", string.Empty);
+ return string.IsNullOrWhiteSpace(input) ? currentValue : input;
+ }
+
+ private static (string SortBy, string? SortDirection) PromptSortOptions(string[] options)
+ {
+ var sortBy = AnsiConsole.Prompt(
+ new SelectionPrompt().Title("Sort By (optional)").AddChoices(options)
+ );
+
+ string? sortDirection = null;
+ if (sortBy != "(none)")
+ {
+ sortDirection = AnsiConsole.Prompt(
+ new SelectionPrompt().Title("Sort Direction").AddChoices("asc", "desc")
+ );
+ }
+
+ return (sortBy, sortDirection);
+ }
+}
diff --git a/ConsoleClient/Handlers/SalesMenuHandler.cs b/ConsoleClient/Handlers/SalesMenuHandler.cs
new file mode 100644
index 00000000..452edc9d
--- /dev/null
+++ b/ConsoleClient/Handlers/SalesMenuHandler.cs
@@ -0,0 +1,463 @@
+using System.Text.Json;
+using ECommerceApp.ConsoleClient.Helpers;
+using ECommerceApp.ConsoleClient.Interfaces;
+using ECommerceApp.ConsoleClient.Models;
+using ECommerceApp.ConsoleClient.Utilities;
+using Spectre.Console;
+
+namespace ECommerceApp.ConsoleClient.Handlers;
+
+///
+/// Handles sales menu operations.
+/// Encapsulates all sales-related UI logic following Single Responsibility Principle.
+/// Split into smaller methods to reduce cyclomatic complexity.
+///
+public class SalesMenuHandler : IConsoleMenuHandler
+{
+ public string MenuName => "Sales";
+
+ public async Task ExecuteAsync(HttpClient http)
+ {
+ var actions = new Dictionary>
+ {
+ { "List", ListAsync },
+ { "Get by Id", h => ApiClient.GetByIdAsync(h, "/api/sales/{id}", "Sale") },
+ {
+ "With Deleted Products (all)",
+ h => ApiClient.GetAndRenderAsync(h, "/api/sales/with-deleted-products")
+ },
+ {
+ "With Deleted Products (by Id)",
+ h => ApiClient.GetByIdAsync(h, "/api/sales/{id}/with-deleted-products", "Sale")
+ },
+ { "Create", CreateAsync },
+ { "Update", UpdateAsync },
+ { "Delete", DeleteAsync },
+ };
+
+ while (true)
+ {
+ var choice = AnsiConsole.Prompt(
+ new SelectionPrompt()
+ .Title("[green]Sales[/] â Choose an action:")
+ .AddChoices(actions.Keys.Concat(new[] { "Back" }).ToList())
+ );
+
+ if (choice == "Back")
+ return;
+
+ if (actions.TryGetValue(choice, out var action))
+ await action(http);
+ }
+ }
+
+ private static async Task ListAsync(HttpClient http)
+ {
+ var pageSize = ConsoleInputHelper.PromptPositiveInt("Items per page");
+
+ // Ask if user wants to apply filters
+ var applyFilters = AnsiConsole.Confirm("Apply filters?", false);
+
+ DateTime? start = null;
+ DateTime? end = null;
+ string? customerName = null;
+ string? customerEmail = null;
+ string? sortBy = null;
+ string? sortDirection = null;
+
+ if (applyFilters)
+ {
+ start = ConsoleInputHelper.PromptOptionalDate("Start Date (yyyy-MM-dd)");
+ end = ConsoleInputHelper.PromptOptionalDate("End Date (yyyy-MM-dd)");
+ customerName = ConsoleInputHelper.PromptOptional("Customer Name");
+ customerEmail = ConsoleInputHelper.PromptOptional("Customer Email");
+ var (sortByResult, sortDirectionResult) = PromptSortOptions(
+ new[] { "(none)", "saledate", "totalamount", "customername" }
+ );
+ sortBy = sortByResult;
+ sortDirection = sortDirectionResult;
+ }
+
+ var query = new SaleListQuery
+ {
+ Page = 1,
+ PageSize = pageSize,
+ StartDate = start,
+ EndDate = end,
+ CustomerName = customerName,
+ CustomerEmail = customerEmail,
+ SortBy = sortBy,
+ SortDirection = sortDirection,
+ };
+
+ await BuildAndExecuteListQueryWithPagination(http, query);
+ }
+
+ private static async Task BuildAndExecuteListQueryWithPagination(
+ HttpClient http,
+ SaleListQuery query
+ )
+ {
+ while (true)
+ {
+ var qs = BuildSaleQueryString(query);
+ var response = await ApiClient.FetchPaginatedAsync(http, $"/api/sales{qs}");
+
+ if (response?.Data == null || response.Data.Count == 0)
+ {
+ AnsiConsole.MarkupLine("[yellow]No sales found[/]");
+ break;
+ }
+
+ var pagination = new PaginationState
+ {
+ CurrentPage = query.Page,
+ PageSize = query.PageSize,
+ TotalCount = response.TotalCount,
+ };
+
+ TableRenderer.DisplayTable(
+ response.Data,
+ $"Sales (Page {pagination.CurrentPage}/{pagination.TotalPages}, Total: {pagination.TotalCount})",
+ pagination.IndexOffset
+ );
+
+ if (!HandlePaginationNavigation(query, pagination))
+ break;
+ }
+ }
+
+ private static string BuildSaleQueryString(SaleListQuery query)
+ {
+ return new QueryStringBuilder()
+ .Add("page", query.Page.ToString())
+ .Add("pageSize", query.PageSize.ToString())
+ .Add("startDate", query.StartDate?.ToString("yyyy-MM-dd"))
+ .Add("endDate", query.EndDate?.ToString("yyyy-MM-dd"))
+ .Add("customerName", query.CustomerName)
+ .Add("customerEmail", query.CustomerEmail)
+ .Add("sortBy", query.SortBy == "(none)" || query.SortBy == null ? null : query.SortBy)
+ .Add("sortDirection", query.SortDirection)
+ .Build();
+ }
+
+ private static bool HandlePaginationNavigation(SaleListQuery query, PaginationState pagination)
+ {
+ var navChoice = AnsiConsole.Prompt(
+ new SelectionPrompt()
+ .Title("Navigation:")
+ .AddChoices(pagination.GetNavigationChoices())
+ );
+
+ switch (navChoice)
+ {
+ case "Back to Menu":
+ return false;
+ case "Next Page":
+ query.Page++;
+ break;
+ case "Previous Page":
+ query.Page--;
+ break;
+ case "Jump to Page":
+ HandleJumpToPage(query, pagination);
+ break;
+ }
+
+ return true;
+ }
+
+ private static void HandleJumpToPage(SaleListQuery query, PaginationState pagination)
+ {
+ int targetPage = ConsoleInputHelper.PromptPositiveInt(
+ $"Enter page number (1-{pagination.TotalPages})"
+ );
+ if (targetPage >= 1 && targetPage <= pagination.TotalPages)
+ query.Page = targetPage;
+ else
+ AnsiConsole.MarkupLine(
+ $"[red]Invalid page number. Valid range: 1-{pagination.TotalPages}[/]"
+ );
+ }
+
+ private static async Task CreateAsync(HttpClient http)
+ {
+ var customerName = ConsoleInputHelper.PromptRequired("Customer Name");
+ var customerEmail = ConsoleInputHelper.PromptRequired("Customer Email");
+ var customerAddress = ConsoleInputHelper.PromptRequired("Customer Address");
+ var saleDate = AnsiConsole.Prompt(
+ new TextPrompt("Sale Date (yyyy-MM-dd):").DefaultValue(DateTime.Now)
+ );
+
+ var saleItems = await PromptSaleItemsAsync(http);
+ if (saleItems.Count == 0)
+ {
+ ConsoleInputHelper.DisplayError("Must add at least one sale item");
+ return;
+ }
+
+ var sale = new
+ {
+ customerName,
+ customerEmail,
+ customerAddress,
+ saleDate,
+ saleItems,
+ };
+ var payload = new { payload = sale };
+ await ApiClient.PostAsync(http, "/api/sales", payload);
+ }
+
+ private static async Task UpdateAsync(HttpClient http)
+ {
+ var response = await ApiClient.FetchPaginatedAsync(
+ http,
+ "/api/sales?page=1&pageSize=32"
+ );
+ if (!HasSaleResults(response))
+ {
+ AnsiConsole.MarkupLine("[yellow]No sales available[/]");
+ return;
+ }
+
+ var selected = await SelectSaleAsync(http, response!.TotalCount);
+ if (selected == null)
+ return;
+
+ var current = await FetchSaleDetailsAsync(http, selected.SaleId);
+ if (current == null)
+ {
+ ConsoleInputHelper.DisplayError("Sale not found");
+ return;
+ }
+
+ DisplayCurrentSaleValues(current);
+ var update = PromptSaleUpdate(current);
+
+ await ApiClient.PutAsync(
+ http,
+ $"/api/sales/{selected.SaleId}",
+ BuildSaleUpdatePayload(selected.SaleId, update)
+ );
+ }
+
+ private static async Task DeleteAsync(HttpClient http)
+ {
+ var response = await ApiClient.FetchPaginatedAsync(
+ http,
+ "/api/sales?page=1&pageSize=32"
+ );
+ if (response?.Data == null || response.Data.Count == 0)
+ {
+ AnsiConsole.MarkupLine("[yellow]No sales available[/]");
+ return;
+ }
+
+ var selected = await TableRenderer.SelectFromPromptAsync(
+ async (pageNum) =>
+ {
+ var pageResponse = await ApiClient.FetchPaginatedAsync(
+ http,
+ $"/api/sales?page={pageNum}&pageSize=32"
+ );
+ return pageResponse?.Data ?? new List();
+ },
+ response.TotalCount,
+ 32,
+ "Select a Sale to Delete",
+ sale => $"{sale.SaleDate:yyyy-MM-dd HH:mm} - {sale.CustomerName}"
+ );
+
+ if (selected == null)
+ return;
+
+ if (
+ !AnsiConsole.Confirm(
+ $"[red]Are you sure you want to delete the sale from '{selected.SaleDate:yyyy-MM-dd HH:mm}' for '{selected.CustomerName}'?[/]"
+ )
+ )
+ return;
+
+ await ApiClient.DeleteAsync(http, $"/api/sales/{selected.SaleId}");
+ }
+
+ private static async Task> PromptSaleItemsAsync(HttpClient http)
+ {
+ var saleItems = new List