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:
@@ -25,6 +25,8 @@ public class OrderService : IOrderService
|
||||
.Include(o => o.Customer)
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(oi => oi.Product)
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(oi => oi.ProductVariation)
|
||||
.Include(o => o.Payments)
|
||||
.OrderByDescending(o => o.CreatedAt)
|
||||
.ToListAsync();
|
||||
@@ -38,6 +40,8 @@ public class OrderService : IOrderService
|
||||
.Include(o => o.Customer)
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(oi => oi.Product)
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(oi => oi.ProductVariation)
|
||||
.Include(o => o.Payments)
|
||||
.Where(o => o.IdentityReference == identityReference)
|
||||
.OrderByDescending(o => o.CreatedAt)
|
||||
@@ -52,6 +56,8 @@ public class OrderService : IOrderService
|
||||
.Include(o => o.Customer)
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(oi => oi.Product)
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(oi => oi.ProductVariation)
|
||||
.Include(o => o.Payments)
|
||||
.Where(o => o.CustomerId == customerId)
|
||||
.OrderByDescending(o => o.CreatedAt)
|
||||
@@ -66,6 +72,8 @@ public class OrderService : IOrderService
|
||||
.Include(o => o.Customer)
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(oi => oi.Product)
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(oi => oi.ProductVariation)
|
||||
.Include(o => o.Payments)
|
||||
.FirstOrDefaultAsync(o => o.Id == id);
|
||||
|
||||
@@ -134,14 +142,31 @@ public class OrderService : IOrderService
|
||||
throw new ArgumentException($"Product {itemDto.ProductId} not found or inactive");
|
||||
}
|
||||
|
||||
ProductVariation? variation = null;
|
||||
decimal unitPrice = product.Price;
|
||||
|
||||
if (itemDto.ProductVariationId.HasValue)
|
||||
{
|
||||
variation = await _context.ProductVariations.FindAsync(itemDto.ProductVariationId.Value);
|
||||
if (variation == null || !variation.IsActive || variation.ProductId != itemDto.ProductId)
|
||||
{
|
||||
throw new ArgumentException($"Product variation {itemDto.ProductVariationId} not found, inactive, or doesn't belong to product {itemDto.ProductId}");
|
||||
}
|
||||
|
||||
// When using a variation, the quantity represents how many of that variation bundle
|
||||
// For example: buying 2 of the "3 for £25" variation means 6 total items for £50
|
||||
unitPrice = variation.Price;
|
||||
}
|
||||
|
||||
var orderItem = new OrderItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrderId = order.Id,
|
||||
ProductId = itemDto.ProductId,
|
||||
ProductVariationId = itemDto.ProductVariationId,
|
||||
Quantity = itemDto.Quantity,
|
||||
UnitPrice = product.Price,
|
||||
TotalPrice = product.Price * itemDto.Quantity
|
||||
UnitPrice = unitPrice,
|
||||
TotalPrice = unitPrice * itemDto.Quantity
|
||||
};
|
||||
|
||||
_context.OrderItems.Add(orderItem);
|
||||
@@ -262,12 +287,30 @@ public class OrderService : IOrderService
|
||||
CreatedAt = order.CreatedAt,
|
||||
UpdatedAt = order.UpdatedAt,
|
||||
PaidAt = order.PaidAt,
|
||||
|
||||
// Workflow timestamps
|
||||
AcceptedAt = order.AcceptedAt,
|
||||
PackingStartedAt = order.PackingStartedAt,
|
||||
DispatchedAt = order.DispatchedAt,
|
||||
ExpectedDeliveryDate = order.ExpectedDeliveryDate,
|
||||
ActualDeliveryDate = order.ActualDeliveryDate,
|
||||
OnHoldAt = order.OnHoldAt,
|
||||
|
||||
// Workflow details
|
||||
AcceptedByUser = order.AcceptedByUser,
|
||||
PackedByUser = order.PackedByUser,
|
||||
DispatchedByUser = order.DispatchedByUser,
|
||||
OnHoldReason = order.OnHoldReason,
|
||||
|
||||
// Legacy field (for backward compatibility)
|
||||
ShippedAt = order.ShippedAt,
|
||||
Items = order.Items.Select(oi => new OrderItemDto
|
||||
{
|
||||
Id = oi.Id,
|
||||
ProductId = oi.ProductId,
|
||||
ProductVariationId = oi.ProductVariationId,
|
||||
ProductName = oi.Product.Name,
|
||||
ProductVariationName = oi.ProductVariation?.Name,
|
||||
Quantity = oi.Quantity,
|
||||
UnitPrice = oi.UnitPrice,
|
||||
TotalPrice = oi.TotalPrice
|
||||
@@ -289,4 +332,162 @@ public class OrderService : IOrderService
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
// Enhanced workflow methods
|
||||
public async Task<bool> AcceptOrderAsync(Guid id, string userName, AcceptOrderDto acceptDto)
|
||||
{
|
||||
var order = await _context.Orders.FindAsync(id);
|
||||
if (order == null || order.Status != OrderStatus.PaymentReceived)
|
||||
return false;
|
||||
|
||||
order.Status = OrderStatus.Accepted;
|
||||
order.AcceptedAt = DateTime.UtcNow;
|
||||
order.AcceptedByUser = userName;
|
||||
if (!string.IsNullOrEmpty(acceptDto.Notes))
|
||||
order.Notes = acceptDto.Notes;
|
||||
order.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Order {OrderId} accepted by {User}", id, userName);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> StartPackingAsync(Guid id, string userName, StartPackingDto packingDto)
|
||||
{
|
||||
var order = await _context.Orders.FindAsync(id);
|
||||
if (order == null || order.Status != OrderStatus.Accepted)
|
||||
return false;
|
||||
|
||||
order.Status = OrderStatus.Packing;
|
||||
order.PackingStartedAt = DateTime.UtcNow;
|
||||
order.PackedByUser = userName;
|
||||
if (!string.IsNullOrEmpty(packingDto.Notes))
|
||||
order.Notes = packingDto.Notes;
|
||||
order.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Order {OrderId} packing started by {User}", id, userName);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> DispatchOrderAsync(Guid id, string userName, DispatchOrderDto dispatchDto)
|
||||
{
|
||||
var order = await _context.Orders.FindAsync(id);
|
||||
if (order == null || order.Status != OrderStatus.Packing)
|
||||
return false;
|
||||
|
||||
order.Status = OrderStatus.Dispatched;
|
||||
order.DispatchedAt = DateTime.UtcNow;
|
||||
order.DispatchedByUser = userName;
|
||||
order.TrackingNumber = dispatchDto.TrackingNumber;
|
||||
|
||||
// Calculate expected delivery date (working days only)
|
||||
var expectedDate = DateTime.UtcNow.AddDays(dispatchDto.EstimatedDeliveryDays);
|
||||
while (expectedDate.DayOfWeek == DayOfWeek.Saturday || expectedDate.DayOfWeek == DayOfWeek.Sunday)
|
||||
{
|
||||
expectedDate = expectedDate.AddDays(1);
|
||||
}
|
||||
order.ExpectedDeliveryDate = expectedDate;
|
||||
|
||||
if (!string.IsNullOrEmpty(dispatchDto.Notes))
|
||||
order.Notes = dispatchDto.Notes;
|
||||
order.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Set legacy field for backward compatibility
|
||||
order.ShippedAt = order.DispatchedAt;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Order {OrderId} dispatched by {User} with tracking {TrackingNumber}", id, userName, dispatchDto.TrackingNumber);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> PutOnHoldAsync(Guid id, string userName, PutOnHoldDto holdDto)
|
||||
{
|
||||
var order = await _context.Orders.FindAsync(id);
|
||||
if (order == null || order.Status == OrderStatus.OnHold)
|
||||
return false;
|
||||
|
||||
order.Status = OrderStatus.OnHold;
|
||||
order.OnHoldAt = DateTime.UtcNow;
|
||||
order.OnHoldReason = holdDto.Reason;
|
||||
if (!string.IsNullOrEmpty(holdDto.Notes))
|
||||
order.Notes = holdDto.Notes;
|
||||
order.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Order {OrderId} put on hold by {User}: {Reason}", id, userName, holdDto.Reason);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> RemoveFromHoldAsync(Guid id, string userName)
|
||||
{
|
||||
var order = await _context.Orders.FindAsync(id);
|
||||
if (order == null || order.Status != OrderStatus.OnHold)
|
||||
return false;
|
||||
|
||||
// Return to appropriate status based on workflow progress
|
||||
if (order.AcceptedAt.HasValue && order.PackingStartedAt.HasValue)
|
||||
order.Status = OrderStatus.Packing;
|
||||
else if (order.AcceptedAt.HasValue)
|
||||
order.Status = OrderStatus.Accepted;
|
||||
else
|
||||
order.Status = OrderStatus.PaymentReceived;
|
||||
|
||||
order.OnHoldAt = null;
|
||||
order.OnHoldReason = null;
|
||||
order.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Order {OrderId} removed from hold by {User}, returned to {Status}", id, userName, order.Status);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> MarkDeliveredAsync(Guid id, MarkDeliveredDto deliveredDto)
|
||||
{
|
||||
var order = await _context.Orders.FindAsync(id);
|
||||
if (order == null || order.Status != OrderStatus.Dispatched)
|
||||
return false;
|
||||
|
||||
order.Status = OrderStatus.Delivered;
|
||||
order.ActualDeliveryDate = deliveredDto.ActualDeliveryDate ?? DateTime.UtcNow;
|
||||
if (!string.IsNullOrEmpty(deliveredDto.Notes))
|
||||
order.Notes = deliveredDto.Notes;
|
||||
order.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Order {OrderId} marked as delivered", id);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Workflow queries
|
||||
public async Task<IEnumerable<OrderDto>> GetOrdersByStatusAsync(OrderStatus status)
|
||||
{
|
||||
var orders = await _context.Orders
|
||||
.Include(o => o.Customer)
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(oi => oi.Product)
|
||||
.Include(o => o.Items)
|
||||
.ThenInclude(oi => oi.ProductVariation)
|
||||
.Include(o => o.Payments)
|
||||
.Where(o => o.Status == status)
|
||||
.OrderByDescending(o => o.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
return orders.Select(MapToDto);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<OrderDto>> GetOrdersRequiringActionAsync()
|
||||
{
|
||||
return await GetOrdersByStatusAsync(OrderStatus.PaymentReceived);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<OrderDto>> GetOrdersForPackingAsync()
|
||||
{
|
||||
return await GetOrdersByStatusAsync(OrderStatus.Accepted);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<OrderDto>> GetOrdersOnHoldAsync()
|
||||
{
|
||||
return await GetOrdersByStatusAsync(OrderStatus.OnHold);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user