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

@@ -21,6 +21,7 @@ public class ProductService : IProductService
return await _context.Products
.Include(p => p.Category)
.Include(p => p.Photos)
.Include(p => p.Variations.Where(v => v.IsActive))
.Where(p => p.IsActive)
.Select(p => new ProductDto
{
@@ -43,6 +44,20 @@ public class ProductService : IProductService
FilePath = ph.FilePath,
AltText = ph.AltText,
SortOrder = ph.SortOrder
}).ToList(),
Variations = p.Variations.OrderBy(v => v.SortOrder).Select(v => new ProductVariationDto
{
Id = v.Id,
ProductId = v.ProductId,
Name = v.Name,
Description = v.Description,
Quantity = v.Quantity,
Price = v.Price,
PricePerUnit = v.PricePerUnit,
SortOrder = v.SortOrder,
IsActive = v.IsActive,
CreatedAt = v.CreatedAt,
UpdatedAt = v.UpdatedAt
}).ToList()
})
.ToListAsync();
@@ -53,6 +68,7 @@ public class ProductService : IProductService
return await _context.Products
.Include(p => p.Category)
.Include(p => p.Photos)
.Include(p => p.Variations.Where(v => v.IsActive))
.Where(p => p.IsActive && p.CategoryId == categoryId)
.Select(p => new ProductDto
{
@@ -75,6 +91,20 @@ public class ProductService : IProductService
FilePath = ph.FilePath,
AltText = ph.AltText,
SortOrder = ph.SortOrder
}).ToList(),
Variations = p.Variations.OrderBy(v => v.SortOrder).Select(v => new ProductVariationDto
{
Id = v.Id,
ProductId = v.ProductId,
Name = v.Name,
Description = v.Description,
Quantity = v.Quantity,
Price = v.Price,
PricePerUnit = v.PricePerUnit,
SortOrder = v.SortOrder,
IsActive = v.IsActive,
CreatedAt = v.CreatedAt,
UpdatedAt = v.UpdatedAt
}).ToList()
})
.ToListAsync();
@@ -85,6 +115,7 @@ public class ProductService : IProductService
var product = await _context.Products
.Include(p => p.Category)
.Include(p => p.Photos)
.Include(p => p.Variations.Where(v => v.IsActive))
.FirstOrDefaultAsync(p => p.Id == id);
if (product == null) return null;
@@ -110,6 +141,20 @@ public class ProductService : IProductService
FilePath = ph.FilePath,
AltText = ph.AltText,
SortOrder = ph.SortOrder
}).ToList(),
Variations = product.Variations.OrderBy(v => v.SortOrder).Select(v => new ProductVariationDto
{
Id = v.Id,
ProductId = v.ProductId,
Name = v.Name,
Description = v.Description,
Quantity = v.Quantity,
Price = v.Price,
PricePerUnit = v.PricePerUnit,
SortOrder = v.SortOrder,
IsActive = v.IsActive,
CreatedAt = v.CreatedAt,
UpdatedAt = v.UpdatedAt
}).ToList()
};
}
@@ -149,7 +194,8 @@ public class ProductService : IProductService
CreatedAt = product.CreatedAt,
UpdatedAt = product.UpdatedAt,
IsActive = product.IsActive,
Photos = new List<ProductPhotoDto>()
Photos = new List<ProductPhotoDto>(),
Variations = new List<ProductVariationDto>()
};
}
@@ -293,6 +339,7 @@ public class ProductService : IProductService
var query = _context.Products
.Include(p => p.Category)
.Include(p => p.Photos)
.Include(p => p.Variations.Where(v => v.IsActive))
.Where(p => p.IsActive);
if (!string.IsNullOrWhiteSpace(searchTerm))
@@ -327,4 +374,149 @@ public class ProductService : IProductService
}).ToList()
}).ToListAsync();
}
public async Task<ProductVariationDto> CreateProductVariationAsync(CreateProductVariationDto createVariationDto)
{
var product = await _context.Products.FindAsync(createVariationDto.ProductId);
if (product == null)
throw new ArgumentException("Product not found");
// Check if variation with this quantity already exists
var existingVariation = await _context.ProductVariations
.FirstOrDefaultAsync(v => v.ProductId == createVariationDto.ProductId &&
v.Quantity == createVariationDto.Quantity &&
v.IsActive);
if (existingVariation != null)
throw new ArgumentException($"A variation with quantity {createVariationDto.Quantity} already exists for this product");
var pricePerUnit = createVariationDto.Price / createVariationDto.Quantity;
var variation = new ProductVariation
{
Id = Guid.NewGuid(),
ProductId = createVariationDto.ProductId,
Name = createVariationDto.Name,
Description = createVariationDto.Description,
Quantity = createVariationDto.Quantity,
Price = createVariationDto.Price,
PricePerUnit = pricePerUnit,
SortOrder = createVariationDto.SortOrder,
IsActive = true,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
_context.ProductVariations.Add(variation);
try
{
await _context.SaveChangesAsync();
}
catch (Microsoft.EntityFrameworkCore.DbUpdateException ex) when (ex.InnerException?.Message.Contains("UNIQUE constraint failed") == true)
{
throw new ArgumentException($"A variation with quantity {createVariationDto.Quantity} already exists for this product");
}
return new ProductVariationDto
{
Id = variation.Id,
ProductId = variation.ProductId,
Name = variation.Name,
Description = variation.Description,
Quantity = variation.Quantity,
Price = variation.Price,
PricePerUnit = variation.PricePerUnit,
SortOrder = variation.SortOrder,
IsActive = variation.IsActive,
CreatedAt = variation.CreatedAt,
UpdatedAt = variation.UpdatedAt
};
}
public async Task<bool> UpdateProductVariationAsync(Guid id, UpdateProductVariationDto updateVariationDto)
{
var variation = await _context.ProductVariations.FindAsync(id);
if (variation == null) return false;
if (!string.IsNullOrEmpty(updateVariationDto.Name))
variation.Name = updateVariationDto.Name;
if (!string.IsNullOrEmpty(updateVariationDto.Description))
variation.Description = updateVariationDto.Description;
if (updateVariationDto.Quantity.HasValue)
variation.Quantity = updateVariationDto.Quantity.Value;
if (updateVariationDto.Price.HasValue)
variation.Price = updateVariationDto.Price.Value;
if (updateVariationDto.Quantity.HasValue || updateVariationDto.Price.HasValue)
variation.PricePerUnit = variation.Price / variation.Quantity;
if (updateVariationDto.SortOrder.HasValue)
variation.SortOrder = updateVariationDto.SortOrder.Value;
if (updateVariationDto.IsActive.HasValue)
variation.IsActive = updateVariationDto.IsActive.Value;
variation.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
return true;
}
public async Task<bool> DeleteProductVariationAsync(Guid id)
{
var variation = await _context.ProductVariations.FindAsync(id);
if (variation == null) return false;
variation.IsActive = false;
variation.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
return true;
}
public async Task<IEnumerable<ProductVariationDto>> GetProductVariationsAsync(Guid productId)
{
return await _context.ProductVariations
.Where(v => v.ProductId == productId && v.IsActive)
.OrderBy(v => v.SortOrder)
.Select(v => new ProductVariationDto
{
Id = v.Id,
ProductId = v.ProductId,
Name = v.Name,
Description = v.Description,
Quantity = v.Quantity,
Price = v.Price,
PricePerUnit = v.PricePerUnit,
SortOrder = v.SortOrder,
IsActive = v.IsActive,
CreatedAt = v.CreatedAt,
UpdatedAt = v.UpdatedAt
})
.ToListAsync();
}
public async Task<ProductVariationDto?> GetProductVariationByIdAsync(Guid id)
{
var variation = await _context.ProductVariations.FindAsync(id);
if (variation == null) return null;
return new ProductVariationDto
{
Id = variation.Id,
ProductId = variation.ProductId,
Name = variation.Name,
Description = variation.Description,
Quantity = variation.Quantity,
Price = variation.Price,
PricePerUnit = variation.PricePerUnit,
SortOrder = variation.SortOrder,
IsActive = variation.IsActive,
CreatedAt = variation.CreatedAt,
UpdatedAt = variation.UpdatedAt
};
}
}