Add variant collections system and enhance ProductVariant with weight/stock tracking
This commit introduces a comprehensive variant management system and enhances the existing ProductVariant model with per-variant weight overrides and stock tracking, integrated across Admin Panel and TeleBot. Features Added: - Variant Collections: Reusable variant templates (e.g., "Standard Sizes") - Admin UI for managing variant collections (CRUD operations) - Dynamic variant editor with JavaScript-based UI - Per-variant weight and weight unit overrides - Per-variant stock level tracking - SalesLedger model for financial tracking ProductVariant Enhancements: - Added Weight (decimal, nullable) field for variant-specific weights - Added WeightUnit (enum, nullable) field for variant-specific units - Maintains backward compatibility with product-level weights TeleBot Integration: - Enhanced variant selection UI to display stock levels - Shows weight information with proper unit conversion (µg, g, oz, lb, ml, L) - Compact button format: "Medium (15 in stock, 350g)" - Real-time stock availability display Database Migrations: - 20250928014850_AddVariantCollectionsAndSalesLedger - 20250928155814_AddWeightToProductVariants Technical Changes: - Updated Product model to support VariantCollectionId and VariantsJson - Extended ProductService with variant collection operations - Enhanced OrderService to handle variant-specific pricing and weights - Updated LittleShop.Client DTOs to match server models - Added JavaScript dynamic variant form builder Files Modified: 15 Files Added: 17 Lines Changed: ~2000 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
566
LittleShop/Pages/Admin/Products/ProductEditor.razor
Normal file
566
LittleShop/Pages/Admin/Products/ProductEditor.razor
Normal file
@@ -0,0 +1,566 @@
|
||||
@page "/Admin/Products/Editor"
|
||||
@page "/Admin/Products/Editor/{ProductId:guid}"
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using LittleShop.Services
|
||||
@using LittleShop.DTOs
|
||||
@using LittleShop.Models
|
||||
@using LittleShop.Enums
|
||||
@inject IProductService ProductService
|
||||
@inject ICategoryService CategoryService
|
||||
@inject IVariantCollectionService VariantCollectionService
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JS
|
||||
@attribute [Authorize(Policy = "AdminOnly")]
|
||||
|
||||
<PageTitle>@(ProductId == null ? "Create Product" : "Edit Product")</PageTitle>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2>
|
||||
<i class="fas fa-box"></i>
|
||||
@(ProductId == null ? "Create New Product" : $"Edit: {_productName}")
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<p class="mt-2">Loading product data...</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="_model" OnValidSubmit="HandleSaveProduct">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-4 order-lg-1 order-2 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5><i class="fas fa-images"></i> Product Photos</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (_photos.Any())
|
||||
{
|
||||
<RadzenCarousel @bind-SelectedIndex="_selectedPhotoIndex" Style="height: 300px;">
|
||||
@foreach (var photo in _photos)
|
||||
{
|
||||
<RadzenCarouselItem>
|
||||
<div class="position-relative">
|
||||
<img src="@photo.FilePath" class="w-100" style="height: 300px; object-fit: contain;" alt="@photo.AltText" />
|
||||
<button type="button" class="btn btn-danger btn-sm position-absolute top-0 end-0 m-2"
|
||||
@onclick="() => DeletePhoto(photo.Id)">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</RadzenCarouselItem>
|
||||
}
|
||||
</RadzenCarousel>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="fas fa-camera fa-3x mb-3"></i>
|
||||
<p>No photos uploaded</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<hr />
|
||||
|
||||
<RadzenFileInput TValue="string" @bind-Value="_uploadedPhotoBase64"
|
||||
Accept="image/*"
|
||||
Change="@OnPhotoSelected"
|
||||
class="w-100 mb-2" />
|
||||
|
||||
@if (_uploading)
|
||||
{
|
||||
<div class="text-center">
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
<span class="ms-2">Uploading...</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-8 order-lg-2 order-1 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5><i class="fas fa-info-circle"></i> Product Details</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Product Name *</label>
|
||||
<RadzenTextBox @bind-Value="_model.Name" class="w-100" Placeholder="Enter product name" />
|
||||
<ValidationMessage For="@(() => _model.Name)" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description *</label>
|
||||
<RadzenTextArea @bind-Value="_model.Description" class="w-100" Rows="4" Placeholder="Enter product description" />
|
||||
<ValidationMessage For="@(() => _model.Description)" />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">Price (£) *</label>
|
||||
<RadzenNumeric @bind-Value="_model.Price" TValue="decimal" class="w-100" ShowUpDown="false" Step="0.01M" />
|
||||
<ValidationMessage For="@(() => _model.Price)" />
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">Weight/Volume *</label>
|
||||
<RadzenNumeric @bind-Value="_model.Weight" TValue="decimal" class="w-100" ShowUpDown="false" Step="0.01M" />
|
||||
<ValidationMessage For="@(() => _model.Weight)" />
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">Unit</label>
|
||||
<RadzenDropDown @bind-Value="_model.WeightUnit" Data="@_weightUnits" class="w-100" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Category *</label>
|
||||
<RadzenDropDown @bind-Value="_model.CategoryId" Data="@_categories"
|
||||
TextProperty="Name" ValueProperty="Id"
|
||||
class="w-100" Placeholder="Select category" />
|
||||
<ValidationMessage For="@(() => _model.CategoryId)" />
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Stock Quantity</label>
|
||||
<RadzenNumeric @bind-Value="_model.StockQuantity" TValue="int" class="w-100" ShowUpDown="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<RadzenCheckBox @bind-Value="_model.IsActive" />
|
||||
<label class="form-check-label ms-2">
|
||||
Product is Active
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RadzenAccordion class="mt-4">
|
||||
<Items>
|
||||
<RadzenAccordionItem Text="Variants" Icon="palette">
|
||||
<div class="p-3">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Variant Collection (Optional)</label>
|
||||
<RadzenDropDown @bind-Value="_model.VariantCollectionId" Data="@_variantCollections"
|
||||
TextProperty="Name" ValueProperty="Id" AllowClear="true"
|
||||
class="w-100" Placeholder="Select variant collection" />
|
||||
<small class="form-text text-muted">
|
||||
Select a reusable variant template or leave empty for custom variants
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Variants JSON</label>
|
||||
<RadzenTextArea @bind-Value="_model.VariantsJson" class="w-100 font-monospace" Rows="6"
|
||||
Placeholder='[{"Size":"M","Colour":"Red","Stock":10}]' />
|
||||
<small class="form-text text-muted">
|
||||
Define product variants as JSON array of objects
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</RadzenAccordionItem>
|
||||
|
||||
<RadzenAccordionItem Text="Multi-Buys" Icon="tags">
|
||||
<div class="p-3">
|
||||
<RadzenButton Text="Add Multi-Buy" Icon="add" ButtonStyle="ButtonStyle.Success"
|
||||
Size="ButtonSize.Small" class="mb-3" Click="@AddMultiBuy" />
|
||||
|
||||
@if (_multiBuys.Any())
|
||||
{
|
||||
<RadzenDataGrid Data="@_multiBuys" TItem="ProductMultiBuyDto" class="mb-3">
|
||||
<Columns>
|
||||
<RadzenDataGridColumn TItem="ProductMultiBuyDto" Property="Quantity" Title="Quantity" Width="100px" />
|
||||
<RadzenDataGridColumn TItem="ProductMultiBuyDto" Property="Price" Title="Price" Width="120px">
|
||||
<Template Context="multiBuy">
|
||||
£@multiBuy.Price.ToString("F2")
|
||||
</Template>
|
||||
</RadzenDataGridColumn>
|
||||
<RadzenDataGridColumn TItem="ProductMultiBuyDto" Property="PricePerUnit" Title="Per Unit" Width="120px">
|
||||
<Template Context="multiBuy">
|
||||
£@multiBuy.PricePerUnit.ToString("F2")
|
||||
</Template>
|
||||
</RadzenDataGridColumn>
|
||||
<RadzenDataGridColumn TItem="ProductMultiBuyDto" Title="Active" Width="80px">
|
||||
<Template Context="multiBuy">
|
||||
<RadzenCheckBox @bind-Value="multiBuy.IsActive" />
|
||||
</Template>
|
||||
</RadzenDataGridColumn>
|
||||
<RadzenDataGridColumn TItem="ProductMultiBuyDto" Title="" Width="80px">
|
||||
<Template Context="multiBuy">
|
||||
<RadzenButton Icon="delete" ButtonStyle="ButtonStyle.Danger"
|
||||
Size="ButtonSize.Small" Click="@(() => RemoveMultiBuy(multiBuy))" />
|
||||
</Template>
|
||||
</RadzenDataGridColumn>
|
||||
</Columns>
|
||||
</RadzenDataGrid>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-info-circle"></i> No multi-buy offers configured. Add quantity-based pricing above.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</RadzenAccordionItem>
|
||||
|
||||
<RadzenAccordionItem Text="Sales History" Icon="trending_up">
|
||||
<div class="p-3">
|
||||
@if (_salesLedger.Any())
|
||||
{
|
||||
<RadzenDataGrid Data="@_salesLedger" TItem="SalesLedgerDto">
|
||||
<Columns>
|
||||
<RadzenDataGridColumn TItem="SalesLedgerDto" Property="SoldAt" Title="Date" Width="150px">
|
||||
<Template Context="sale">
|
||||
@sale.SoldAt.ToString("MMM dd, yyyy HH:mm")
|
||||
</Template>
|
||||
</RadzenDataGridColumn>
|
||||
<RadzenDataGridColumn TItem="SalesLedgerDto" Property="Quantity" Title="Qty" Width="80px" />
|
||||
<RadzenDataGridColumn TItem="SalesLedgerDto" Title="Fiat Price" Width="120px">
|
||||
<Template Context="sale">
|
||||
@sale.FiatCurrency £@sale.SalePriceFiat.ToString("F2")
|
||||
</Template>
|
||||
</RadzenDataGridColumn>
|
||||
<RadzenDataGridColumn TItem="SalesLedgerDto" Title="Crypto Price" Width="180px">
|
||||
<Template Context="sale">
|
||||
@if (sale.SalePriceBTC.HasValue)
|
||||
{
|
||||
@sale.SalePriceBTC.Value.ToString("F8") @sale.Cryptocurrency
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">N/A</span>
|
||||
}
|
||||
</Template>
|
||||
</RadzenDataGridColumn>
|
||||
</Columns>
|
||||
</RadzenDataGrid>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-info-circle"></i> No sales recorded yet for this product.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</RadzenAccordionItem>
|
||||
</Items>
|
||||
</RadzenAccordion>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between flex-wrap gap-2">
|
||||
<RadzenButton Text="Back to Products" Icon="arrow_back" ButtonStyle="ButtonStyle.Secondary"
|
||||
Click="@(() => Navigation.NavigateTo("/Admin/Products"))" />
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<RadzenButton Text="Save" Icon="save" ButtonStyle="ButtonStyle.Primary"
|
||||
ButtonType="ButtonType.Submit" Disabled="@_saving" />
|
||||
|
||||
@if (ProductId != null)
|
||||
{
|
||||
<RadzenButton Text="Clone" Icon="content_copy" ButtonStyle="ButtonStyle.Info"
|
||||
Click="@HandleCloneProduct" Disabled="@_saving" />
|
||||
}
|
||||
|
||||
<RadzenButton Text="Save + New" Icon="add" ButtonStyle="ButtonStyle.Success"
|
||||
Click="@HandleSaveAndNew" Disabled="@_saving" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</EditForm>
|
||||
|
||||
@if (_saveError != null)
|
||||
{
|
||||
<div class="alert alert-danger alert-dismissible fade show mt-3" role="alert">
|
||||
<i class="fas fa-exclamation-triangle"></i> @_saveError
|
||||
<button type="button" class="btn-close" @onclick="@(() => _saveError = null)"></button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public Guid? ProductId { get; set; }
|
||||
|
||||
private ProductFormModel _model = new();
|
||||
private string _productName = "";
|
||||
private bool _loading = true;
|
||||
private bool _saving = false;
|
||||
private bool _uploading = false;
|
||||
private string? _saveError;
|
||||
private int _selectedPhotoIndex = 0;
|
||||
private string? _uploadedPhotoBase64;
|
||||
|
||||
private List<CategoryDto> _categories = new();
|
||||
private List<VariantCollectionDto> _variantCollections = new();
|
||||
private List<ProductPhotoDto> _photos = new();
|
||||
private List<ProductMultiBuyDto> _multiBuys = new();
|
||||
private List<SalesLedgerDto> _salesLedger = new();
|
||||
private ProductWeightUnit[] _weightUnits = Enum.GetValues<ProductWeightUnit>();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadDropdownData();
|
||||
|
||||
if (ProductId != null)
|
||||
{
|
||||
await LoadProduct(ProductId.Value);
|
||||
}
|
||||
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task LoadDropdownData()
|
||||
{
|
||||
_categories = (await CategoryService.GetAllCategoriesAsync()).ToList();
|
||||
_variantCollections = (await VariantCollectionService.GetAllVariantCollectionsAsync())
|
||||
.Where(vc => vc.IsActive)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task LoadProduct(Guid productId)
|
||||
{
|
||||
var product = await ProductService.GetProductByIdAsync(productId);
|
||||
if (product == null)
|
||||
{
|
||||
Navigation.NavigateTo("/Admin/Products");
|
||||
return;
|
||||
}
|
||||
|
||||
_productName = product.Name;
|
||||
_model = new ProductFormModel
|
||||
{
|
||||
Name = product.Name,
|
||||
Description = product.Description,
|
||||
Price = product.Price,
|
||||
Weight = product.Weight,
|
||||
WeightUnit = product.WeightUnit,
|
||||
StockQuantity = product.StockQuantity,
|
||||
CategoryId = product.CategoryId,
|
||||
VariantCollectionId = product.VariantCollectionId,
|
||||
VariantsJson = product.VariantsJson ?? "[]",
|
||||
IsActive = product.IsActive
|
||||
};
|
||||
|
||||
_photos = product.Photos.ToList();
|
||||
_multiBuys = product.MultiBuys.ToList();
|
||||
}
|
||||
|
||||
private async Task HandleSaveProduct()
|
||||
{
|
||||
await SaveProduct(false);
|
||||
}
|
||||
|
||||
private async Task HandleSaveAndNew()
|
||||
{
|
||||
await SaveProduct(true);
|
||||
}
|
||||
|
||||
private async Task SaveProduct(bool createNew)
|
||||
{
|
||||
_saving = true;
|
||||
_saveError = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (ProductId == null)
|
||||
{
|
||||
var createDto = new CreateProductDto
|
||||
{
|
||||
Name = _model.Name,
|
||||
Description = _model.Description,
|
||||
Price = _model.Price,
|
||||
Weight = _model.Weight,
|
||||
WeightUnit = _model.WeightUnit,
|
||||
StockQuantity = _model.StockQuantity,
|
||||
CategoryId = _model.CategoryId,
|
||||
VariantCollectionId = _model.VariantCollectionId,
|
||||
VariantsJson = _model.VariantsJson
|
||||
};
|
||||
|
||||
var created = await ProductService.CreateProductAsync(createDto);
|
||||
|
||||
if (createNew)
|
||||
{
|
||||
_model = new ProductFormModel();
|
||||
_photos.Clear();
|
||||
_multiBuys.Clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
Navigation.NavigateTo($"/Admin/Products/Editor/{created.Id}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var updateDto = new UpdateProductDto
|
||||
{
|
||||
Name = _model.Name,
|
||||
Description = _model.Description,
|
||||
Price = _model.Price,
|
||||
Weight = _model.Weight,
|
||||
WeightUnit = _model.WeightUnit,
|
||||
StockQuantity = _model.StockQuantity,
|
||||
CategoryId = _model.CategoryId,
|
||||
VariantCollectionId = _model.VariantCollectionId,
|
||||
VariantsJson = _model.VariantsJson,
|
||||
IsActive = _model.IsActive
|
||||
};
|
||||
|
||||
await ProductService.UpdateProductAsync(ProductId.Value, updateDto);
|
||||
|
||||
if (createNew)
|
||||
{
|
||||
Navigation.NavigateTo("/Admin/Products/Editor");
|
||||
}
|
||||
else
|
||||
{
|
||||
await LoadProduct(ProductId.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_saveError = $"Error saving product: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleCloneProduct()
|
||||
{
|
||||
if (ProductId == null) return;
|
||||
|
||||
_saving = true;
|
||||
_saveError = null;
|
||||
|
||||
try
|
||||
{
|
||||
var createDto = new CreateProductDto
|
||||
{
|
||||
Name = $"{_model.Name} (Copy)",
|
||||
Description = _model.Description,
|
||||
Price = _model.Price,
|
||||
Weight = _model.Weight,
|
||||
WeightUnit = _model.WeightUnit,
|
||||
StockQuantity = _model.StockQuantity,
|
||||
CategoryId = _model.CategoryId,
|
||||
VariantCollectionId = _model.VariantCollectionId,
|
||||
VariantsJson = _model.VariantsJson
|
||||
};
|
||||
|
||||
var cloned = await ProductService.CreateProductAsync(createDto);
|
||||
Navigation.NavigateTo($"/Admin/Products/Editor/{cloned.Id}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_saveError = $"Error cloning product: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPhotoSelected(string value)
|
||||
{
|
||||
}
|
||||
|
||||
private async Task DeletePhoto(Guid photoId)
|
||||
{
|
||||
if (ProductId == null) return;
|
||||
|
||||
var confirmed = await JS.InvokeAsync<bool>("confirm", "Delete this photo?");
|
||||
if (!confirmed) return;
|
||||
|
||||
try
|
||||
{
|
||||
_photos.RemoveAll(p => p.Id == photoId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_saveError = $"Error deleting photo: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private void AddMultiBuy()
|
||||
{
|
||||
_multiBuys.Add(new ProductMultiBuyDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Quantity = 1,
|
||||
Price = _model.Price,
|
||||
PricePerUnit = _model.Price,
|
||||
IsActive = true,
|
||||
SortOrder = _multiBuys.Count
|
||||
});
|
||||
}
|
||||
|
||||
private void RemoveMultiBuy(ProductMultiBuyDto multiBuy)
|
||||
{
|
||||
_multiBuys.Remove(multiBuy);
|
||||
}
|
||||
|
||||
public class ProductFormModel
|
||||
{
|
||||
[Required]
|
||||
[StringLength(200)]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
[Required]
|
||||
public string Description { get; set; } = "";
|
||||
|
||||
[Required]
|
||||
[Range(0.01, double.MaxValue)]
|
||||
public decimal Price { get; set; } = 0;
|
||||
|
||||
[Required]
|
||||
[Range(0, double.MaxValue)]
|
||||
public decimal Weight { get; set; } = 0;
|
||||
|
||||
public ProductWeightUnit WeightUnit { get; set; } = ProductWeightUnit.Kilogram;
|
||||
|
||||
public int StockQuantity { get; set; } = 0;
|
||||
|
||||
[Required]
|
||||
public Guid CategoryId { get; set; }
|
||||
|
||||
public Guid? VariantCollectionId { get; set; }
|
||||
|
||||
public string? VariantsJson { get; set; } = "[]";
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
||||
public class SalesLedgerDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid OrderId { get; set; }
|
||||
public int Quantity { get; set; }
|
||||
public decimal SalePriceFiat { get; set; }
|
||||
public string FiatCurrency { get; set; } = "";
|
||||
public decimal? SalePriceBTC { get; set; }
|
||||
public string? Cryptocurrency { get; set; }
|
||||
public DateTime SoldAt { get; set; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user