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:
SilverLabs DevTeam
2025-09-18 01:39:31 +01:00
parent 6b6961e61a
commit a419bd7a78
38 changed files with 3815 additions and 104 deletions

View File

@@ -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

View File

@@ -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();
}

View File

@@ -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 });
}
}

View File

@@ -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);
}
}

View File

@@ -11,8 +11,8 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"]</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<link href="/lib/bootstrap/css/bootstrap.min.css" rel="stylesheet">
<link href="/lib/fontawesome/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<div class="container">
@@ -57,9 +57,7 @@
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validate/1.19.3/jquery.validate.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validation-unobtrusive/3.2.12/jquery.validate.unobtrusive.min.js"></script>
<script src="/lib/jquery/jquery.min.js"></script>
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -20,34 +20,44 @@
<div class="card-body">
<form asp-area="Admin" asp-controller="Bots" asp-action="Wizard" method="post">
@Html.AntiForgeryToken()
<div asp-validation-summary="All" class="text-danger"></div>
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="mb-3">
<label asp-for="BotName" class="form-label">Bot Display Name</label>
<input asp-for="BotName" class="form-control"
<label for="BotName" class="form-label">Bot Display Name</label>
<input name="BotName" id="BotName" value="@Model?.BotName" class="form-control @(ViewData.ModelState["BotName"]?.Errors.Count > 0 ? "is-invalid" : "")"
placeholder="e.g., LittleShop Electronics Bot" required />
<span asp-validation-for="BotName" class="text-danger"></span>
@if(ViewData.ModelState["BotName"]?.Errors.Count > 0)
{
<div class="invalid-feedback">
@ViewData.ModelState["BotName"]?.Errors.FirstOrDefault()?.ErrorMessage
</div>
}
<small class="text-muted">This is the name users will see</small>
</div>
<div class="mb-3">
<label asp-for="BotUsername" class="form-label">Bot Username</label>
<label for="BotUsername" class="form-label">Bot Username</label>
<div class="input-group">
<span class="input-group-text">@@</span>
<input asp-for="BotUsername" class="form-control"
<input name="BotUsername" id="BotUsername" value="@Model?.BotUsername" class="form-control @(ViewData.ModelState["BotUsername"]?.Errors.Count > 0 ? "is-invalid" : "")"
placeholder="littleshop_bot" required />
</div>
<span asp-validation-for="BotUsername" class="text-danger"></span>
@if(ViewData.ModelState["BotUsername"]?.Errors.Count > 0)
{
<div class="invalid-feedback">
@ViewData.ModelState["BotUsername"]?.Errors.FirstOrDefault()?.ErrorMessage
</div>
}
<small class="text-muted">Must end with 'bot' and be unique on Telegram</small>
</div>
<div class="mb-3">
<label for="PersonalityName" class="form-label">Personality</label>
<select asp-for="PersonalityName" class="form-select">
<select name="PersonalityName" id="PersonalityName" class="form-select @(ViewData.ModelState["PersonalityName"]?.Errors.Count > 0 ? "is-invalid" : "")">
<option value="">Auto-assign (recommended)</option>
<option value="Alan" @(Model.PersonalityName == "Alan" ? "selected" : "")>Alan (Professional)</option>
<option value="Dave" @(Model.PersonalityName == "Dave" ? "selected" : "")>Dave (Casual)</option>
<option value="Sarah" @(Model.PersonalityName == "Sarah" ? "selected" : "")>Sarah (Helpful)</option>
<option value="Alan" @(Model?.PersonalityName == "Alan" ? "selected" : "")>Alan (Professional)</option>
<option value="Dave" @(Model?.PersonalityName == "Dave" ? "selected" : "")>Dave (Casual)</option>
<option value="Sarah" @(Model?.PersonalityName == "Sarah" ? "selected" : "")>Sarah (Helpful)</option>
<option value="Mike" @(Model.PersonalityName == "Mike" ? "selected" : "")>Mike (Direct)</option>
<option value="Emma" @(Model.PersonalityName == "Emma" ? "selected" : "")>Emma (Friendly)</option>
<option value="Tom" @(Model.PersonalityName == "Tom" ? "selected" : "")>Tom (Efficient)</option>

View File

@@ -16,10 +16,11 @@
</div>
<div class="card-body">
<h4 class="card-title">@ViewData["TotalOrders"]</h4>
<small>@ViewData["PendingOrders"] pending • @ViewData["ShippedOrders"] shipped</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-white bg-success mb-3">
<div class="card-header">
@@ -27,21 +28,23 @@
</div>
<div class="card-body">
<h4 class="card-title">@ViewData["TotalProducts"]</h4>
<small>@ViewData["TotalVariations"] variations • @ViewData["TotalStock"] in stock</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-white bg-info mb-3">
<div class="card-header">
<i class="fas fa-tags"></i> Total Categories
<i class="fas fa-tags"></i> Categories
</div>
<div class="card-body">
<h4 class="card-title">@ViewData["TotalCategories"]</h4>
<small>Active categories</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-white bg-warning mb-3">
<div class="card-header">
@@ -49,6 +52,48 @@
</div>
<div class="card-body">
<h4 class="card-title">£@ViewData["TotalRevenue"]</h4>
<small>From completed orders</small>
</div>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-3">
<div class="card text-white bg-danger mb-3">
<div class="card-header">
<i class="fas fa-exclamation-triangle"></i> Stock Alerts
</div>
<div class="card-body">
<h4 class="card-title">@ViewData["LowStockProducts"]</h4>
<small>@ViewData["OutOfStockProducts"] out of stock</small>
</div>
</div>
</div>
<div class="col-md-9">
<div class="card">
<div class="card-header">
<h5><i class="fas fa-list"></i> Product Variations Summary</h5>
</div>
<div class="card-body">
@if ((int)ViewData["TotalVariations"] > 0)
{
<div class="alert alert-success">
<i class="fas fa-check-circle"></i>
<strong>@ViewData["TotalVariations"] product variations</strong> have been configured across your catalog.
Customers can now choose quantity-based pricing options!
</div>
}
else
{
<div class="alert alert-info">
<i class="fas fa-info-circle"></i>
No product variations configured yet.
<a href="@Url.Action("Index", "Products")" class="alert-link">Add variations</a>
to offer quantity-based pricing (e.g., 1 for £10, 2 for £19, 3 for £25).
</div>
}
</div>
</div>
</div>

