From aacba4cc90a48cc425765b19fcf6068933c39438 Mon Sep 17 00:00:00 2001 From: princechavez Date: Thu, 13 Nov 2025 13:34:06 +0800 Subject: [PATCH 1/6] admin page --- .../Controllers/AdminController.cs | 345 +++++++++++++ .../Middleware/RequestLoggingMiddleware.cs | 67 +++ CSharpPizza.Api/Program.cs | 5 + .../net9.0/CSharpPizza.Api.AssemblyInfo.cs | 2 +- .../Debug/net9.0/rjsmcshtml.dswa.cache.json | 2 +- .../Debug/net9.0/rjsmrazor.dswa.cache.json | 2 +- CSharpPizza.DTO/Admin/AdminOrderFilterDto.cs | 9 + CSharpPizza.DTO/Admin/AdminOrderListDto.cs | 13 + CSharpPizza.DTO/Mappings/AdminProfile.cs | 18 + .../net9.0/CSharpPizza.DTO.AssemblyInfo.cs | 2 +- CSharpPizza.Data/Entities/Log.cs | 12 + .../20251113045133_AddLogEntity.Designer.cs | 474 ++++++++++++++++++ .../Migrations/20251113045133_AddLogEntity.cs | 43 ++ .../Migrations/PizzaDbContextModelSnapshot.cs | 43 ++ CSharpPizza.Data/PizzaDbContext.cs | 10 + .../Repositories/ILogRepository.cs | 7 + .../Repositories/LogRepository.cs | 10 + .../net9.0/CSharpPizza.Data.AssemblyInfo.cs | 2 +- .../Extensions/StringExtensions.cs | 10 + .../Services/ILoggingService.cs | 8 + CSharpPizza.Domain/Services/LoggingService.cs | 47 ++ CSharpPizza.Domain/Services/PizzaService.cs | 18 +- CSharpPizza.Domain/Services/ToppingService.cs | 18 +- .../net9.0/CSharpPizza.Domain.AssemblyInfo.cs | 2 +- CSharpPizza.WebUI/src/App.tsx | 21 + CSharpPizza.WebUI/src/api/admin.ts | 79 +++ CSharpPizza.WebUI/src/api/index.ts | 3 +- .../src/components/AdminLayout.css | 133 +++++ .../src/components/AdminLayout.tsx | 60 +++ .../src/components/AdminProtectedRoute.tsx | 20 + CSharpPizza.WebUI/src/components/Navbar.tsx | 6 + .../src/pages/admin/AdminDashboard.css | 73 +++ .../src/pages/admin/AdminDashboard.tsx | 75 +++ .../src/pages/admin/AdminOrders.css | 310 ++++++++++++ .../src/pages/admin/AdminOrders.tsx | 210 ++++++++ .../src/pages/admin/AdminPizzas.css | 296 +++++++++++ .../src/pages/admin/AdminPizzas.tsx | 232 +++++++++ .../src/pages/admin/AdminToppings.css | 308 ++++++++++++ .../src/pages/admin/AdminToppings.tsx | 263 ++++++++++ CSharpPizza.WebUI/src/types/index.ts | 46 ++ 40 files changed, 3293 insertions(+), 11 deletions(-) create mode 100644 CSharpPizza.Api/Controllers/AdminController.cs create mode 100644 CSharpPizza.Api/Middleware/RequestLoggingMiddleware.cs create mode 100644 CSharpPizza.DTO/Admin/AdminOrderFilterDto.cs create mode 100644 CSharpPizza.DTO/Admin/AdminOrderListDto.cs create mode 100644 CSharpPizza.DTO/Mappings/AdminProfile.cs create mode 100644 CSharpPizza.Data/Entities/Log.cs create mode 100644 CSharpPizza.Data/Migrations/20251113045133_AddLogEntity.Designer.cs create mode 100644 CSharpPizza.Data/Migrations/20251113045133_AddLogEntity.cs create mode 100644 CSharpPizza.Data/Repositories/ILogRepository.cs create mode 100644 CSharpPizza.Data/Repositories/LogRepository.cs create mode 100644 CSharpPizza.Domain/Extensions/StringExtensions.cs create mode 100644 CSharpPizza.Domain/Services/ILoggingService.cs create mode 100644 CSharpPizza.Domain/Services/LoggingService.cs create mode 100644 CSharpPizza.WebUI/src/api/admin.ts create mode 100644 CSharpPizza.WebUI/src/components/AdminLayout.css create mode 100644 CSharpPizza.WebUI/src/components/AdminLayout.tsx create mode 100644 CSharpPizza.WebUI/src/components/AdminProtectedRoute.tsx create mode 100644 CSharpPizza.WebUI/src/pages/admin/AdminDashboard.css create mode 100644 CSharpPizza.WebUI/src/pages/admin/AdminDashboard.tsx create mode 100644 CSharpPizza.WebUI/src/pages/admin/AdminOrders.css create mode 100644 CSharpPizza.WebUI/src/pages/admin/AdminOrders.tsx create mode 100644 CSharpPizza.WebUI/src/pages/admin/AdminPizzas.css create mode 100644 CSharpPizza.WebUI/src/pages/admin/AdminPizzas.tsx create mode 100644 CSharpPizza.WebUI/src/pages/admin/AdminToppings.css create mode 100644 CSharpPizza.WebUI/src/pages/admin/AdminToppings.tsx diff --git a/CSharpPizza.Api/Controllers/AdminController.cs b/CSharpPizza.Api/Controllers/AdminController.cs new file mode 100644 index 0000000..69ff09c --- /dev/null +++ b/CSharpPizza.Api/Controllers/AdminController.cs @@ -0,0 +1,345 @@ +using AutoMapper; +using CSharpPizza.Data.Entities; +using CSharpPizza.Data.Repositories; +using CSharpPizza.Domain.Services; +using CSharpPizza.DTO.Admin; +using CSharpPizza.DTO.Orders; +using CSharpPizza.DTO.Pizzas; +using CSharpPizza.DTO.Toppings; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace CSharpPizza.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize(Roles = "Admin")] +public class AdminController : ControllerBase +{ + private readonly IOrderService _orderService; + private readonly IPizzaService _pizzaService; + private readonly IToppingService _toppingService; + private readonly IRepository _orderRepository; + private readonly IPizzaRepository _pizzaRepository; + private readonly IRepository _toppingRepository; + private readonly ILoggingService _loggingService; + private readonly IMapper _mapper; + private readonly ILogger _logger; + + public AdminController( + IOrderService orderService, + IPizzaService pizzaService, + IToppingService toppingService, + IRepository orderRepository, + IPizzaRepository pizzaRepository, + IRepository toppingRepository, + ILoggingService loggingService, + IMapper mapper, + ILogger logger) + { + _orderService = orderService; + _pizzaService = pizzaService; + _toppingService = toppingService; + _orderRepository = orderRepository; + _pizzaRepository = pizzaRepository; + _toppingRepository = toppingRepository; + _loggingService = loggingService; + _mapper = mapper; + _logger = logger; + } + + /// + /// Get all orders with optional filtering + /// + [HttpGet("orders")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task>> GetAllOrders( + [FromQuery] string? status = null, + [FromQuery] DateTime? startDate = null, + [FromQuery] DateTime? endDate = null, + [FromQuery] string? customerName = null, + CancellationToken cancellationToken = default) + { + _logger.LogInformation("Admin getting all orders with filters - Status: {Status}, StartDate: {StartDate}, EndDate: {EndDate}, CustomerName: {CustomerName}", + status, startDate, endDate, customerName); + + _ = _loggingService.LogInfoAsync($"Admin retrieved orders list with filters", + $"Status: {status}, StartDate: {startDate}, EndDate: {endDate}, CustomerName: {customerName}"); + + var orders = await _orderRepository.GetAllAsync(cancellationToken); + + // Apply filters + var filteredOrders = orders.AsEnumerable(); + + if (!string.IsNullOrWhiteSpace(status)) + { + if (Enum.TryParse(status, true, out var orderStatus)) + { + filteredOrders = filteredOrders.Where(o => o.Status == orderStatus); + } + } + + if (startDate.HasValue) + { + filteredOrders = filteredOrders.Where(o => o.CreatedAt >= startDate.Value); + } + + if (endDate.HasValue) + { + filteredOrders = filteredOrders.Where(o => o.CreatedAt <= endDate.Value); + } + + if (!string.IsNullOrWhiteSpace(customerName)) + { + filteredOrders = filteredOrders.Where(o => + o.User.Name.Contains(customerName, StringComparison.OrdinalIgnoreCase)); + } + + var orderList = filteredOrders + .OrderByDescending(o => o.CreatedAt) + .Select(o => _mapper.Map(o)) + .ToList(); + + return Ok(orderList); + } + + /// + /// Get order details by ID + /// + [HttpGet("orders/{id}")] + [ProducesResponseType(typeof(OrderDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> GetOrderById(Guid id, CancellationToken cancellationToken) + { + _logger.LogInformation("Admin getting order details for ID: {OrderId}", id); + + _ = _loggingService.LogInfoAsync($"Admin retrieved order details", $"OrderId: {id}"); + + var order = await _orderService.GetOrderByIdAsync(id, cancellationToken); + + if (order == null) + { + return NotFound(new { error = $"Order with ID {id} not found" }); + } + + return Ok(order); + } + + /// + /// Update order status + /// + [HttpPut("orders/{id}/status")] + [ProducesResponseType(typeof(OrderDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> UpdateOrderStatus( + Guid id, + [FromBody] UpdateOrderStatusDto updateDto, + CancellationToken cancellationToken) + { + + _logger.LogInformation("Admin updating order {OrderId} status to: {Status}", id, updateDto.Status); + + _ = _loggingService.LogInfoAsync($"Admin updated order status", + $"OrderId: {id}, NewStatus: {updateDto.Status}"); + + var order = await _orderService.UpdateOrderStatusAsync(id, updateDto.Status, cancellationToken); + return Ok(order); + } + + /// + /// Get all pizzas including soft-deleted ones + /// + [HttpGet("pizzas")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task>> GetAllPizzas(CancellationToken cancellationToken) + { + + _logger.LogInformation("Admin getting all pizzas including soft-deleted"); + + _ = _loggingService.LogInfoAsync("Admin retrieved all pizzas including soft-deleted"); + + var pizzas = await _pizzaRepository.GetAllAsync(cancellationToken); + var pizzaList = pizzas.Select(p => + { + var dto = _mapper.Map(p); + dto.ComputedCost = _pizzaService.ComputePizzaCost(p); + return dto; + }).ToList(); + + return Ok(pizzaList); + } + + /// + /// Create a new pizza + /// + [HttpPost("pizzas")] + [ProducesResponseType(typeof(PizzaDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> CreatePizza( + [FromBody] CreatePizzaDto createDto, + CancellationToken cancellationToken) + { + _logger.LogInformation("Admin creating new pizza: {Name}", createDto.Name); + + _ = _loggingService.LogInfoAsync($"Admin created new pizza", $"PizzaName: {createDto.Name}"); + + var pizza = await _pizzaService.CreateAsync(createDto, cancellationToken); + return CreatedAtAction(nameof(GetOrderById), new { id = pizza.Id }, pizza); + } + + /// + /// Update a pizza + /// + [HttpPut("pizzas/{id}")] + [ProducesResponseType(typeof(PizzaDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> UpdatePizza( + Guid id, + [FromBody] UpdatePizzaDto updateDto, + CancellationToken cancellationToken) + { + + + _logger.LogInformation("Admin updating pizza with ID: {Id}", id); + + _ = _loggingService.LogInfoAsync($"Admin updated pizza", $"PizzaId: {id}, PizzaName: {updateDto.Name}"); + + var pizza = await _pizzaService.UpdateAsync(id, updateDto, cancellationToken); + + if (pizza == null) + { + return NotFound(new { error = $"Pizza with ID {id} not found" }); + } + + return Ok(pizza); + } + + /// + /// Soft delete a pizza + /// + [HttpDelete("pizzas/{id}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task DeletePizza(Guid id, CancellationToken cancellationToken) + { + _logger.LogInformation("Admin soft deleting pizza with ID: {Id}", id); + + _ = _loggingService.LogInfoAsync($"Admin soft deleted pizza", $"PizzaId: {id}"); + + var result = await _pizzaService.DeleteAsync(id, cancellationToken); + + if (!result) + { + return NotFound(new { error = $"Pizza with ID {id} not found" }); + } + + return NoContent(); + } + + /// + /// Get all toppings including soft-deleted ones + /// + [HttpGet("toppings")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task>> GetAllToppings(CancellationToken cancellationToken) + { + + _logger.LogInformation("Admin getting all toppings including soft-deleted"); + + _ = _loggingService.LogInfoAsync("Admin retrieved all toppings including soft-deleted"); + + var toppings = await _toppingRepository.GetAllAsync(cancellationToken); + var toppingList = toppings.Select(t => _mapper.Map(t)).ToList(); + + return Ok(toppingList); + } + + /// + /// Create a new topping + /// + [HttpPost("toppings")] + [ProducesResponseType(typeof(ToppingDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> CreateTopping( + [FromBody] CreateToppingDto createDto, + CancellationToken cancellationToken) + { + + _logger.LogInformation("Admin creating new topping: {Name}", createDto.Name); + + _ = _loggingService.LogInfoAsync($"Admin created new topping", $"ToppingName: {createDto.Name}"); + + var topping = await _toppingService.CreateAsync(createDto, cancellationToken); + return CreatedAtAction(nameof(GetAllToppings), new { id = topping.Id }, topping); + } + + /// + /// Update a topping + /// + [HttpPut("toppings/{id}")] + [ProducesResponseType(typeof(ToppingDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> UpdateTopping( + Guid id, + [FromBody] UpdateToppingDto updateDto, + CancellationToken cancellationToken) + { + + _logger.LogInformation("Admin updating topping with ID: {Id}", id); + + _ = _loggingService.LogInfoAsync($"Admin updated topping", $"ToppingId: {id}, ToppingName: {updateDto.Name}"); + + var topping = await _toppingService.UpdateAsync(id, updateDto, cancellationToken); + + if (topping == null) + { + return NotFound(new { error = $"Topping with ID {id} not found" }); + } + + return Ok(topping); + } + + /// + /// Soft delete a topping + /// + [HttpDelete("toppings/{id}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task DeleteTopping(Guid id, CancellationToken cancellationToken) + { + _logger.LogInformation("Admin soft deleting topping with ID: {Id}", id); + + _ = _loggingService.LogInfoAsync($"Admin soft deleted topping", $"ToppingId: {id}"); + + var result = await _toppingService.DeleteAsync(id, cancellationToken); + + if (!result) + { + return NotFound(new { error = $"Topping with ID {id} not found" }); + } + + return NoContent(); + } +} \ No newline at end of file diff --git a/CSharpPizza.Api/Middleware/RequestLoggingMiddleware.cs b/CSharpPizza.Api/Middleware/RequestLoggingMiddleware.cs new file mode 100644 index 0000000..5bfc984 --- /dev/null +++ b/CSharpPizza.Api/Middleware/RequestLoggingMiddleware.cs @@ -0,0 +1,67 @@ +using System.Security.Claims; +using CSharpPizza.Domain.Services; + +namespace CSharpPizza.Api.Middleware; + +public class RequestLoggingMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public RequestLoggingMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context, ILoggingService loggingService) + { + // TODO: Should implement request/response body logging for debugging + // TODO: Should filter sensitive data (passwords, tokens) from logs + // GOTCHA: Logging happens synchronously in the request pipeline - could impact performance + + var method = context.Request.Method; + var path = context.Request.Path; + var timestamp = DateTime.UtcNow; + + // Get user information if authenticated + var userIdClaim = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + int? userId = null; + if (!string.IsNullOrEmpty(userIdClaim) && int.TryParse(userIdClaim, out var parsedUserId)) + { + userId = parsedUserId; + } + + _logger.LogInformation("Incoming request: {Method} {Path} at {Timestamp} by User: {UserId}", + method, path, timestamp, userId?.ToString() ?? "Anonymous"); + + // Fire-and-forget logging (don't await) + _ = loggingService.LogAsync( + level: "Info", + message: $"API Request: {method} {path}", + details: $"Timestamp: {timestamp}, User: {userId?.ToString() ?? "Anonymous"}", + userId: userId, + endpoint: path, + httpMethod: method, + statusCode: null); + + // Call the next middleware in the pipeline + await _next(context); + + // Log response status code + var statusCode = context.Response.StatusCode; + + _logger.LogInformation("Response: {Method} {Path} returned {StatusCode}", + method, path, statusCode); + + // Fire-and-forget logging (don't await) + _ = loggingService.LogAsync( + level: statusCode >= 400 ? "Error" : "Info", + message: $"API Response: {method} {path}", + details: $"StatusCode: {statusCode}, User: {userId?.ToString() ?? "Anonymous"}", + userId: userId, + endpoint: path, + httpMethod: method, + statusCode: statusCode); + } +} \ No newline at end of file diff --git a/CSharpPizza.Api/Program.cs b/CSharpPizza.Api/Program.cs index 7ac25dd..c5c67cc 100644 --- a/CSharpPizza.Api/Program.cs +++ b/CSharpPizza.Api/Program.cs @@ -29,6 +29,7 @@ builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // Register services builder.Services.AddScoped(); @@ -38,6 +39,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // Configure AutoMapper builder.Services.AddAutoMapper(typeof(CSharpPizza.DTO.Mappings.UserProfile).Assembly); @@ -167,6 +169,9 @@ app.UseAuthentication(); app.UseAuthorization(); +// Add request logging middleware +app.UseMiddleware(); + app.MapControllers(); app.Run(); diff --git a/CSharpPizza.Api/obj/Debug/net9.0/CSharpPizza.Api.AssemblyInfo.cs b/CSharpPizza.Api/obj/Debug/net9.0/CSharpPizza.Api.AssemblyInfo.cs index 2443d27..409186d 100644 --- a/CSharpPizza.Api/obj/Debug/net9.0/CSharpPizza.Api.AssemblyInfo.cs +++ b/CSharpPizza.Api/obj/Debug/net9.0/CSharpPizza.Api.AssemblyInfo.cs @@ -13,7 +13,7 @@ [assembly: System.Reflection.AssemblyCompanyAttribute("CSharpPizza.Api")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] -[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+0f2ea68a7cfc1ee055cda29bd334562793872a4a")] +[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+e85cdd0cef1401d6cefb49ea1692034a365a4042")] [assembly: System.Reflection.AssemblyProductAttribute("CSharpPizza.Api")] [assembly: System.Reflection.AssemblyTitleAttribute("CSharpPizza.Api")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] diff --git a/CSharpPizza.Api/obj/Debug/net9.0/rjsmcshtml.dswa.cache.json b/CSharpPizza.Api/obj/Debug/net9.0/rjsmcshtml.dswa.cache.json index 32769f1..1ab9b5c 100644 --- a/CSharpPizza.Api/obj/Debug/net9.0/rjsmcshtml.dswa.cache.json +++ b/CSharpPizza.Api/obj/Debug/net9.0/rjsmcshtml.dswa.cache.json @@ -1 +1 @@ -{"GlobalPropertiesHash":"PHw3jjWBKF81DYsc5h3FDTLACQkzWq+Pt27M+7R9tsM=","FingerprintPatternsHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["IznhvaV3J7lgPbEA24dFaPloqFkuxyp5sS49\u002B1nKOy0=","CGU2MilnF\u002BUW\u002BORoSUPiIu6dPYbncMmzTBMiEiEYfDM=","x0yqGwiR2g4/a8lR/v1hTKeyfEDLEk120yr0waLgryM=","LyawzLxWkJQyqWyW8YB4ADC3ap7TvoM91NuTKUy\u002BlfU=","JFLNhlMToFtpox74Wvkp3UX2jJwsOmUloxTUP/h2oYo=","b92C2jNZPTq4Pg9wrL44ZrQzim6dKV1dJkWtYafeLB0=","haN5onKbwHdoFLraurYDOSkVx61fv/n0rX/jUdY6dTk=","AmCgmiKclltFCynpVNrCEV4kBPF1wFvibNRfg5OENUU="],"CachedAssets":{},"CachedCopyCandidates":{}} \ No newline at end of file +{"GlobalPropertiesHash":"PHw3jjWBKF81DYsc5h3FDTLACQkzWq+Pt27M+7R9tsM=","FingerprintPatternsHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["IznhvaV3J7lgPbEA24dFaPloqFkuxyp5sS49\u002B1nKOy0=","CGU2MilnF\u002BUW\u002BORoSUPiIu6dPYbncMmzTBMiEiEYfDM=","jwjk8B\u002B2\u002BRvjpfPCgdnUnDFsQfUhyuu8syH7sdueeFw=","b92C2jNZPTq4Pg9wrL44ZrQzim6dKV1dJkWtYafeLB0=","haN5onKbwHdoFLraurYDOSkVx61fv/n0rX/jUdY6dTk=","NSZFYoxhthD0FsplXG/wMDFjf9sb8pTm/vmv//4ZnNg="],"CachedAssets":{},"CachedCopyCandidates":{}} \ No newline at end of file diff --git a/CSharpPizza.Api/obj/Debug/net9.0/rjsmrazor.dswa.cache.json b/CSharpPizza.Api/obj/Debug/net9.0/rjsmrazor.dswa.cache.json index 23e408a..1a112fe 100644 --- a/CSharpPizza.Api/obj/Debug/net9.0/rjsmrazor.dswa.cache.json +++ b/CSharpPizza.Api/obj/Debug/net9.0/rjsmrazor.dswa.cache.json @@ -1 +1 @@ -{"GlobalPropertiesHash":"ZkcXimgRQaglrQlljonUGg6P/DX4dXgrZ3s4jzN+bP0=","FingerprintPatternsHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["IznhvaV3J7lgPbEA24dFaPloqFkuxyp5sS49\u002B1nKOy0=","CGU2MilnF\u002BUW\u002BORoSUPiIu6dPYbncMmzTBMiEiEYfDM=","x0yqGwiR2g4/a8lR/v1hTKeyfEDLEk120yr0waLgryM=","LyawzLxWkJQyqWyW8YB4ADC3ap7TvoM91NuTKUy\u002BlfU=","JFLNhlMToFtpox74Wvkp3UX2jJwsOmUloxTUP/h2oYo=","b92C2jNZPTq4Pg9wrL44ZrQzim6dKV1dJkWtYafeLB0=","haN5onKbwHdoFLraurYDOSkVx61fv/n0rX/jUdY6dTk=","AmCgmiKclltFCynpVNrCEV4kBPF1wFvibNRfg5OENUU="],"CachedAssets":{},"CachedCopyCandidates":{}} \ No newline at end of file +{"GlobalPropertiesHash":"ZkcXimgRQaglrQlljonUGg6P/DX4dXgrZ3s4jzN+bP0=","FingerprintPatternsHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["IznhvaV3J7lgPbEA24dFaPloqFkuxyp5sS49\u002B1nKOy0=","CGU2MilnF\u002BUW\u002BORoSUPiIu6dPYbncMmzTBMiEiEYfDM=","jwjk8B\u002B2\u002BRvjpfPCgdnUnDFsQfUhyuu8syH7sdueeFw=","b92C2jNZPTq4Pg9wrL44ZrQzim6dKV1dJkWtYafeLB0=","haN5onKbwHdoFLraurYDOSkVx61fv/n0rX/jUdY6dTk=","NSZFYoxhthD0FsplXG/wMDFjf9sb8pTm/vmv//4ZnNg="],"CachedAssets":{},"CachedCopyCandidates":{}} \ No newline at end of file diff --git a/CSharpPizza.DTO/Admin/AdminOrderFilterDto.cs b/CSharpPizza.DTO/Admin/AdminOrderFilterDto.cs new file mode 100644 index 0000000..7e03243 --- /dev/null +++ b/CSharpPizza.DTO/Admin/AdminOrderFilterDto.cs @@ -0,0 +1,9 @@ +namespace CSharpPizza.DTO.Admin; + +public class AdminOrderFilterDto +{ + public string? Status { get; set; } + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } + public string? CustomerName { get; set; } +} \ No newline at end of file diff --git a/CSharpPizza.DTO/Admin/AdminOrderListDto.cs b/CSharpPizza.DTO/Admin/AdminOrderListDto.cs new file mode 100644 index 0000000..ae49fd6 --- /dev/null +++ b/CSharpPizza.DTO/Admin/AdminOrderListDto.cs @@ -0,0 +1,13 @@ +namespace CSharpPizza.DTO.Admin; + +public class AdminOrderListDto +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public string CustomerName { get; set; } = string.Empty; + public string CustomerEmail { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public decimal TotalAmount { get; set; } + public int ItemCount { get; set; } + public DateTime CreatedAt { get; set; } +} \ No newline at end of file diff --git a/CSharpPizza.DTO/Mappings/AdminProfile.cs b/CSharpPizza.DTO/Mappings/AdminProfile.cs new file mode 100644 index 0000000..7b9a739 --- /dev/null +++ b/CSharpPizza.DTO/Mappings/AdminProfile.cs @@ -0,0 +1,18 @@ +using AutoMapper; +using CSharpPizza.Data.Entities; +using CSharpPizza.DTO.Admin; + +namespace CSharpPizza.DTO.Mappings; + +public class AdminProfile : Profile +{ + public AdminProfile() + { + // Order -> AdminOrderListDto + CreateMap() + .ForMember(dest => dest.Status, opt => opt.MapFrom(src => src.Status.ToString())) + .ForMember(dest => dest.ItemCount, opt => opt.MapFrom(src => src.OrderItems.Count)) + .ForMember(dest => dest.CustomerName, opt => opt.MapFrom(src => src.User.Name)) + .ForMember(dest => dest.CustomerEmail, opt => opt.MapFrom(src => src.User.Email)); + } +} \ No newline at end of file diff --git a/CSharpPizza.DTO/obj/Debug/net9.0/CSharpPizza.DTO.AssemblyInfo.cs b/CSharpPizza.DTO/obj/Debug/net9.0/CSharpPizza.DTO.AssemblyInfo.cs index 3219431..86db9f6 100644 --- a/CSharpPizza.DTO/obj/Debug/net9.0/CSharpPizza.DTO.AssemblyInfo.cs +++ b/CSharpPizza.DTO/obj/Debug/net9.0/CSharpPizza.DTO.AssemblyInfo.cs @@ -13,7 +13,7 @@ [assembly: System.Reflection.AssemblyCompanyAttribute("CSharpPizza.DTO")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] -[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+0f2ea68a7cfc1ee055cda29bd334562793872a4a")] +[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+e85cdd0cef1401d6cefb49ea1692034a365a4042")] [assembly: System.Reflection.AssemblyProductAttribute("CSharpPizza.DTO")] [assembly: System.Reflection.AssemblyTitleAttribute("CSharpPizza.DTO")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] diff --git a/CSharpPizza.Data/Entities/Log.cs b/CSharpPizza.Data/Entities/Log.cs new file mode 100644 index 0000000..32abeed --- /dev/null +++ b/CSharpPizza.Data/Entities/Log.cs @@ -0,0 +1,12 @@ +namespace CSharpPizza.Data.Entities; + +public class Log : BaseEntity +{ + public string LogLevel { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public string? Details { get; set; } + public int? UserId { get; set; } + public string? Endpoint { get; set; } + public string? HttpMethod { get; set; } + public int? StatusCode { get; set; } +} \ No newline at end of file diff --git a/CSharpPizza.Data/Migrations/20251113045133_AddLogEntity.Designer.cs b/CSharpPizza.Data/Migrations/20251113045133_AddLogEntity.Designer.cs new file mode 100644 index 0000000..6c90183 --- /dev/null +++ b/CSharpPizza.Data/Migrations/20251113045133_AddLogEntity.Designer.cs @@ -0,0 +1,474 @@ +ο»Ώ// +using System; +using CSharpPizza.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CSharpPizza.Data.Migrations +{ + [DbContext(typeof(PizzaDbContext))] + [Migration("20251113045133_AddLogEntity")] + partial class AddLogEntity + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + + modelBuilder.Entity("CSharpPizza.Data.Entities.Cart", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Carts"); + }); + + modelBuilder.Entity("CSharpPizza.Data.Entities.CartItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CartId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("PizzaId") + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CartId"); + + b.HasIndex("PizzaId"); + + b.ToTable("CartItems"); + }); + + modelBuilder.Entity("CSharpPizza.Data.Entities.CartItemTopping", b => + { + b.Property("CartItemId") + .HasColumnType("TEXT"); + + b.Property("ToppingId") + .HasColumnType("TEXT"); + + b.Property("IsAdded") + .HasColumnType("INTEGER"); + + b.HasKey("CartItemId", "ToppingId"); + + b.HasIndex("ToppingId"); + + b.ToTable("CartItemToppings"); + }); + + modelBuilder.Entity("CSharpPizza.Data.Entities.Log", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Endpoint") + .HasColumnType("TEXT"); + + b.Property("HttpMethod") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("LogLevel") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Message") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StatusCode") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Logs"); + }); + + modelBuilder.Entity("CSharpPizza.Data.Entities.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TotalAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Orders"); + }); + + modelBuilder.Entity("CSharpPizza.Data.Entities.OrderItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("OrderId") + .HasColumnType("TEXT"); + + b.Property("PizzaName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PizzaPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.Property("Toppings") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.ToTable("OrderItems"); + }); + + modelBuilder.Entity("CSharpPizza.Data.Entities.Pizza", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BasePrice") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ImageUrl") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Pizzas"); + }); + + modelBuilder.Entity("CSharpPizza.Data.Entities.PizzaTopping", b => + { + b.Property("PizzaId") + .HasColumnType("TEXT"); + + b.Property("ToppingId") + .HasColumnType("TEXT"); + + b.HasKey("PizzaId", "ToppingId"); + + b.HasIndex("ToppingId"); + + b.ToTable("PizzaToppings"); + }); + + modelBuilder.Entity("CSharpPizza.Data.Entities.Topping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Cost") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Toppings"); + }); + + modelBuilder.Entity("CSharpPizza.Data.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Address") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("Mobile") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserRole") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("CSharpPizza.Data.Entities.Cart", b => + { + b.HasOne("CSharpPizza.Data.Entities.User", "User") + .WithOne("Cart") + .HasForeignKey("CSharpPizza.Data.Entities.Cart", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CSharpPizza.Data.Entities.CartItem", b => + { + b.HasOne("CSharpPizza.Data.Entities.Cart", "Cart") + .WithMany("CartItems") + .HasForeignKey("CartId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CSharpPizza.Data.Entities.Pizza", "Pizza") + .WithMany("CartItems") + .HasForeignKey("PizzaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cart"); + + b.Navigation("Pizza"); + }); + + modelBuilder.Entity("CSharpPizza.Data.Entities.CartItemTopping", b => + { + b.HasOne("CSharpPizza.Data.Entities.CartItem", "CartItem") + .WithMany("CartItemToppings") + .HasForeignKey("CartItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CSharpPizza.Data.Entities.Topping", "Topping") + .WithMany("CartItemToppings") + .HasForeignKey("ToppingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CartItem"); + + b.Navigation("Topping"); + }); + + modelBuilder.Entity("CSharpPizza.Data.Entities.Order", b => + { + b.HasOne("CSharpPizza.Data.Entities.User", "User") + .WithMany("Orders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CSharpPizza.Data.Entities.OrderItem", b => + { + b.HasOne("CSharpPizza.Data.Entities.Order", "Order") + .WithMany("OrderItems") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("CSharpPizza.Data.Entities.PizzaTopping", b => + { + b.HasOne("CSharpPizza.Data.Entities.Pizza", "Pizza") + .WithMany("PizzaToppings") + .HasForeignKey("PizzaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CSharpPizza.Data.Entities.Topping", "Topping") + .WithMany("PizzaToppings") + .HasForeignKey("ToppingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Pizza"); + + b.Navigation("Topping"); + }); + + modelBuilder.Entity("CSharpPizza.Data.Entities.Cart", b => + { + b.Navigation("CartItems"); + }); + + modelBuilder.Entity("CSharpPizza.Data.Entities.CartItem", b => + { + b.Navigation("CartItemToppings"); + }); + + modelBuilder.Entity("CSharpPizza.Data.Entities.Order", b => + { + b.Navigation("OrderItems"); + }); + + modelBuilder.Entity("CSharpPizza.Data.Entities.Pizza", b => + { + b.Navigation("CartItems"); + + b.Navigation("PizzaToppings"); + }); + + modelBuilder.Entity("CSharpPizza.Data.Entities.Topping", b => + { + b.Navigation("CartItemToppings"); + + b.Navigation("PizzaToppings"); + }); + + modelBuilder.Entity("CSharpPizza.Data.Entities.User", b => + { + b.Navigation("Cart"); + + b.Navigation("Orders"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/CSharpPizza.Data/Migrations/20251113045133_AddLogEntity.cs b/CSharpPizza.Data/Migrations/20251113045133_AddLogEntity.cs new file mode 100644 index 0000000..fed39c9 --- /dev/null +++ b/CSharpPizza.Data/Migrations/20251113045133_AddLogEntity.cs @@ -0,0 +1,43 @@ +ο»Ώusing System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CSharpPizza.Data.Migrations +{ + /// + public partial class AddLogEntity : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Logs", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + LogLevel = table.Column(type: "TEXT", nullable: false), + Message = table.Column(type: "TEXT", nullable: false), + Details = table.Column(type: "TEXT", nullable: true), + UserId = table.Column(type: "INTEGER", nullable: true), + Endpoint = table.Column(type: "TEXT", nullable: true), + HttpMethod = table.Column(type: "TEXT", nullable: true), + StatusCode = table.Column(type: "INTEGER", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false), + IsDeleted = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Logs", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Logs"); + } + } +} diff --git a/CSharpPizza.Data/Migrations/PizzaDbContextModelSnapshot.cs b/CSharpPizza.Data/Migrations/PizzaDbContextModelSnapshot.cs index d002c49..1519ba6 100644 --- a/CSharpPizza.Data/Migrations/PizzaDbContextModelSnapshot.cs +++ b/CSharpPizza.Data/Migrations/PizzaDbContextModelSnapshot.cs @@ -94,6 +94,49 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("CartItemToppings"); }); + modelBuilder.Entity("CSharpPizza.Data.Entities.Log", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Endpoint") + .HasColumnType("TEXT"); + + b.Property("HttpMethod") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("LogLevel") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Message") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StatusCode") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Logs"); + }); + modelBuilder.Entity("CSharpPizza.Data.Entities.Order", b => { b.Property("Id") diff --git a/CSharpPizza.Data/PizzaDbContext.cs b/CSharpPizza.Data/PizzaDbContext.cs index 61b1c8a..de4f3b0 100644 --- a/CSharpPizza.Data/PizzaDbContext.cs +++ b/CSharpPizza.Data/PizzaDbContext.cs @@ -18,6 +18,7 @@ public PizzaDbContext(DbContextOptions options) : base(options) public DbSet CartItemToppings => Set(); public DbSet Orders => Set(); public DbSet OrderItems => Set(); + public DbSet Logs => Set(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { @@ -39,6 +40,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity().HasQueryFilter(ci => !ci.IsDeleted); modelBuilder.Entity().HasQueryFilter(o => !o.IsDeleted); modelBuilder.Entity().HasQueryFilter(oi => !oi.IsDeleted); + modelBuilder.Entity().HasQueryFilter(l => !l.IsDeleted); // User configuration modelBuilder.Entity(entity => @@ -162,6 +164,14 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasForeignKey(oi => oi.OrderId) .OnDelete(DeleteBehavior.Cascade); }); + + // Log configuration + modelBuilder.Entity(entity => + { + entity.HasKey(l => l.Id); + entity.Property(l => l.LogLevel).IsRequired(); + entity.Property(l => l.Message).IsRequired(); + }); } public override int SaveChanges() diff --git a/CSharpPizza.Data/Repositories/ILogRepository.cs b/CSharpPizza.Data/Repositories/ILogRepository.cs new file mode 100644 index 0000000..8becf5d --- /dev/null +++ b/CSharpPizza.Data/Repositories/ILogRepository.cs @@ -0,0 +1,7 @@ +using CSharpPizza.Data.Entities; + +namespace CSharpPizza.Data.Repositories; + +public interface ILogRepository : IRepository +{ +} \ No newline at end of file diff --git a/CSharpPizza.Data/Repositories/LogRepository.cs b/CSharpPizza.Data/Repositories/LogRepository.cs new file mode 100644 index 0000000..8a827cd --- /dev/null +++ b/CSharpPizza.Data/Repositories/LogRepository.cs @@ -0,0 +1,10 @@ +using CSharpPizza.Data.Entities; + +namespace CSharpPizza.Data.Repositories; + +public class LogRepository : Repository, ILogRepository +{ + public LogRepository(PizzaDbContext context) : base(context) + { + } +} \ No newline at end of file diff --git a/CSharpPizza.Data/obj/Debug/net9.0/CSharpPizza.Data.AssemblyInfo.cs b/CSharpPizza.Data/obj/Debug/net9.0/CSharpPizza.Data.AssemblyInfo.cs index 021b67a..fd55eb6 100644 --- a/CSharpPizza.Data/obj/Debug/net9.0/CSharpPizza.Data.AssemblyInfo.cs +++ b/CSharpPizza.Data/obj/Debug/net9.0/CSharpPizza.Data.AssemblyInfo.cs @@ -13,7 +13,7 @@ [assembly: System.Reflection.AssemblyCompanyAttribute("CSharpPizza.Data")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] -[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+0f2ea68a7cfc1ee055cda29bd334562793872a4a")] +[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+e85cdd0cef1401d6cefb49ea1692034a365a4042")] [assembly: System.Reflection.AssemblyProductAttribute("CSharpPizza.Data")] [assembly: System.Reflection.AssemblyTitleAttribute("CSharpPizza.Data")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] diff --git a/CSharpPizza.Domain/Extensions/StringExtensions.cs b/CSharpPizza.Domain/Extensions/StringExtensions.cs new file mode 100644 index 0000000..01e8436 --- /dev/null +++ b/CSharpPizza.Domain/Extensions/StringExtensions.cs @@ -0,0 +1,10 @@ +namespace CSharpPizza.Domain.Extensions; + +public static class StringExtensions +{ + public static async Task FetchDataAsync(this string url) + { + using var client = new HttpClient(); + return await client.GetStringAsync(url); + } +} \ No newline at end of file diff --git a/CSharpPizza.Domain/Services/ILoggingService.cs b/CSharpPizza.Domain/Services/ILoggingService.cs new file mode 100644 index 0000000..116a1a3 --- /dev/null +++ b/CSharpPizza.Domain/Services/ILoggingService.cs @@ -0,0 +1,8 @@ +namespace CSharpPizza.Domain.Services; + +public interface ILoggingService +{ + Task LogAsync(string level, string message, string? details = null, int? userId = null, string? endpoint = null, string? httpMethod = null, int? statusCode = null); + Task LogInfoAsync(string message, string? details = null); + Task LogErrorAsync(string message, string? details = null); +} \ No newline at end of file diff --git a/CSharpPizza.Domain/Services/LoggingService.cs b/CSharpPizza.Domain/Services/LoggingService.cs new file mode 100644 index 0000000..6f4ac90 --- /dev/null +++ b/CSharpPizza.Domain/Services/LoggingService.cs @@ -0,0 +1,47 @@ +using CSharpPizza.Data.Entities; +using CSharpPizza.Data.Repositories; + +namespace CSharpPizza.Domain.Services; + +public class LoggingService : ILoggingService +{ + private readonly ILogRepository _logRepository; + + public LoggingService(ILogRepository logRepository) + { + _logRepository = logRepository; + } + + public Task LogAsync(string level, string message, string? details = null, int? userId = null, string? endpoint = null, string? httpMethod = null, int? statusCode = null) + { + Task.Run(async () => + { + var log = new Log + { + Id = Guid.NewGuid(), + LogLevel = level, + Message = message, + Details = details, + UserId = userId, + Endpoint = endpoint, + HttpMethod = httpMethod, + StatusCode = statusCode + }; + + await _logRepository.AddAsync(log); + await _logRepository.SaveChangesAsync(); + }); + + return Task.CompletedTask; + } + + public Task LogInfoAsync(string message, string? details = null) + { + return LogAsync("Info", message, details); + } + + public Task LogErrorAsync(string message, string? details = null) + { + return LogAsync("Error", message, details); + } +} \ No newline at end of file diff --git a/CSharpPizza.Domain/Services/PizzaService.cs b/CSharpPizza.Domain/Services/PizzaService.cs index 0d9545c..0e0df0e 100644 --- a/CSharpPizza.Domain/Services/PizzaService.cs +++ b/CSharpPizza.Domain/Services/PizzaService.cs @@ -1,6 +1,7 @@ using AutoMapper; using CSharpPizza.Data.Entities; using CSharpPizza.Data.Repositories; +using CSharpPizza.Domain.Extensions; using CSharpPizza.DTO.Pizzas; namespace CSharpPizza.Domain.Services; @@ -61,11 +62,18 @@ public async Task> GetAllAsync(CancellationToken cancellation public async Task CreateAsync(CreatePizzaDto createDto, CancellationToken cancellationToken = default) { + + var description = createDto.Description; + if (string.IsNullOrWhiteSpace(description)) + { + description = await "https://lorem-api.com/api/lorem".FetchDataAsync(); + } + // Create pizza entity var pizza = new Pizza { Name = createDto.Name, - Description = createDto.Description, + Description = description, BasePrice = createDto.BasePrice, ImageUrl = createDto.ImageUrl, Slug = GenerateSlug(createDto.Name) @@ -104,9 +112,15 @@ public async Task UpdateAsync(Guid id, UpdatePizzaDto updateDto, Cance throw new KeyNotFoundException($"Pizza with ID {id} not found"); } + var description = updateDto.Description; + if (string.IsNullOrWhiteSpace(description)) + { + description = await "https://lorem-api.com/api/lorem".FetchDataAsync(); + } + // Update pizza properties pizza.Name = updateDto.Name; - pizza.Description = updateDto.Description; + pizza.Description = description; pizza.BasePrice = updateDto.BasePrice; pizza.ImageUrl = updateDto.ImageUrl; pizza.Slug = GenerateSlug(updateDto.Name); diff --git a/CSharpPizza.Domain/Services/ToppingService.cs b/CSharpPizza.Domain/Services/ToppingService.cs index 51a9992..4bb5b13 100644 --- a/CSharpPizza.Domain/Services/ToppingService.cs +++ b/CSharpPizza.Domain/Services/ToppingService.cs @@ -1,6 +1,7 @@ using AutoMapper; using CSharpPizza.Data.Entities; using CSharpPizza.Data.Repositories; +using CSharpPizza.Domain.Extensions; using CSharpPizza.DTO.Toppings; namespace CSharpPizza.Domain.Services; @@ -30,10 +31,17 @@ public async Task> GetAllAsync(CancellationToken cancellationTo public async Task CreateAsync(CreateToppingDto createDto, CancellationToken cancellationToken = default) { + + var description = createDto.Description; + if (string.IsNullOrWhiteSpace(description)) + { + description = await "https://lorem-api.com/api/lorem".FetchDataAsync(); + } + var topping = new Topping { Name = createDto.Name, - Description = createDto.Description, + Description = description, Cost = createDto.Cost }; @@ -51,8 +59,14 @@ public async Task UpdateAsync(Guid id, UpdateToppingDto updateDto, C throw new KeyNotFoundException($"Topping with ID {id} not found"); } + var description = updateDto.Description; + if (string.IsNullOrWhiteSpace(description)) + { + description = await "https://lorem-api.com/api/lorem".FetchDataAsync(); + } + topping.Name = updateDto.Name; - topping.Description = updateDto.Description; + topping.Description = description; topping.Cost = updateDto.Cost; await _toppingRepository.UpdateAsync(topping, cancellationToken); diff --git a/CSharpPizza.Domain/obj/Debug/net9.0/CSharpPizza.Domain.AssemblyInfo.cs b/CSharpPizza.Domain/obj/Debug/net9.0/CSharpPizza.Domain.AssemblyInfo.cs index b3cf2d9..499b1db 100644 --- a/CSharpPizza.Domain/obj/Debug/net9.0/CSharpPizza.Domain.AssemblyInfo.cs +++ b/CSharpPizza.Domain/obj/Debug/net9.0/CSharpPizza.Domain.AssemblyInfo.cs @@ -13,7 +13,7 @@ [assembly: System.Reflection.AssemblyCompanyAttribute("CSharpPizza.Domain")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] -[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+0f2ea68a7cfc1ee055cda29bd334562793872a4a")] +[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+e85cdd0cef1401d6cefb49ea1692034a365a4042")] [assembly: System.Reflection.AssemblyProductAttribute("CSharpPizza.Domain")] [assembly: System.Reflection.AssemblyTitleAttribute("CSharpPizza.Domain")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] diff --git a/CSharpPizza.WebUI/src/App.tsx b/CSharpPizza.WebUI/src/App.tsx index 365d061..4dc9f8e 100644 --- a/CSharpPizza.WebUI/src/App.tsx +++ b/CSharpPizza.WebUI/src/App.tsx @@ -4,11 +4,17 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Toaster } from 'react-hot-toast'; import { Navbar } from './components/Navbar'; import { ProtectedRoute } from './components/ProtectedRoute'; +import { AdminProtectedRoute } from './components/AdminProtectedRoute'; +import { AdminLayout } from './components/AdminLayout'; import { HomePage } from './pages/HomePage'; import { PizzaDetailsPage } from './pages/PizzaDetailsPage'; import { CartPage } from './pages/CartPage'; import { LoginPage } from './pages/LoginPage'; import { RegisterPage } from './pages/RegisterPage'; +import { AdminDashboard } from './pages/admin/AdminDashboard'; +import { AdminOrders } from './pages/admin/AdminOrders'; +import { AdminPizzas } from './pages/admin/AdminPizzas'; +import { AdminToppings } from './pages/admin/AdminToppings'; import { useAuthStore } from './stores/authStore'; import { useCartStore } from './stores/cartStore'; import './App.css'; @@ -51,6 +57,21 @@ function App() { /> } /> } /> + + {/* Admin Routes */} + + + + } + > + } /> + } /> + } /> + } /> + diff --git a/CSharpPizza.WebUI/src/api/admin.ts b/CSharpPizza.WebUI/src/api/admin.ts new file mode 100644 index 0000000..43605fe --- /dev/null +++ b/CSharpPizza.WebUI/src/api/admin.ts @@ -0,0 +1,79 @@ +import { apiClient } from './client'; +import type { + AdminOrderFilter, + AdminOrderListDto, + Order, + CreatePizzaDto, + UpdatePizzaDto, + Pizza, + CreateToppingDto, + UpdateToppingDto, + Topping, +} from '../types'; + +export const adminApi = { + // Order management + getOrders: async (filters?: AdminOrderFilter): Promise => { + const params = new URLSearchParams(); + if (filters?.status) params.append('status', filters.status); + if (filters?.startDate) params.append('startDate', filters.startDate); + if (filters?.endDate) params.append('endDate', filters.endDate); + if (filters?.customerName) params.append('customerName', filters.customerName); + + const queryString = params.toString(); + const url = queryString ? `/admin/orders?${queryString}` : '/admin/orders'; + + const response = await apiClient.get(url); + return response.data; + }, + + getOrderById: async (id: number): Promise => { + const response = await apiClient.get(`/admin/orders/${id}`); + return response.data; + }, + + updateOrderStatus: async (id: number, status: string): Promise => { + const response = await apiClient.put(`/admin/orders/${id}/status`, { status }); + return response.data; + }, + + // Pizza management + getAllPizzas: async (): Promise => { + const response = await apiClient.get('/admin/pizzas'); + return response.data; + }, + + createPizza: async (data: CreatePizzaDto): Promise => { + const response = await apiClient.post('/admin/pizzas', data); + return response.data; + }, + + updatePizza: async (id: number, data: UpdatePizzaDto): Promise => { + const response = await apiClient.put(`/admin/pizzas/${id}`, data); + return response.data; + }, + + deletePizza: async (id: number): Promise => { + await apiClient.delete(`/admin/pizzas/${id}`); + }, + + // Topping management + getAllToppings: async (): Promise => { + const response = await apiClient.get('/admin/toppings'); + return response.data; + }, + + createTopping: async (data: CreateToppingDto): Promise => { + const response = await apiClient.post('/admin/toppings', data); + return response.data; + }, + + updateTopping: async (id: number, data: UpdateToppingDto): Promise => { + const response = await apiClient.put(`/admin/toppings/${id}`, data); + return response.data; + }, + + deleteTopping: async (id: number): Promise => { + await apiClient.delete(`/admin/toppings/${id}`); + }, +}; \ No newline at end of file diff --git a/CSharpPizza.WebUI/src/api/index.ts b/CSharpPizza.WebUI/src/api/index.ts index e2ce8a5..dc29c4b 100644 --- a/CSharpPizza.WebUI/src/api/index.ts +++ b/CSharpPizza.WebUI/src/api/index.ts @@ -2,4 +2,5 @@ export { apiClient } from './client'; export { authApi } from './auth'; export { pizzasApi } from './pizzas'; export { cartApi } from './cart'; -export { ordersApi } from './orders'; \ No newline at end of file +export { ordersApi } from './orders'; +export { adminApi } from './admin'; \ No newline at end of file diff --git a/CSharpPizza.WebUI/src/components/AdminLayout.css b/CSharpPizza.WebUI/src/components/AdminLayout.css new file mode 100644 index 0000000..27747fe --- /dev/null +++ b/CSharpPizza.WebUI/src/components/AdminLayout.css @@ -0,0 +1,133 @@ +.admin-layout { + display: flex; + min-height: 100vh; + background-color: #f5f5f5; +} + +.admin-sidebar { + width: 250px; + background-color: #2c3e50; + color: white; + position: fixed; + height: 100vh; + overflow-y: auto; +} + +.admin-sidebar-header { + padding: 20px; + background-color: #1a252f; + border-bottom: 1px solid #34495e; +} + +.admin-sidebar-header h2 { + margin: 0; + font-size: 1.5rem; + color: #ecf0f1; +} + +.admin-nav { + padding: 20px 0; +} + +.admin-nav-link { + display: flex; + align-items: center; + padding: 15px 20px; + color: #ecf0f1; + text-decoration: none; + transition: background-color 0.3s; +} + +.admin-nav-link:hover { + background-color: #34495e; +} + +.admin-nav-link.active { + background-color: #3498db; +} + +.nav-icon { + margin-right: 10px; + font-size: 1.2rem; +} + +.admin-main { + margin-left: 250px; + flex: 1; + display: flex; + flex-direction: column; +} + +.admin-header { + background-color: white; + border-bottom: 1px solid #ddd; + padding: 20px 30px; + position: sticky; + top: 0; + z-index: 100; +} + +.admin-header-content { + display: flex; + justify-content: space-between; + align-items: center; +} + +.admin-header h1 { + margin: 0; + font-size: 1.8rem; + color: #2c3e50; +} + +.admin-user-info { + display: flex; + align-items: center; + gap: 15px; +} + +.user-name { + font-weight: 600; + color: #2c3e50; +} + +.user-role { + padding: 5px 10px; + background-color: #3498db; + color: white; + border-radius: 4px; + font-size: 0.85rem; +} + +.logout-btn { + padding: 8px 16px; + background-color: #e74c3c; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + transition: background-color 0.3s; +} + +.logout-btn:hover { + background-color: #c0392b; +} + +.admin-content { + padding: 30px; + flex: 1; +} + +@media (max-width: 768px) { + .admin-sidebar { + width: 200px; + } + + .admin-main { + margin-left: 200px; + } + + .admin-header h1 { + font-size: 1.4rem; + } +} \ No newline at end of file diff --git a/CSharpPizza.WebUI/src/components/AdminLayout.tsx b/CSharpPizza.WebUI/src/components/AdminLayout.tsx new file mode 100644 index 0000000..e9d1a68 --- /dev/null +++ b/CSharpPizza.WebUI/src/components/AdminLayout.tsx @@ -0,0 +1,60 @@ +import { Link, Outlet, useNavigate } from 'react-router-dom'; +import { useAuthStore } from '../stores/authStore'; +import './AdminLayout.css'; + +// TODO: Should add breadcrumb navigation for better UX + +export const AdminLayout = () => { + const { user, logout } = useAuthStore(); + const navigate = useNavigate(); + + const handleLogout = () => { + logout(); + navigate('/login'); + }; + + return ( +
+ +
+
+
+

Pizza Management System

+
+ {user?.name} + {user?.role} + +
+
+
+
+ +
+
+
+ ); +}; \ No newline at end of file diff --git a/CSharpPizza.WebUI/src/components/AdminProtectedRoute.tsx b/CSharpPizza.WebUI/src/components/AdminProtectedRoute.tsx new file mode 100644 index 0000000..7772bb1 --- /dev/null +++ b/CSharpPizza.WebUI/src/components/AdminProtectedRoute.tsx @@ -0,0 +1,20 @@ +import { Navigate } from 'react-router-dom'; +import { useAuthStore } from '../stores/authStore'; + +interface AdminProtectedRouteProps { + children: React.ReactNode; +} + +export const AdminProtectedRoute = ({ children }: AdminProtectedRouteProps) => { + const { isAuthenticated, user } = useAuthStore(); + + if (!isAuthenticated) { + return ; + } + + if (user?.role !== 'Admin') { + return ; + } + + return <>{children}; +}; \ No newline at end of file diff --git a/CSharpPizza.WebUI/src/components/Navbar.tsx b/CSharpPizza.WebUI/src/components/Navbar.tsx index af7412f..785aadf 100644 --- a/CSharpPizza.WebUI/src/components/Navbar.tsx +++ b/CSharpPizza.WebUI/src/components/Navbar.tsx @@ -34,6 +34,12 @@ export const Navbar = () => { )} + {isAuthenticated && user?.role === 'Admin' && ( + + Admin + + )} +
{isAuthenticated ? (
diff --git a/CSharpPizza.WebUI/src/pages/admin/AdminDashboard.css b/CSharpPizza.WebUI/src/pages/admin/AdminDashboard.css new file mode 100644 index 0000000..794511b --- /dev/null +++ b/CSharpPizza.WebUI/src/pages/admin/AdminDashboard.css @@ -0,0 +1,73 @@ +.admin-dashboard { + max-width: 1200px; +} + +.admin-dashboard h2 { + margin-bottom: 30px; + color: #2c3e50; + font-size: 2rem; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 20px; + margin-top: 20px; +} + +.stat-card { + background: white; + border-radius: 8px; + padding: 25px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + display: flex; + align-items: center; + gap: 20px; + transition: transform 0.3s, box-shadow 0.3s; +} + +.stat-card:hover { + transform: translateY(-5px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); +} + +.stat-card.pending { + border-left: 4px solid #f39c12; +} + +.stat-icon { + font-size: 3rem; + opacity: 0.8; +} + +.stat-content h3 { + margin: 0 0 10px 0; + color: #7f8c8d; + font-size: 0.9rem; + font-weight: 500; + text-transform: uppercase; +} + +.stat-value { + margin: 0; + font-size: 2.5rem; + font-weight: 700; + color: #2c3e50; +} + +.loading { + text-align: center; + padding: 40px; + color: #7f8c8d; + font-size: 1.1rem; +} + +@media (max-width: 768px) { + .stats-grid { + grid-template-columns: 1fr; + } + + .stat-value { + font-size: 2rem; + } +} \ No newline at end of file diff --git a/CSharpPizza.WebUI/src/pages/admin/AdminDashboard.tsx b/CSharpPizza.WebUI/src/pages/admin/AdminDashboard.tsx new file mode 100644 index 0000000..43ccf95 --- /dev/null +++ b/CSharpPizza.WebUI/src/pages/admin/AdminDashboard.tsx @@ -0,0 +1,75 @@ +import { useQuery } from '@tanstack/react-query'; +import { adminApi } from '../../api'; +import './AdminDashboard.css'; + +// TODO: Should add charts and graphs for better visualization +// GOTCHA: Multiple API calls on mount - no data aggregation endpoint + +export const AdminDashboard = () => { + const { data: orders, isLoading: ordersLoading } = useQuery({ + queryKey: ['admin-orders'], + queryFn: () => adminApi.getOrders(), + }); + + const { data: pizzas, isLoading: pizzasLoading } = useQuery({ + queryKey: ['admin-pizzas'], + queryFn: () => adminApi.getAllPizzas(), + }); + + const { data: toppings, isLoading: toppingsLoading } = useQuery({ + queryKey: ['admin-toppings'], + queryFn: () => adminApi.getAllToppings(), + }); + + const isLoading = ordersLoading || pizzasLoading || toppingsLoading; + + const totalOrders = orders?.length || 0; + const pendingOrders = orders?.filter(o => o.status === 'Pending').length || 0; + const totalPizzas = pizzas?.length || 0; + const totalToppings = toppings?.length || 0; + + if (isLoading) { + return ( +
+

Dashboard

+
Loading statistics...
+
+ ); + } + + return ( +
+

Dashboard

+
+
+
πŸ“¦
+
+

Total Orders

+

{totalOrders}

+
+
+
+
⏳
+
+

Pending Orders

+

{pendingOrders}

+
+
+
+
πŸ•
+
+

Total Pizzas

+

{totalPizzas}

+
+
+
+
πŸ§€
+
+

Total Toppings

+

{totalToppings}

+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/CSharpPizza.WebUI/src/pages/admin/AdminOrders.css b/CSharpPizza.WebUI/src/pages/admin/AdminOrders.css new file mode 100644 index 0000000..6f5b9f0 --- /dev/null +++ b/CSharpPizza.WebUI/src/pages/admin/AdminOrders.css @@ -0,0 +1,310 @@ +.admin-orders { + max-width: 1400px; +} + +.admin-orders h2 { + margin-bottom: 30px; + color: #2c3e50; + font-size: 2rem; +} + +.filters-section { + background: white; + padding: 20px; + border-radius: 8px; + margin-bottom: 20px; + display: flex; + flex-wrap: wrap; + gap: 15px; + align-items: flex-end; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.filter-group { + display: flex; + flex-direction: column; + gap: 5px; +} + +.filter-group label { + font-size: 0.9rem; + color: #7f8c8d; + font-weight: 500; +} + +.filter-group input, +.filter-group select { + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 0.95rem; + min-width: 150px; +} + +.clear-filters-btn { + padding: 8px 16px; + background-color: #95a5a6; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + transition: background-color 0.3s; +} + +.clear-filters-btn:hover { + background-color: #7f8c8d; +} + +.orders-table-container { + background: white; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.orders-table { + width: 100%; + border-collapse: collapse; +} + +.orders-table thead { + background-color: #34495e; + color: white; +} + +.orders-table th { + padding: 15px; + text-align: left; + font-weight: 600; +} + +.orders-table td { + padding: 15px; + border-bottom: 1px solid #ecf0f1; +} + +.orders-table tbody tr:hover { + background-color: #f8f9fa; +} + +.status-badge { + padding: 5px 10px; + border-radius: 4px; + font-size: 0.85rem; + font-weight: 600; + text-transform: uppercase; +} + +.status-pending { + background-color: #f39c12; + color: white; +} + +.status-confirmed { + background-color: #3498db; + color: white; +} + +.status-preparing { + background-color: #9b59b6; + color: white; +} + +.status-outfordelivery { + background-color: #1abc9c; + color: white; +} + +.status-delivered { + background-color: #27ae60; + color: white; +} + +.status-cancelled { + background-color: #e74c3c; + color: white; +} + +.view-btn { + padding: 6px 12px; + background-color: #3498db; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + transition: background-color 0.3s; +} + +.view-btn:hover { + background-color: #2980b9; +} + +.no-data { + text-align: center; + padding: 40px; + color: #7f8c8d; + font-size: 1.1rem; +} + +.order-details-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal-content { + background: white; + border-radius: 8px; + width: 90%; + max-width: 600px; + max-height: 80vh; + overflow-y: auto; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px; + border-bottom: 1px solid #ecf0f1; +} + +.modal-header h3 { + margin: 0; + color: #2c3e50; +} + +.close-btn { + background: none; + border: none; + font-size: 2rem; + color: #7f8c8d; + cursor: pointer; + line-height: 1; + padding: 0; + width: 30px; + height: 30px; +} + +.close-btn:hover { + color: #2c3e50; +} + +.modal-body { + padding: 20px; +} + +.order-info { + margin-bottom: 20px; +} + +.order-info p { + margin: 0 0 10px 0; + color: #7f8c8d; + font-weight: 500; +} + +.status-select { + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 0.95rem; + width: 100%; + max-width: 300px; +} + +.order-items { + margin-top: 20px; +} + +.order-items h4 { + margin: 0 0 15px 0; + color: #2c3e50; +} + +.order-item { + padding: 15px; + background-color: #f8f9fa; + border-radius: 4px; + margin-bottom: 10px; +} + +.item-info { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 5px; +} + +.item-name { + font-weight: 600; + color: #2c3e50; +} + +.item-quantity { + color: #7f8c8d; +} + +.item-toppings { + font-size: 0.9rem; + color: #7f8c8d; + margin: 5px 0; +} + +.item-price { + font-weight: 600; + color: #27ae60; + text-align: right; + margin-top: 5px; +} + +.order-total { + margin-top: 20px; + padding-top: 20px; + border-top: 2px solid #ecf0f1; + text-align: right; + font-size: 1.2rem; + color: #2c3e50; +} + +.loading { + text-align: center; + padding: 40px; + color: #7f8c8d; + font-size: 1.1rem; +} + +@media (max-width: 768px) { + .filters-section { + flex-direction: column; + align-items: stretch; + } + + .filter-group input, + .filter-group select { + width: 100%; + } + + .orders-table { + font-size: 0.85rem; + } + + .orders-table th, + .orders-table td { + padding: 10px; + } + + .modal-content { + width: 95%; + } +} \ No newline at end of file diff --git a/CSharpPizza.WebUI/src/pages/admin/AdminOrders.tsx b/CSharpPizza.WebUI/src/pages/admin/AdminOrders.tsx new file mode 100644 index 0000000..2106a2f --- /dev/null +++ b/CSharpPizza.WebUI/src/pages/admin/AdminOrders.tsx @@ -0,0 +1,210 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { adminApi } from '../../api'; +import type { AdminOrderFilter } from '../../types'; +import './AdminOrders.css'; + +// TODO: Should implement pagination for large datasets +// TODO: Should add export to CSV functionality + +export const AdminOrders = () => { + const queryClient = useQueryClient(); + const [filters, setFilters] = useState({}); + const [selectedOrderId, setSelectedOrderId] = useState(null); + + const { data: orders, isLoading } = useQuery({ + queryKey: ['admin-orders', filters], + queryFn: () => adminApi.getOrders(filters), + }); + + const { data: selectedOrder } = useQuery({ + queryKey: ['admin-order', selectedOrderId], + queryFn: () => adminApi.getOrderById(Number(selectedOrderId)), + enabled: !!selectedOrderId, + }); + + const updateStatusMutation = useMutation({ + mutationFn: ({ id, status }: { id: number; status: string }) => + adminApi.updateOrderStatus(id, status), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-orders'] }); + queryClient.invalidateQueries({ queryKey: ['admin-order'] }); + }, + }); + + const handleStatusChange = (orderId: string, newStatus: string) => { + updateStatusMutation.mutate({ id: Number(orderId), status: newStatus }); + }; + + const handleFilterChange = (key: keyof AdminOrderFilter, value: string) => { + setFilters(prev => ({ + ...prev, + [key]: value || undefined, + })); + }; + + const clearFilters = () => { + setFilters({}); + }; + + if (isLoading) { + return ( +
+

Orders Management

+
Loading orders...
+
+ ); + } + + return ( +
+

Orders Management

+ +
+
+ + +
+ +
+ + handleFilterChange('startDate', e.target.value)} + /> +
+ +
+ + handleFilterChange('endDate', e.target.value)} + /> +
+ +
+ + handleFilterChange('customerName', e.target.value)} + /> +
+ + +
+ +
+ + + + + + + + + + + + + + {orders?.map((order) => ( + + + + + + + + + + ))} + +
Order IDCustomerEmailDateStatusTotalActions
#{order.id}{order.customerName}{order.customerEmail}{new Date(order.createdAt).toLocaleDateString()} + + {order.status} + + ${order.totalAmount.toFixed(2)} + +
+ + {orders?.length === 0 && ( +
No orders found
+ )} +
+ + {selectedOrder && ( +
setSelectedOrderId(null)}> +
e.stopPropagation()}> +
+

