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

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