View File

@@ -1,104 +1,407 @@
@model IEnumerable<LittleShop.DTOs.OrderDto>
@{
ViewData["Title"] = "Orders";
ViewData["Title"] = "Order Management";
var orders = ViewData["Orders"] as IEnumerable<LittleShop.DTOs.OrderDto> ?? new List<LittleShop.DTOs.OrderDto>();
var currentTab = ViewData["CurrentTab"] as string ?? "accept";
var tabTitle = ViewData["TabTitle"] as string ?? "Orders";
var acceptCount = (int)(ViewData["AcceptCount"] ?? 0);
var packingCount = (int)(ViewData["PackingCount"] ?? 0);
var dispatchedCount = (int)(ViewData["DispatchedCount"] ?? 0);
var onHoldCount = (int)(ViewData["OnHoldCount"] ?? 0);
}
<div class="row mb-4">
<div class="row mb-3">
<div class="col">
<h1><i class="fas fa-shopping-cart"></i> Orders</h1>
<h1 class="h3"><i class="fas fa-clipboard-list"></i> <span class="d-none d-md-inline">Order Management</span><span class="d-md-none">Orders</span></h1>
<p class="text-muted d-none d-md-block">Workflow-focused order fulfillment system</p>
</div>
<div class="col-auto">
<a href="@Url.Action("Create")" class="btn btn-primary">
<i class="fas fa-plus"></i> Create Order
<a href="@Url.Action("Create")" class="btn btn-primary btn-sm">
<i class="fas fa-plus"></i> <span class="d-none d-sm-inline">Create Order</span><span class="d-sm-none">New</span>
</a>
</div>
</div>
<!-- Workflow Tabs - Mobile Responsive -->
<ul class="nav nav-tabs mb-3 flex-nowrap overflow-auto" id="orderTabs" role="tablist" style="white-space: nowrap;">
<li class="nav-item" role="presentation">
<a class="nav-link @(currentTab == "accept" ? "active" : "")" href="@Url.Action("Index", new { tab = "accept" })">
<i class="fas fa-check-circle"></i>
<span class="d-none d-md-inline">Accept Orders</span>
<span class="d-md-none">Accept</span>
@if (acceptCount > 0)
{
<span class="badge bg-danger ms-1">@acceptCount</span>
}
</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link @(currentTab == "packing" ? "active" : "")" href="@Url.Action("Index", new { tab = "packing" })">
<i class="fas fa-box"></i>
<span class="d-none d-md-inline">Packing</span>
<span class="d-md-none">Pack</span>
@if (packingCount > 0)
{
<span class="badge bg-warning ms-1">@packingCount</span>
}
</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link @(currentTab == "dispatched" ? "active" : "")" href="@Url.Action("Index", new { tab = "dispatched" })">
<i class="fas fa-shipping-fast"></i>
<span class="d-none d-md-inline">Dispatched</span>
<span class="d-md-none">Ship</span>
@if (dispatchedCount > 0)
{
<span class="badge bg-info ms-1">@dispatchedCount</span>
}
</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link @(currentTab == "delivered" ? "active" : "")" href="@Url.Action("Index", new { tab = "delivered" })">
<i class="fas fa-check"></i>
<span class="d-none d-md-inline">Delivered</span>
<span class="d-md-none">Done</span>
</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link @(currentTab == "onhold" ? "active" : "")" href="@Url.Action("Index", new { tab = "onhold" })">
<i class="fas fa-pause-circle"></i>
<span class="d-none d-md-inline">On Hold</span>
<span class="d-md-none">Hold</span>
@if (onHoldCount > 0)
{
<span class="badge bg-secondary ms-1">@onHoldCount</span>
}
</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link @(currentTab == "cancelled" ? "active" : "")" href="@Url.Action("Index", new { tab = "cancelled" })">
<i class="fas fa-times-circle"></i>
<span class="d-none d-md-inline">Cancelled</span>
<span class="d-md-none">Cancel</span>
</a>
</li>
</ul>
<div class="card">
<div class="card-header">
<h5 class="mb-0">@tabTitle (@orders.Count())</h5>
</div>
<div class="card-body">
@if (Model.Any())
@if (orders.Any())
{
<div class="table-responsive">
<table class="table table-striped">
<!-- Desktop Table View (hidden on mobile) -->
<div class="table-responsive d-none d-lg-block">
<table class="table table-hover">
<thead>
<tr>
<th>Order ID</th>
<th>Customer</th>
<th>Shipping To</th>
<th>Status</th>
<th>Items</th>
<th>Total</th>
<th>Created</th>
<th>Status</th>
<th>Timeline</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var order in Model)
@foreach (var order in orders)
{
<tr>
<td><code>@order.Id.ToString().Substring(0, 8)...</code></td>
<td>
<strong>#@order.Id.ToString().Substring(0, 8)</strong>
<br><small class="text-muted">@order.CreatedAt.ToString("MMM dd, HH:mm")</small>
</td>
<td>
@if (order.Customer != null)
{
<div>
<strong>@order.Customer.DisplayName</strong>
@if (!string.IsNullOrEmpty(order.Customer.TelegramUsername))
{
<br><small class="text-muted">@@@order.Customer.TelegramUsername</small>
}
<br><small class="badge bg-info">@order.Customer.CustomerType</small>
</div>
<strong>@order.Customer.DisplayName</strong>
<br><small class="text-muted">@order.Customer.CustomerType</small>
}
else
{
<span class="text-muted">@order.ShippingName</span>
@if (!string.IsNullOrEmpty(order.IdentityReference))
<strong>@order.ShippingName</strong>
<br><small class="text-muted">Anonymous</small>
}
</td>
<td>
@foreach (var item in order.Items.Take(2))
{
<div>@item.Quantity× @item.ProductName</div>
@if (!string.IsNullOrEmpty(item.ProductVariationName))
{
<br><small class="text-muted">(@order.IdentityReference)</small>
<small class="text-muted">(@item.ProductVariationName)</small>
}
}
@if (order.Items.Count > 2)
{
<small class="text-muted">+@(order.Items.Count - 2) more...</small>
}
</td>
<td>
<strong>£@order.TotalAmount</strong>
<br><small class="text-muted">@order.Currency</small>
</td>
<td>@order.ShippingCity, @order.ShippingCountry</td>
<td>
@{
var badgeClass = order.Status switch
var statusClass = order.Status switch
{
LittleShop.Enums.OrderStatus.PendingPayment => "bg-warning",
LittleShop.Enums.OrderStatus.PaymentReceived => "bg-success",
LittleShop.Enums.OrderStatus.Processing => "bg-info",
LittleShop.Enums.OrderStatus.Shipped => "bg-primary",
LittleShop.Enums.OrderStatus.PaymentReceived => "bg-info",
LittleShop.Enums.OrderStatus.Accepted => "bg-primary",
LittleShop.Enums.OrderStatus.Packing => "bg-warning",
LittleShop.Enums.OrderStatus.Dispatched => "bg-info",
LittleShop.Enums.OrderStatus.Delivered => "bg-success",
LittleShop.Enums.OrderStatus.OnHold => "bg-secondary",
LittleShop.Enums.OrderStatus.Cancelled => "bg-danger",
_ => "bg-secondary"
_ => "bg-light"
};
}
<span class="badge @badgeClass">@order.Status</span>
</td>
<td><strong>£@order.TotalAmount</strong></td>
<td>@order.CreatedAt.ToString("MMM dd, yyyy HH:mm")</td>
<td>
<a href="@Url.Action("Details", new { id = order.Id })" class="btn btn-sm btn-outline-primary">
<i class="fas fa-eye"></i> View
</a>
@if (order.Customer != null)
<span class="badge @statusClass">@order.Status</span>
@if (!string.IsNullOrEmpty(order.TrackingNumber))
{
<a href="@Url.Action("Details", new { id = order.Id })" class="btn btn-sm btn-success ms-1" title="Message Customer">
<i class="fas fa-comment"></i>
</a>
<br><small class="text-muted">@order.TrackingNumber</small>
}
</td>
<td>
<small>
@if (order.AcceptedAt.HasValue)
{
<div>✅ Accepted @order.AcceptedAt.Value.ToString("MMM dd, HH:mm")</div>
}
@if (order.PackingStartedAt.HasValue)
{
<div>📦 Packing @order.PackingStartedAt.Value.ToString("MMM dd, HH:mm")</div>
}
@if (order.DispatchedAt.HasValue)
{
<div>🚚 Dispatched @order.DispatchedAt.Value.ToString("MMM dd, HH:mm")</div>
}
@if (order.ExpectedDeliveryDate.HasValue)
{
<div class="text-muted">📅 Expected @order.ExpectedDeliveryDate.Value.ToString("MMM dd")</div>
}
@if (order.OnHoldAt.HasValue)
{
<div class="text-warning">⏸️ On Hold: @order.OnHoldReason</div>
}
</small>
</td>
<td>
<div class="btn-group btn-group-sm">
<a href="@Url.Action("Details", new { id = order.Id })" class="btn btn-outline-primary" title="View Details">
<i class="fas fa-eye"></i>
</a>
@* Workflow-specific actions *@
@if (order.Status == LittleShop.Enums.OrderStatus.PaymentReceived)
{
<form method="post" action="@Url.Action("AcceptOrder", new { id = order.Id })" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-success btn-sm" title="Accept Order">
<i class="fas fa-check"></i>
</button>
</form>
}
@if (order.Status == LittleShop.Enums.OrderStatus.Accepted)
{
<form method="post" action="@Url.Action("StartPacking", new { id = order.Id })" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-warning btn-sm" title="Start Packing">
<i class="fas fa-box"></i>
</button>
</form>
}
@if (order.Status != LittleShop.Enums.OrderStatus.OnHold && order.Status != LittleShop.Enums.OrderStatus.Delivered && order.Status != LittleShop.Enums.OrderStatus.Cancelled)
{
<button type="button" class="btn btn-secondary btn-sm" title="Put On Hold" data-bs-toggle="modal" data-bs-target="#holdModal-@order.Id">
<i class="fas fa-pause"></i>
</button>
}
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Mobile Card View (hidden on desktop) -->
<div class="d-lg-none">
@foreach (var order in orders)
{
<div class="card mb-3 border-start border-3 @(order.Status switch {
LittleShop.Enums.OrderStatus.PaymentReceived => "border-warning",
LittleShop.Enums.OrderStatus.Accepted => "border-primary",
LittleShop.Enums.OrderStatus.Packing => "border-info",
LittleShop.Enums.OrderStatus.Dispatched => "border-success",
LittleShop.Enums.OrderStatus.OnHold => "border-secondary",
_ => "border-light"
})">
<div class="card-body">
<div class="row align-items-center">
<div class="col">
<h6 class="card-title mb-1">
<strong>#@order.Id.ToString().Substring(0, 8)</strong>
<span class="badge @(order.Status switch {
LittleShop.Enums.OrderStatus.PendingPayment => "bg-warning",
LittleShop.Enums.OrderStatus.PaymentReceived => "bg-info",
LittleShop.Enums.OrderStatus.Accepted => "bg-primary",
LittleShop.Enums.OrderStatus.Packing => "bg-warning",
LittleShop.Enums.OrderStatus.Dispatched => "bg-info",
LittleShop.Enums.OrderStatus.Delivered => "bg-success",
LittleShop.Enums.OrderStatus.OnHold => "bg-secondary",
LittleShop.Enums.OrderStatus.Cancelled => "bg-danger",
_ => "bg-light"
}) ms-2">@order.Status</span>
</h6>
<div class="small text-muted mb-2">
@if (order.Customer != null)
{
<text><strong>@order.Customer.DisplayName</strong> - @order.Customer.CustomerType</text>
}
else
{
<text><strong>@order.ShippingName</strong> - Anonymous</text>
}
</div>
<div class="small mb-2">
<strong>£@order.TotalAmount</strong>
@if (order.Items.Any())
{
var firstItem = order.Items.First();
<text> - @firstItem.Quantity x @firstItem.ProductName</text>
@if (!string.IsNullOrEmpty(firstItem.ProductVariationName))
{
<span class="text-muted">(@firstItem.ProductVariationName)</span>
}
@if (order.Items.Count > 1)
{
<span class="text-muted"> +@(order.Items.Count - 1) more</span>
}
}
</div>
@if (!string.IsNullOrEmpty(order.TrackingNumber))
{
<div class="small text-muted">
📦 @order.TrackingNumber
</div>
}
<!-- Timeline for mobile -->
<div class="small text-muted mt-2">
@if (order.AcceptedAt.HasValue)
{
<div>✅ @order.AcceptedAt.Value.ToString("MMM dd, HH:mm")</div>
}
@if (order.PackingStartedAt.HasValue)
{
<div>📦 @order.PackingStartedAt.Value.ToString("MMM dd, HH:mm")</div>
}
@if (order.DispatchedAt.HasValue)
{
<div>🚚 @order.DispatchedAt.Value.ToString("MMM dd, HH:mm")</div>
}
@if (order.ExpectedDeliveryDate.HasValue)
{
<div>📅 Expected @order.ExpectedDeliveryDate.Value.ToString("MMM dd")</div>
}
@if (order.OnHoldAt.HasValue)
{
<div class="text-warning">⏸️ On Hold: @order.OnHoldReason</div>
}
</div>
</div>
<div class="col-auto">
<!-- Mobile Action Buttons -->
<div class="d-grid gap-1" style="min-width: 100px;">
<a href="@Url.Action("Details", new { id = order.Id })" class="btn btn-outline-primary btn-sm">
<i class="fas fa-eye"></i> View
</a>
@if (order.Status == LittleShop.Enums.OrderStatus.PaymentReceived)
{
<form method="post" action="@Url.Action("AcceptOrder", new { id = order.Id })">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-success btn-sm w-100">
<i class="fas fa-check"></i> Accept
</button>
</form>
}
@if (order.Status == LittleShop.Enums.OrderStatus.Accepted)
{
<form method="post" action="@Url.Action("StartPacking", new { id = order.Id })">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-warning btn-sm w-100">
<i class="fas fa-box"></i> Pack
</button>
</form>
}
@if (order.Status != LittleShop.Enums.OrderStatus.OnHold && order.Status != LittleShop.Enums.OrderStatus.Delivered && order.Status != LittleShop.Enums.OrderStatus.Cancelled)
{
<button type="button" class="btn btn-secondary btn-sm w-100" data-bs-toggle="modal" data-bs-target="#holdModal-@order.Id">
<i class="fas fa-pause"></i> Hold
</button>
}
</div>
</div>
</div>
</div>
</div>
}
</div>
}
else
{
<div class="text-center py-4">
<i class="fas fa-shopping-cart fa-3x text-muted mb-3"></i>
<p class="text-muted">No orders found yet.</p>
<i class="fas fa-clipboard-list fa-3x text-muted mb-3"></i>
<p class="text-muted">No orders found in this category.</p>
@if (currentTab == "accept")
{
<p class="text-muted">Orders will appear here when payment is received.</p>
}
</div>
}
</div>
</div>
</div>
@* Hold Modals for each order *@
@foreach (var order in orders.Where(o => o.Status != LittleShop.Enums.OrderStatus.OnHold && o.Status != LittleShop.Enums.OrderStatus.Delivered && o.Status != LittleShop.Enums.OrderStatus.Cancelled))
{
<div class="modal fade" id="holdModal-@order.Id" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" action="@Url.Action("PutOnHold", new { id = order.Id })">
@Html.AntiForgeryToken()
<div class="modal-header">
<h5 class="modal-title">Put Order On Hold</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="reason-@order.Id" class="form-label">Reason for Hold</label>
<input name="reason" id="reason-@order.Id" class="form-control" placeholder="e.g., Awaiting stock, Customer query" required />
</div>
<div class="mb-3">
<label for="notes-@order.Id" class="form-label">Additional Notes</label>
<textarea name="notes" id="notes-@order.Id" class="form-control" rows="2" placeholder="Optional additional details..."></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-warning">Put On Hold</button>
</div>
</form>
</div>
</div>
</div>
}