Order Details - #{selectedOrder.id}

+ +
+
+
+

Status:

+ +
+ +
+

Order Items:

+ {selectedOrder.items.map((item) => ( +
+
+ {item.pizzaName} + x{item.quantity} +
+ {item.customToppings.length > 0 && ( +
+ Toppings: {item.customToppings.map(t => t.toppingName).join(', ')} +
+ )} +
${item.totalPrice.toFixed(2)}
+
+ ))} +
+ +
+ Total: ${selectedOrder.totalAmount.toFixed(2)} +
+
+
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/CSharpPizza.WebUI/src/pages/admin/AdminPizzas.css b/CSharpPizza.WebUI/src/pages/admin/AdminPizzas.css new file mode 100644 index 0000000..2d41dbf --- /dev/null +++ b/CSharpPizza.WebUI/src/pages/admin/AdminPizzas.css @@ -0,0 +1,296 @@ +.admin-pizzas { + max-width: 1400px; +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 30px; +} + +.page-header h2 { + margin: 0; + color: #2c3e50; + font-size: 2rem; +} + +.add-btn { + padding: 10px 20px; + background-color: #27ae60; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; + transition: background-color 0.3s; +} + +.add-btn:hover { + background-color: #229954; +} + +.pizza-form-container { + background: white; + padding: 30px; + border-radius: 8px; + margin-bottom: 30px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.pizza-form-container h3 { + margin: 0 0 20px 0; + color: #2c3e50; +} + +.pizza-form { + display: flex; + flex-direction: column; + gap: 20px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.form-group label { + font-weight: 600; + color: #2c3e50; +} + +.form-group input, +.form-group textarea { + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; + font-family: inherit; +} + +.form-group input:focus, +.form-group textarea:focus { + outline: none; + border-color: #3498db; +} + +.toppings-checkboxes { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 10px; + padding: 15px; + background-color: #f8f9fa; + border-radius: 4px; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; +} + +.checkbox-label input[type="checkbox"] { + cursor: pointer; +} + +.form-actions { + display: flex; + gap: 10px; + margin-top: 10px; +} + +.submit-btn { + padding: 10px 20px; + background-color: #3498db; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; + transition: background-color 0.3s; +} + +.submit-btn:hover { + background-color: #2980b9; +} + +.cancel-btn { + padding: 10px 20px; + background-color: #95a5a6; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; + transition: background-color 0.3s; +} + +.cancel-btn:hover { + background-color: #7f8c8d; +} + +.pizzas-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 20px; +} + +.pizza-card { + background: white; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + transition: transform 0.3s, box-shadow 0.3s; +} + +.pizza-card:hover { + transform: translateY(-5px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); +} + +.pizza-card.deleted { + opacity: 0.6; + border: 2px solid #e74c3c; +} + +.pizza-image { + width: 100%; + height: 200px; + object-fit: cover; +} + +.pizza-content { + padding: 20px; +} + +.pizza-content h3 { + margin: 0 0 10px 0; + color: #2c3e50; + font-size: 1.3rem; +} + +.deleted-badge { + display: inline-block; + padding: 4px 8px; + background-color: #e74c3c; + color: white; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + margin-bottom: 10px; +} + +.pizza-description { + color: #7f8c8d; + margin: 10px 0; + line-height: 1.5; +} + +.pizza-price { + font-size: 1.5rem; + font-weight: 700; + color: #27ae60; + margin: 10px 0; +} + +.pizza-toppings { + margin: 15px 0; +} + +.pizza-toppings strong { + display: block; + margin-bottom: 8px; + color: #2c3e50; +} + +.topping-list { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.topping-tag { + padding: 4px 8px; + background-color: #ecf0f1; + border-radius: 4px; + font-size: 0.85rem; + color: #2c3e50; +} + +.pizza-actions { + display: flex; + gap: 10px; + margin-top: 15px; +} + +.edit-btn { + flex: 1; + padding: 8px 16px; + background-color: #3498db; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + transition: background-color 0.3s; +} + +.edit-btn:hover { + background-color: #2980b9; +} + +.delete-btn { + flex: 1; + padding: 8px 16px; + background-color: #e74c3c; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + transition: background-color 0.3s; +} + +.delete-btn:hover:not(:disabled) { + background-color: #c0392b; +} + +.delete-btn:disabled { + background-color: #95a5a6; + cursor: not-allowed; +} + +.no-data { + text-align: center; + padding: 40px; + color: #7f8c8d; + font-size: 1.1rem; + background: white; + border-radius: 8px; +} + +.loading { + text-align: center; + padding: 40px; + color: #7f8c8d; + font-size: 1.1rem; +} + +@media (max-width: 768px) { + .page-header { + flex-direction: column; + align-items: flex-start; + gap: 15px; + } + + .pizzas-grid { + grid-template-columns: 1fr; + } + + .toppings-checkboxes { + grid-template-columns: 1fr; + } +} \ No newline at end of file diff --git a/CSharpPizza.WebUI/src/pages/admin/AdminPizzas.tsx b/CSharpPizza.WebUI/src/pages/admin/AdminPizzas.tsx new file mode 100644 index 0000000..a9da016 --- /dev/null +++ b/CSharpPizza.WebUI/src/pages/admin/AdminPizzas.tsx @@ -0,0 +1,232 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { adminApi } from '../../api'; +import type { CreatePizzaDto, UpdatePizzaDto, Pizza, Topping } from '../../types'; +import './AdminPizzas.css'; + +// TODO: Should add image upload functionality +// GOTCHA: No confirmation dialog for delete - easy to accidentally delete + +export const AdminPizzas = () => { + const queryClient = useQueryClient(); + const [showForm, setShowForm] = useState(false); + const [editingPizza, setEditingPizza] = useState(null); + const [formData, setFormData] = useState({ + name: '', + description: '', + basePrice: 0, + imageUrl: '', + toppingIds: [], + }); + + const { data: pizzas, isLoading: pizzasLoading } = useQuery({ + queryKey: ['admin-pizzas'], + queryFn: () => adminApi.getAllPizzas(), + }); + + const { data: toppings } = useQuery({ + queryKey: ['admin-toppings'], + queryFn: () => adminApi.getAllToppings(), + }); + + const createMutation = useMutation({ + mutationFn: (data: CreatePizzaDto) => adminApi.createPizza(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-pizzas'] }); + resetForm(); + }, + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: number; data: UpdatePizzaDto }) => + adminApi.updatePizza(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-pizzas'] }); + resetForm(); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: number) => adminApi.deletePizza(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-pizzas'] }); + }, + }); + + const resetForm = () => { + setFormData({ + name: '', + description: '', + basePrice: 0, + imageUrl: '', + toppingIds: [], + }); + setEditingPizza(null); + setShowForm(false); + }; + + const handleEdit = (pizza: Pizza) => { + setEditingPizza(pizza); + setFormData({ + name: pizza.name, + description: pizza.description, + basePrice: pizza.basePrice, + imageUrl: pizza.imageUrl || '', + toppingIds: pizza.toppings.map(t => t.id), + }); + setShowForm(true); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (editingPizza) { + updateMutation.mutate({ id: Number(editingPizza.id), data: formData }); + } else { + createMutation.mutate(formData); + } + }; + + const handleToppingToggle = (toppingId: string) => { + setFormData(prev => ({ + ...prev, + toppingIds: prev.toppingIds.includes(toppingId) + ? prev.toppingIds.filter(id => id !== toppingId) + : [...prev.toppingIds, toppingId], + })); + }; + + if (pizzasLoading) { + return ( +
+

Pizzas Management

+
Loading pizzas...
+
+ ); + } + + return ( +
+
+

Pizzas Management

+ +
+ + {showForm && ( +
+

{editingPizza ? 'Edit Pizza' : 'Create New Pizza'}

+
+
+ + setFormData({ ...formData, name: e.target.value })} + required + /> +
+ +
+ +