Implement product variations, enhanced order workflow, mobile responsiveness, and product import system
## Product Variations System - Add ProductVariation model with quantity-based pricing (1 for £10, 2 for £19, 3 for £25) - Complete CRUD operations for product variations - Enhanced ProductService to include variations in all queries - Updated OrderItem to support ProductVariationId for variation-based orders - Graceful error handling for duplicate quantity constraints - Admin interface with variations management (Create/Edit/Delete) - API endpoints for programmatic variation management ## Enhanced Order Workflow Management - Redesigned OrderStatus enum with clear workflow states (Accept → Packing → Dispatched → Delivered) - Added workflow tracking fields (AcceptedAt, PackingStartedAt, DispatchedAt, ExpectedDeliveryDate) - User tracking for accountability (AcceptedByUser, PackedByUser, DispatchedByUser) - Automatic delivery date calculation (dispatch date + working days, skips weekends) - On Hold workflow for problem resolution with reason tracking - Tab-based orders interface focused on workflow stages - One-click workflow actions from list view ## Mobile-Responsive Design - Responsive orders interface: tables on desktop, cards on mobile - Touch-friendly buttons and spacing for mobile users - Horizontal scrolling tabs with condensed labels on mobile - Color-coded status borders for quick visual recognition - Smart text switching based on screen size ## Product Import/Export System - CSV import with product variations support - Template download with examples - Export existing products to CSV - Detailed import results with success/error reporting - Category name resolution (no need for GUIDs) - Photo URLs import support ## Enhanced Dashboard - Product variations count and metrics - Stock alerts (low stock/out of stock warnings) - Order workflow breakdown (pending, accepted, dispatched counts) - Enhanced layout with more detailed information ## Technical Improvements - Fixed form binding issues across all admin forms - Removed external CDN dependencies for isolated deployment - Bot Wizard form with auto-personality assignment - Proper authentication scheme configuration (Cookie + JWT) - Enhanced debug logging for troubleshooting ## Self-Contained Deployment - All external CDN references replaced with local libraries - Ready for air-gapped/isolated network deployment - No external internet dependencies 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -71,11 +71,21 @@ public class BotsController : Controller
|
||||
|
||||
// POST: Admin/Bots/Wizard
|
||||
[HttpPost]
|
||||
// [ValidateAntiForgeryToken] // Temporarily disabled for testing
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Wizard(BotWizardDto dto)
|
||||
{
|
||||
Console.WriteLine("=== BOT WIZARD DEBUG ===");
|
||||
Console.WriteLine($"Received: BotName='{dto?.BotName}', BotUsername='{dto?.BotUsername}', PersonalityName='{dto?.PersonalityName}'");
|
||||
Console.WriteLine($"ModelState.IsValid: {ModelState.IsValid}");
|
||||
Console.WriteLine("Raw form data:");
|
||||
foreach (var key in Request.Form.Keys)
|
||||
{
|
||||
Console.WriteLine($" {key} = '{Request.Form[key]}'");
|
||||
}
|
||||
Console.WriteLine("========================");
|
||||
|
||||
_logger.LogInformation("Wizard POST received - BotName: '{BotName}', BotUsername: '{BotUsername}'", dto.BotName, dto.BotUsername);
|
||||
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
_logger.LogWarning("Validation failed");
|
||||
@@ -86,13 +96,22 @@ public class BotsController : Controller
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
// Auto-assign personality if not selected
|
||||
if (string.IsNullOrEmpty(dto.PersonalityName))
|
||||
{
|
||||
var personalities = new[] { "Alan", "Dave", "Sarah", "Mike", "Emma", "Tom" };
|
||||
var random = new Random();
|
||||
dto.PersonalityName = personalities[random.Next(personalities.Length)];
|
||||
Console.WriteLine($"Auto-assigned personality: {dto.PersonalityName}");
|
||||
}
|
||||
|
||||
// Generate BotFather commands
|
||||
var commands = GenerateBotFatherCommands(dto);
|
||||
ViewData["BotFatherCommands"] = commands;
|
||||
ViewData["ShowCommands"] = true;
|
||||
|
||||
_logger.LogInformation("Generated BotFather commands successfully for bot '{BotName}'", dto.BotName);
|
||||
|
||||
|
||||
_logger.LogInformation("Generated BotFather commands successfully for bot '{BotName}' with personality '{PersonalityName}'", dto.BotName, dto.PersonalityName);
|
||||
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
@@ -316,6 +335,7 @@ public class BotsController : Controller
|
||||
return string.Join("\n", commands);
|
||||
}
|
||||
|
||||
|
||||
private async Task<bool> ValidateTelegramToken(string token)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -28,10 +28,23 @@ public class DashboardController : Controller
|
||||
var products = await _productService.GetAllProductsAsync();
|
||||
var categories = await _categoryService.GetAllCategoriesAsync();
|
||||
|
||||
// Basic metrics
|
||||
ViewData["TotalOrders"] = orders.Count();
|
||||
ViewData["TotalProducts"] = products.Count();
|
||||
ViewData["TotalCategories"] = categories.Count();
|
||||
ViewData["TotalRevenue"] = orders.Where(o => o.PaidAt.HasValue).Sum(o => o.TotalAmount);
|
||||
ViewData["TotalRevenue"] = orders.Where(o => o.PaidAt.HasValue).Sum(o => o.TotalAmount).ToString("F2");
|
||||
|
||||
// Enhanced metrics
|
||||
ViewData["TotalVariations"] = products.Sum(p => p.Variations.Count);
|
||||
ViewData["PendingOrders"] = orders.Count(o => o.Status == LittleShop.Enums.OrderStatus.PendingPayment);
|
||||
ViewData["ShippedOrders"] = orders.Count(o => o.Status == LittleShop.Enums.OrderStatus.Shipped);
|
||||
ViewData["TotalStock"] = products.Sum(p => p.StockQuantity);
|
||||
ViewData["LowStockProducts"] = products.Count(p => p.StockQuantity < 10);
|
||||
ViewData["OutOfStockProducts"] = products.Count(p => p.StockQuantity == 0);
|
||||
|
||||
// Recent activity
|
||||
ViewData["RecentOrders"] = orders.OrderByDescending(o => o.CreatedAt).Take(5);
|
||||
ViewData["TopProducts"] = products.OrderByDescending(p => p.StockQuantity).Take(5);
|
||||
|
||||
return View();
|
||||
}
|
||||
|
||||
@@ -16,10 +16,49 @@ public class OrdersController : Controller
|
||||
_orderService = orderService;
|
||||
}
|
||||
|
||||
public async Task<IActionResult> Index()
|
||||
public async Task<IActionResult> Index(string tab = "accept")
|
||||
{
|
||||
var orders = await _orderService.GetAllOrdersAsync();
|
||||
return View(orders.OrderByDescending(o => o.CreatedAt));
|
||||
ViewData["CurrentTab"] = tab;
|
||||
|
||||
switch (tab.ToLower())
|
||||
{
|
||||
case "accept":
|
||||
ViewData["Orders"] = await _orderService.GetOrdersRequiringActionAsync();
|
||||
ViewData["TabTitle"] = "Orders to Accept";
|
||||
break;
|
||||
case "packing":
|
||||
ViewData["Orders"] = await _orderService.GetOrdersForPackingAsync();
|
||||
ViewData["TabTitle"] = "Orders for Packing";
|
||||
break;
|
||||
case "dispatched":
|
||||
ViewData["Orders"] = await _orderService.GetOrdersByStatusAsync(LittleShop.Enums.OrderStatus.Dispatched);
|
||||
ViewData["TabTitle"] = "Dispatched Orders";
|
||||
break;
|
||||
case "delivered":
|
||||
ViewData["Orders"] = await _orderService.GetOrdersByStatusAsync(LittleShop.Enums.OrderStatus.Delivered);
|
||||
ViewData["TabTitle"] = "Delivered Orders";
|
||||
break;
|
||||
case "onhold":
|
||||
ViewData["Orders"] = await _orderService.GetOrdersOnHoldAsync();
|
||||
ViewData["TabTitle"] = "Orders On Hold";
|
||||
break;
|
||||
case "cancelled":
|
||||
ViewData["Orders"] = await _orderService.GetOrdersByStatusAsync(LittleShop.Enums.OrderStatus.Cancelled);
|
||||
ViewData["TabTitle"] = "Cancelled Orders";
|
||||
break;
|
||||
default:
|
||||
ViewData["Orders"] = await _orderService.GetAllOrdersAsync();
|
||||
ViewData["TabTitle"] = "All Orders";
|
||||
break;
|
||||
}
|
||||
|
||||
// Get workflow counts for tab badges
|
||||
ViewData["AcceptCount"] = (await _orderService.GetOrdersRequiringActionAsync()).Count();
|
||||
ViewData["PackingCount"] = (await _orderService.GetOrdersForPackingAsync()).Count();
|
||||
ViewData["DispatchedCount"] = (await _orderService.GetOrdersByStatusAsync(LittleShop.Enums.OrderStatus.Dispatched)).Count();
|
||||
ViewData["OnHoldCount"] = (await _orderService.GetOrdersOnHoldAsync()).Count();
|
||||
|
||||
return View();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> Details(Guid id)
|
||||
@@ -96,4 +135,126 @@ public class OrdersController : Controller
|
||||
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
// Workflow action methods
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> AcceptOrder(Guid id, string? notes)
|
||||
{
|
||||
var userName = User.Identity?.Name ?? "Unknown";
|
||||
var acceptDto = new AcceptOrderDto { Notes = notes };
|
||||
var success = await _orderService.AcceptOrderAsync(id, userName, acceptDto);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
TempData["Error"] = "Could not accept order. Check order status.";
|
||||
}
|
||||
else
|
||||
{
|
||||
TempData["Success"] = "Order accepted successfully.";
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> StartPacking(Guid id, string? notes)
|
||||
{
|
||||
var userName = User.Identity?.Name ?? "Unknown";
|
||||
var packingDto = new StartPackingDto { Notes = notes };
|
||||
var success = await _orderService.StartPackingAsync(id, userName, packingDto);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
TempData["Error"] = "Could not start packing. Check order status.";
|
||||
}
|
||||
else
|
||||
{
|
||||
TempData["Success"] = "Packing started successfully.";
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> DispatchOrder(Guid id, string trackingNumber, int estimatedDays = 3, string? notes = null)
|
||||
{
|
||||
var userName = User.Identity?.Name ?? "Unknown";
|
||||
var dispatchDto = new DispatchOrderDto
|
||||
{
|
||||
TrackingNumber = trackingNumber,
|
||||
EstimatedDeliveryDays = estimatedDays,
|
||||
Notes = notes
|
||||
};
|
||||
var success = await _orderService.DispatchOrderAsync(id, userName, dispatchDto);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
TempData["Error"] = "Could not dispatch order. Check order status.";
|
||||
}
|
||||
else
|
||||
{
|
||||
TempData["Success"] = $"Order dispatched with tracking {trackingNumber}.";
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> PutOnHold(Guid id, string reason, string? notes)
|
||||
{
|
||||
var userName = User.Identity?.Name ?? "Unknown";
|
||||
var holdDto = new PutOnHoldDto { Reason = reason, Notes = notes };
|
||||
var success = await _orderService.PutOnHoldAsync(id, userName, holdDto);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
TempData["Error"] = "Could not put order on hold.";
|
||||
}
|
||||
else
|
||||
{
|
||||
TempData["Success"] = "Order put on hold.";
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> RemoveFromHold(Guid id)
|
||||
{
|
||||
var userName = User.Identity?.Name ?? "Unknown";
|
||||
var success = await _orderService.RemoveFromHoldAsync(id, userName);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
TempData["Error"] = "Could not remove order from hold.";
|
||||
}
|
||||
else
|
||||
{
|
||||
TempData["Success"] = "Order removed from hold and returned to workflow.";
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> MarkDelivered(Guid id, DateTime? actualDeliveryDate, string? notes)
|
||||
{
|
||||
var deliveredDto = new MarkDeliveredDto
|
||||
{
|
||||
ActualDeliveryDate = actualDeliveryDate,
|
||||
Notes = notes
|
||||
};
|
||||
var success = await _orderService.MarkDeliveredAsync(id, deliveredDto);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
TempData["Error"] = "Could not mark order as delivered.";
|
||||
}
|
||||
else
|
||||
{
|
||||
TempData["Success"] = "Order marked as delivered.";
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using LittleShop.Services;
|
||||
using LittleShop.DTOs;
|
||||
using System.Text;
|
||||
|
||||
namespace LittleShop.Areas.Admin.Controllers;
|
||||
|
||||
@@ -11,11 +12,13 @@ public class ProductsController : Controller
|
||||
{
|
||||
private readonly IProductService _productService;
|
||||
private readonly ICategoryService _categoryService;
|
||||
private readonly IProductImportService _importService;
|
||||
|
||||
public ProductsController(IProductService productService, ICategoryService categoryService)
|
||||
public ProductsController(IProductService productService, ICategoryService categoryService, IProductImportService importService)
|
||||
{
|
||||
_productService = productService;
|
||||
_categoryService = categoryService;
|
||||
_importService = importService;
|
||||
}
|
||||
|
||||
public async Task<IActionResult> Index()
|
||||
@@ -139,4 +142,203 @@ public class ProductsController : Controller
|
||||
await _productService.DeleteProductAsync(id);
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
// Product Variations
|
||||
public async Task<IActionResult> Variations(Guid id)
|
||||
{
|
||||
var product = await _productService.GetProductByIdAsync(id);
|
||||
if (product == null)
|
||||
return NotFound();
|
||||
|
||||
ViewData["Product"] = product;
|
||||
var variations = await _productService.GetProductVariationsAsync(id);
|
||||
return View(variations);
|
||||
}
|
||||
|
||||
public async Task<IActionResult> CreateVariation(Guid productId)
|
||||
{
|
||||
var product = await _productService.GetProductByIdAsync(productId);
|
||||
if (product == null)
|
||||
return NotFound();
|
||||
|
||||
// Force no-cache to ensure updated form loads
|
||||
Response.Headers.Add("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
Response.Headers.Add("Pragma", "no-cache");
|
||||
Response.Headers.Add("Expires", "0");
|
||||
|
||||
ViewData["Product"] = product;
|
||||
|
||||
// Get existing quantities to help user avoid duplicates
|
||||
var existingQuantities = await _productService.GetProductVariationsAsync(productId);
|
||||
ViewData["ExistingQuantities"] = existingQuantities.Select(v => v.Quantity).ToList();
|
||||
|
||||
return View(new CreateProductVariationDto { ProductId = productId });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> CreateVariation(CreateProductVariationDto model)
|
||||
{
|
||||
// Debug form data
|
||||
Console.WriteLine("=== FORM DEBUG ===");
|
||||
Console.WriteLine($"Received CreateVariation POST: ProductId={model?.ProductId}, Name='{model?.Name}', Quantity={model?.Quantity}, Price={model?.Price}");
|
||||
Console.WriteLine($"ModelState.IsValid: {ModelState.IsValid}");
|
||||
Console.WriteLine("Raw form data:");
|
||||
foreach (var key in Request.Form.Keys)
|
||||
{
|
||||
Console.WriteLine($" {key} = '{Request.Form[key]}'");
|
||||
}
|
||||
Console.WriteLine("================");
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
foreach (var key in ModelState.Keys)
|
||||
{
|
||||
var errors = ModelState[key]?.Errors;
|
||||
if (errors?.Any() == true)
|
||||
{
|
||||
Console.WriteLine($"ModelState Error - {key}: {string.Join(", ", errors.Select(e => e.ErrorMessage))}");
|
||||
}
|
||||
}
|
||||
|
||||
var product = await _productService.GetProductByIdAsync(model.ProductId);
|
||||
ViewData["Product"] = product;
|
||||
|
||||
// Re-populate existing quantities for error display
|
||||
var existingQuantities = await _productService.GetProductVariationsAsync(model.ProductId);
|
||||
ViewData["ExistingQuantities"] = existingQuantities.Select(v => v.Quantity).ToList();
|
||||
|
||||
return View(model);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _productService.CreateProductVariationAsync(model);
|
||||
return RedirectToAction(nameof(Variations), new { id = model.ProductId });
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
// Add the error to the appropriate field if it's a quantity conflict
|
||||
if (ex.Message.Contains("quantity") && ex.Message.Contains("already exists"))
|
||||
{
|
||||
ModelState.AddModelError("Quantity", ex.Message);
|
||||
}
|
||||
else
|
||||
{
|
||||
ModelState.AddModelError("", ex.Message);
|
||||
}
|
||||
|
||||
var product = await _productService.GetProductByIdAsync(model.ProductId);
|
||||
ViewData["Product"] = product;
|
||||
|
||||
// Re-populate existing quantities for error display
|
||||
var existingQuantities = await _productService.GetProductVariationsAsync(model.ProductId);
|
||||
ViewData["ExistingQuantities"] = existingQuantities.Select(v => v.Quantity).ToList();
|
||||
|
||||
return View(model);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IActionResult> EditVariation(Guid id)
|
||||
{
|
||||
var variation = await _productService.GetProductVariationByIdAsync(id);
|
||||
if (variation == null)
|
||||
return NotFound();
|
||||
|
||||
var product = await _productService.GetProductByIdAsync(variation.ProductId);
|
||||
ViewData["Product"] = product;
|
||||
|
||||
var model = new UpdateProductVariationDto
|
||||
{
|
||||
Name = variation.Name,
|
||||
Description = variation.Description,
|
||||
Quantity = variation.Quantity,
|
||||
Price = variation.Price,
|
||||
SortOrder = variation.SortOrder,
|
||||
IsActive = variation.IsActive
|
||||
};
|
||||
|
||||
return View(model);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> EditVariation(Guid id, UpdateProductVariationDto model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
var variation = await _productService.GetProductVariationByIdAsync(id);
|
||||
var product = await _productService.GetProductByIdAsync(variation!.ProductId);
|
||||
ViewData["Product"] = product;
|
||||
return View(model);
|
||||
}
|
||||
|
||||
var success = await _productService.UpdateProductVariationAsync(id, model);
|
||||
if (!success)
|
||||
return NotFound();
|
||||
|
||||
var variationToRedirect = await _productService.GetProductVariationByIdAsync(id);
|
||||
return RedirectToAction(nameof(Variations), new { id = variationToRedirect!.ProductId });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> DeleteVariation(Guid id)
|
||||
{
|
||||
var variation = await _productService.GetProductVariationByIdAsync(id);
|
||||
if (variation == null)
|
||||
return NotFound();
|
||||
|
||||
await _productService.DeleteProductVariationAsync(id);
|
||||
return RedirectToAction(nameof(Variations), new { id = variation.ProductId });
|
||||
}
|
||||
|
||||
// Product Import/Export
|
||||
public IActionResult Import()
|
||||
{
|
||||
return View();
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Import(IFormFile file)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
{
|
||||
ModelState.AddModelError("", "Please select a CSV file to import");
|
||||
return View();
|
||||
}
|
||||
|
||||
if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ModelState.AddModelError("", "Only CSV files are supported");
|
||||
return View();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _importService.ImportFromCsvAsync(stream);
|
||||
|
||||
ViewData["ImportResult"] = result;
|
||||
return View("ImportResult", result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ModelState.AddModelError("", $"Import failed: {ex.Message}");
|
||||
return View();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IActionResult> Export()
|
||||
{
|
||||
var csvContent = await _importService.ExportProductsAsCsvAsync();
|
||||
var fileName = $"products_export_{DateTime.UtcNow:yyyyMMdd_HHmmss}.csv";
|
||||
|
||||
return File(Encoding.UTF8.GetBytes(csvContent), "text/csv", fileName);
|
||||
}
|
||||
|
||||
public IActionResult DownloadTemplate()
|
||||
{
|
||||
var templateContent = _importService.GenerateTemplateAsCsv();
|
||||
var fileName = "product_import_template.csv";
|
||||
|
||||
return File(Encoding.UTF8.GetBytes(templateContent), "text/csv", fileName);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user