View File

@@ -0,0 +1,146 @@
@model LittleShop.DTOs.CreateProductVariationDto
@{
ViewData["Title"] = "Create Product Variation";
var product = ViewData["Product"] as LittleShop.DTOs.ProductDto;
}
<div class="row mb-4">
<div class="col">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="@Url.Action("Index")">Products</a></li>
<li class="breadcrumb-item"><a href="@Url.Action("Variations", new { id = product?.Id })">@product?.Name - Variations</a></li>
<li class="breadcrumb-item active" aria-current="page">Create Variation</li>
</ol>
</nav>
<h1><i class="fas fa-plus"></i> Create Product Variation</h1>
<p class="text-muted">Add a new quantity-based pricing option for <strong>@product?.Name</strong></p>
</div>
</div>
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Variation Details</h5>
</div>
<div class="card-body">
<form method="post">
@Html.AntiForgeryToken()
<input type="hidden" asp-for="ProductId" value="@product?.Id" />
<div class="mb-3">
<label for="Name" class="form-label">Name</label>
<input name="Name" id="Name" value="@Model?.Name" class="form-control @(ViewData.ModelState["Name"]?.Errors.Count > 0 ? "is-invalid" : "")" placeholder="e.g., Single Item, Twin Pack, Triple Pack" required />
@if(ViewData.ModelState["Name"]?.Errors.Count > 0)
{
<div class="invalid-feedback">
@ViewData.ModelState["Name"]?.Errors.FirstOrDefault()?.ErrorMessage
</div>
}
<div class="form-text">A descriptive name for this variation</div>
</div>
<div class="mb-3">
<label for="Description" class="form-label">Description</label>
<textarea name="Description" id="Description" class="form-control @(ViewData.ModelState["Description"]?.Errors.Count > 0 ? "is-invalid" : "")" rows="2" placeholder="e.g., Best value for 3 items">@Model?.Description</textarea>
@if(ViewData.ModelState["Description"]?.Errors.Count > 0)
{
<div class="invalid-feedback">
@ViewData.ModelState["Description"]?.Errors.FirstOrDefault()?.ErrorMessage
</div>
}
<div class="form-text">Optional description to help customers understand the value</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="Quantity" class="form-label">Quantity</label>
<input name="Quantity" id="Quantity" value="@Model?.Quantity" type="number" class="form-control @(ViewData.ModelState["Quantity"]?.Errors.Count > 0 ? "is-invalid" : "")" min="1" placeholder="3" required />
@if(ViewData.ModelState["Quantity"]?.Errors.Count > 0)
{
<div class="invalid-feedback">
@ViewData.ModelState["Quantity"]?.Errors.FirstOrDefault()?.ErrorMessage
</div>
}
<div class="form-text">
Number of items in this variation
@{
var existingQuantities = ViewData["ExistingQuantities"] as List<int>;
}
@if (existingQuantities?.Any() == true)
{
<br><small class="text-muted">Already used: @string.Join(", ", existingQuantities)</small>
}
</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="Price" class="form-label">Price (£)</label>
<input name="Price" id="Price" value="@Model?.Price" type="number" step="0.01" class="form-control @(ViewData.ModelState["Price"]?.Errors.Count > 0 ? "is-invalid" : "")" min="0.01" placeholder="25.00" required />
@if(ViewData.ModelState["Price"]?.Errors.Count > 0)
{
<div class="invalid-feedback">
@ViewData.ModelState["Price"]?.Errors.FirstOrDefault()?.ErrorMessage
</div>
}
<div class="form-text">Total price for this quantity</div>
</div>
</div>
</div>
<div class="mb-3">
<label for="SortOrder" class="form-label">Sort Order</label>
<input name="SortOrder" id="SortOrder" value="@(Model?.SortOrder ?? 0)" type="number" class="form-control @(ViewData.ModelState["SortOrder"]?.Errors.Count > 0 ? "is-invalid" : "")" min="0" placeholder="0" />
@if(ViewData.ModelState["SortOrder"]?.Errors.Count > 0)
{
<div class="invalid-feedback">
@ViewData.ModelState["SortOrder"]?.Errors.FirstOrDefault()?.ErrorMessage
</div>
}
<div class="form-text">Order in which this variation appears (0 = first)</div>
</div>
<div class="mb-3">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Create Variation
</button>
<a href="@Url.Action("Variations", new { id = product?.Id })" class="btn btn-secondary">
<i class="fas fa-times"></i> Cancel
</a>
</div>
</form>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h6 class="card-title mb-0">Product Information</h6>
</div>
<div class="card-body">
<p><strong>Product:</strong> @product?.Name</p>
<p><strong>Base Price:</strong> £@product?.Price</p>
<p><strong>Category:</strong> @product?.CategoryName</p>
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<h6 class="card-title mb-0">Pricing Example</h6>
</div>
<div class="card-body">
<p class="text-muted">If you set:</p>
<ul class="text-muted">
<li>Quantity: 3</li>
<li>Price: £25.00</li>
</ul>
<p class="text-muted">Then price per unit = £8.33 (vs £@product?.Price base price)</p>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,121 @@
@model LittleShop.DTOs.UpdateProductVariationDto
@{
ViewData["Title"] = "Edit Product Variation";
var product = ViewData["Product"] as LittleShop.DTOs.ProductDto;
}
<div class="row mb-4">
<div class="col">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="@Url.Action("Index")">Products</a></li>
<li class="breadcrumb-item"><a href="@Url.Action("Variations", new { id = product?.Id })">@product?.Name - Variations</a></li>
<li class="breadcrumb-item active" aria-current="page">Edit Variation</li>
</ol>
</nav>
<h1><i class="fas fa-edit"></i> Edit Product Variation</h1>
<p class="text-muted">Edit the quantity-based pricing option for <strong>@product?.Name</strong></p>
</div>
</div>
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Variation Details</h5>
</div>
<div class="card-body">
<form method="post">
@Html.AntiForgeryToken()
<div class="mb-3">
<label asp-for="Name" class="form-label">Name</label>
<input asp-for="Name" class="form-control" placeholder="e.g., Single Item, Twin Pack, Triple Pack" />
<span asp-validation-for="Name" class="text-danger"></span>
<div class="form-text">A descriptive name for this variation</div>
</div>
<div class="mb-3">
<label asp-for="Description" class="form-label">Description</label>
<textarea asp-for="Description" class="form-control" rows="2" placeholder="e.g., Best value for 3 items"></textarea>
<span asp-validation-for="Description" class="text-danger"></span>
<div class="form-text">Optional description to help customers understand the value</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label asp-for="Quantity" class="form-label">Quantity</label>
<input asp-for="Quantity" type="number" class="form-control" min="1" placeholder="3" />
<span asp-validation-for="Quantity" class="text-danger"></span>
<div class="form-text">Number of items in this variation</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label asp-for="Price" class="form-label">Price (£)</label>
<input asp-for="Price" type="number" step="0.01" class="form-control" min="0.01" placeholder="25.00" />
<span asp-validation-for="Price" class="text-danger"></span>
<div class="form-text">Total price for this quantity</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label asp-for="SortOrder" class="form-label">Sort Order</label>
<input asp-for="SortOrder" type="number" class="form-control" min="0" placeholder="0" />
<span asp-validation-for="SortOrder" class="text-danger"></span>
<div class="form-text">Order in which this variation appears (0 = first)</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<div class="form-check">
<input asp-for="IsActive" class="form-check-input" type="checkbox" />
<label asp-for="IsActive" class="form-check-label">Active</label>
</div>
<div class="form-text">Uncheck to hide this variation from customers</div>
</div>
</div>
</div>
<div class="mb-3">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Update Variation
</button>
<a href="@Url.Action("Variations", new { id = product?.Id })" class="btn btn-secondary">
<i class="fas fa-times"></i> Cancel
</a>
</div>
</form>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h6 class="card-title mb-0">Product Information</h6>
</div>
<div class="card-body">
<p><strong>Product:</strong> @product?.Name</p>
<p><strong>Base Price:</strong> £@product?.Price</p>
<p><strong>Category:</strong> @product?.CategoryName</p>
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<h6 class="card-title mb-0">Pricing Calculator</h6>
</div>
<div class="card-body">
<p class="text-muted">Price per unit calculation:</p>
<p class="text-muted">Total Price ÷ Quantity = Price per Unit</p>
<p class="text-muted">Compare with base price of £@product?.Price per item</p>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,123 @@
@{
ViewData["Title"] = "Import Products";
}
<div class="row mb-4">
<div class="col">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="@Url.Action("Index")">Products</a></li>
<li class="breadcrumb-item active" aria-current="page">Import</li>
</ol>
</nav>
<h1><i class="fas fa-upload"></i> Import Products</h1>
<p class="text-muted">Bulk import products with variations from CSV files</p>
</div>
</div>
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Upload CSV File</h5>
</div>
<div class="card-body">
<form method="post" enctype="multipart/form-data" asp-action="Import">
@Html.AntiForgeryToken()
@if (!ViewData.ModelState.IsValid)
{
<div class="alert alert-danger">
<ul class="mb-0">
@foreach (var error in ViewData.ModelState.Values.SelectMany(v => v.Errors))
{
<li>@error.ErrorMessage</li>
}
</ul>
</div>
}
<div class="mb-3">
<label for="file" class="form-label">CSV File</label>
<input type="file" name="file" id="file" class="form-control" accept=".csv" required />
<div class="form-text">
Select a CSV file containing product data.
<a href="@Url.Action("DownloadTemplate")" class="text-decoration-none">
<i class="fas fa-download"></i> Download template
</a>
</div>
</div>
<div class="mb-3">
<button type="submit" class="btn btn-primary">
<i class="fas fa-upload"></i> Import Products
</button>
<a href="@Url.Action("Index")" class="btn btn-secondary">
<i class="fas fa-times"></i> Cancel
</a>
</div>
</form>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h6 class="card-title mb-0">CSV Format</h6>
</div>
<div class="card-body">
<p><strong>Required Columns:</strong></p>
<ul class="small">
<li><code>Name</code> - Product name</li>
<li><code>Description</code> - Product description</li>
<li><code>Price</code> - Base price (e.g., 29.99)</li>
<li><code>Weight</code> - Weight value (e.g., 150)</li>
<li><code>WeightUnit</code> - Grams/Kilogram</li>
<li><code>StockQuantity</code> - Available stock</li>
<li><code>CategoryName</code> - Must match existing category</li>
</ul>
<p><strong>Optional Columns:</strong></p>
<ul class="small">
<li><code>IsActive</code> - true/false</li>
<li><code>Variations</code> - Format: Name:Qty:Price;Name:Qty:Price</li>
<li><code>PhotoUrls</code> - URL1;URL2;URL3</li>
</ul>
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<h6 class="card-title mb-0">Variations Format</h6>
</div>
<div class="card-body">
<p class="small">Example variations column:</p>
<code class="small">Single Item:1:10.00;Twin Pack:2:19.00;Triple Pack:3:25.00</code>
<p class="small mt-2">This creates:</p>
<ul class="small">
<li>Single Item: 1 for £10.00</li>
<li>Twin Pack: 2 for £19.00</li>
<li>Triple Pack: 3 for £25.00</li>
</ul>
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<h6 class="card-title mb-0">Quick Actions</h6>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="@Url.Action("DownloadTemplate")" class="btn btn-outline-info btn-sm">
<i class="fas fa-download"></i> Download Template
</a>
<a href="@Url.Action("Export")" class="btn btn-outline-success btn-sm">
<i class="fas fa-file-export"></i> Export Current Products
</a>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,158 @@
@model LittleShop.DTOs.ProductImportResultDto
@{
ViewData["Title"] = "Import Results";
}
<div class="row mb-4">
<div class="col">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="@Url.Action("Index")">Products</a></li>
<li class="breadcrumb-item"><a href="@Url.Action("Import")">Import</a></li>
<li class="breadcrumb-item active" aria-current="page">Results</li>
</ol>
</nav>
<h1><i class="fas fa-chart-bar"></i> Import Results</h1>
</div>
</div>
<!-- Summary Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card text-white bg-info">
<div class="card-body">
<h4 class="card-title">@Model.TotalRows</h4>
<p class="card-text">Total Rows</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-white bg-success">
<div class="card-body">
<h4 class="card-title">@Model.SuccessfulImports</h4>
<p class="card-text">Successful</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-white bg-danger">
<div class="card-body">
<h4 class="card-title">@Model.FailedImports</h4>
<p class="card-text">Failed</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-white bg-warning">
<div class="card-body">
<h4 class="card-title">@(((double)Model.SuccessfulImports / Model.TotalRows * 100).ToString("F1"))%</h4>
<p class="card-text">Success Rate</p>
</div>
</div>
</div>
</div>
@if (Model.SuccessfulImports > 0)
{
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0 text-success">
<i class="fas fa-check-circle"></i> Successfully Imported Products (@Model.SuccessfulImports)
</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Name</th>
<th>Category</th>
<th>Price</th>
<th>Stock</th>
<th>Variations</th>
</tr>
</thead>
<tbody>
@foreach (var product in Model.ImportedProducts)
{
<tr>
<td>
<strong>@product.Name</strong>
<br><small class="text-muted">@product.Description.Substring(0, Math.Min(50, product.Description.Length))@(product.Description.Length > 50 ? "..." : "")</small>
</td>
<td>
<span class="badge bg-secondary">@product.CategoryName</span>
</td>
<td>
<strong>£@product.Price</strong>
</td>
<td>
@product.StockQuantity
</td>
<td>
@if (product.Variations.Any())
{
<span class="badge bg-info">@product.Variations.Count variations</span>
}
else
{
<span class="text-muted">None</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
@if (Model.Errors.Any())
{
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0 text-danger">
<i class="fas fa-exclamation-triangle"></i> Import Errors (@Model.Errors.Count)
</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Row</th>
<th>Product Name</th>
<th>Errors</th>
</tr>
</thead>
<tbody>
@foreach (var error in Model.Errors)
{
<tr>
<td><strong>@error.RowNumber</strong></td>
<td>@error.ProductName</td>
<td>
@foreach (var errorMsg in error.ErrorMessages)
{
<div class="text-danger small">@errorMsg</div>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
<div class="mt-3">
<a href="@Url.Action("Index")" class="btn btn-success">
<i class="fas fa-arrow-left"></i> Back to Products
</a>
<a href="@Url.Action("Import")" class="btn btn-primary">
<i class="fas fa-upload"></i> Import More
</a>
</div>

View File

@@ -9,9 +9,17 @@
<h1><i class="fas fa-box"></i> Products</h1>
</div>
<div class="col-auto">
<a href="@Url.Action("Create")" class="btn btn-primary">
<i class="fas fa-plus"></i> Add Product
</a>
<div class="btn-group">
<a href="@Url.Action("Create")" class="btn btn-primary">
<i class="fas fa-plus"></i> Add Product
</a>
<a href="@Url.Action("Import")" class="btn btn-outline-success">
<i class="fas fa-upload"></i> Import CSV
</a>
<a href="@Url.Action("Export")" class="btn btn-outline-info">
<i class="fas fa-download"></i> Export CSV
</a>
</div>
</div>
</div>
@@ -27,6 +35,7 @@
<th>Name</th>
<th>Category</th>
<th>Price</th>
<th>Variations</th>
<th>Stock</th>
<th>Weight</th>
<th>Status</th>
@@ -59,6 +68,16 @@
<td>
<strong>£@product.Price</strong>
</td>
<td>
@if (product.Variations.Any())
{
<span class="badge bg-info">@product.Variations.Count() variations</span>
}
else
{
<span class="text-muted">None</span>
}
</td>
<td>
@if (product.StockQuantity > 0)
{
@@ -84,12 +103,15 @@
</td>
<td>
<div class="btn-group btn-group-sm">
<a href="@Url.Action("Edit", new { id = product.Id })" class="btn btn-outline-primary">
<a href="@Url.Action("Edit", new { id = product.Id })" class="btn btn-outline-primary" title="Edit Product">
<i class="fas fa-edit"></i>
</a>
<form method="post" action="@Url.Action("Delete", new { id = product.Id })" class="d-inline"
<a href="@Url.Action("Variations", new { id = product.Id })" class="btn btn-outline-info" title="Manage Variations">
<i class="fas fa-list"></i>
</a>
<form method="post" action="@Url.Action("Delete", new { id = product.Id })" class="d-inline"
onsubmit="return confirm('Are you sure you want to delete this product?')">
<button type="submit" class="btn btn-outline-danger">
<button type="submit" class="btn btn-outline-danger" title="Delete Product">
<i class="fas fa-trash"></i>
</button>
</form>

View File

@@ -0,0 +1,113 @@
@model IEnumerable<LittleShop.DTOs.ProductVariationDto>
@{
ViewData["Title"] = "Product Variations";
var product = ViewData["Product"] as LittleShop.DTOs.ProductDto;
}
<div class="row mb-4">
<div class="col">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="@Url.Action("Index")">Products</a></li>
<li class="breadcrumb-item active" aria-current="page">@product?.Name - Variations</li>
</ol>
</nav>
<h1><i class="fas fa-list"></i> Product Variations</h1>
<p class="text-muted">Manage quantity-based pricing for <strong>@product?.Name</strong></p>
</div>
<div class="col-auto">
<a href="@Url.Action("CreateVariation", new { productId = product?.Id })" class="btn btn-primary">
<i class="fas fa-plus"></i> Add Variation
</a>
</div>
</div>
<div class="card">
<div class="card-body">
@if (Model.Any())
{
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Quantity</th>
<th>Price</th>
<th>Price per Unit</th>
<th>Sort Order</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var variation in Model.OrderBy(v => v.SortOrder))
{
<tr>
<td>
<strong>@variation.Name</strong>
</td>
<td>
@variation.Description
</td>
<td>
<span class="badge bg-secondary">@variation.Quantity items</span>
</td>
<td>
<strong>£@variation.Price</strong>
</td>
<td>
£@variation.PricePerUnit.ToString("F2")
</td>
<td>
@variation.SortOrder
</td>
<td>
@if (variation.IsActive)
{
<span class="badge bg-success">Active</span>
}
else
{
<span class="badge bg-danger">Inactive</span>
}
</td>
<td>
<div class="btn-group btn-group-sm">
<a href="@Url.Action("EditVariation", new { id = variation.Id })" class="btn btn-outline-primary" title="Edit Variation">
<i class="fas fa-edit"></i>
</a>
<form method="post" action="@Url.Action("DeleteVariation", new { id = variation.Id })" class="d-inline"
onsubmit="return confirm('Are you sure you want to delete this variation?')">
<button type="submit" class="btn btn-outline-danger" title="Delete Variation">
<i class="fas fa-trash"></i>
</button>
</form>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<div class="text-center py-4">
<i class="fas fa-list fa-3x text-muted mb-3"></i>
<p class="text-muted">No variations found for this product.</p>
<p class="text-muted">
<a href="@Url.Action("CreateVariation", new { productId = product?.Id })">Create your first variation</a>
to offer quantity-based pricing (e.g., 1 for £10, 2 for £19, 3 for £25).
</p>
</div>
}
</div>
</div>
<div class="mt-3">
<a href="@Url.Action("Index")" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Back to Products
</a>
</div>