gyorsrendelés, előrendelés, előrendelés management, hasonlók
This commit is contained in:
parent
51f546caec
commit
c86ef0e416
|
|
@ -9,6 +9,7 @@ using FruitBank.Common.Dtos;
|
|||
using FruitBank.Common.Entities;
|
||||
using FruitBank.Common.Enums;
|
||||
using FruitBank.Common.Interfaces;
|
||||
using FruitBank.Common.Server;
|
||||
using FruitBank.Common.Server.Interfaces;
|
||||
using FruitBank.Common.Server.Services.SignalRs;
|
||||
using FruitBank.Common.SignalRs;
|
||||
|
|
@ -72,7 +73,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
|
|||
private readonly CustomOrderModelFactory _orderModelFactory;
|
||||
private readonly ICustomOrderSignalREndpointServer _customOrderSignalREndpoint;
|
||||
private readonly IPermissionService _permissionService;
|
||||
private readonly IGenericAttributeService _genericAttributeService;
|
||||
//private readonly IGenericAttributeService _genericAttributeService;
|
||||
private readonly FruitBankAttributeService _fruitBankAttributeService;
|
||||
private readonly INotificationService _notificationService;
|
||||
private readonly ICustomerService _customerService;
|
||||
private readonly IProductService _productService;
|
||||
|
|
@ -89,6 +91,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
|
|||
protected readonly ITaxService _taxService;
|
||||
protected readonly MeasurementService _measurementService;
|
||||
protected readonly IWorkflowMessageService _workflowMessageService;
|
||||
protected readonly FruitBankNotificationService _fruitBankNotificationService;
|
||||
protected readonly IAddressService _addressService;
|
||||
private static readonly char[] _separator = [','];
|
||||
// ... other dependencies
|
||||
|
||||
|
|
@ -121,7 +125,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
|
|||
IOrderModelFactory orderModelFactory,
|
||||
ICustomOrderSignalREndpointServer customOrderSignalREndpoint,
|
||||
IPermissionService permissionService,
|
||||
IGenericAttributeService genericAttributeService,
|
||||
//IGenericAttributeService genericAttributeService,
|
||||
FruitBankAttributeService fruitBankAttributeService,
|
||||
INotificationService notificationService,
|
||||
ICustomerService customerService,
|
||||
IProductService productService,
|
||||
|
|
@ -136,7 +141,10 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
|
|||
IImportManager importManager,
|
||||
IDateTimeHelper dateTimeHelper,
|
||||
ITaxService taxService,
|
||||
MeasurementService measurementService, IWorkflowMessageService workflowMessageService)
|
||||
MeasurementService measurementService,
|
||||
IWorkflowMessageService workflowMessageService,
|
||||
FruitBankNotificationService fruitBankNotificationService,
|
||||
IAddressService addressService)
|
||||
{
|
||||
_logger = new Logger<CustomOrderController>(logWriters.ToArray());
|
||||
|
||||
|
|
@ -147,7 +155,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
|
|||
_orderModelFactory = orderModelFactory as CustomOrderModelFactory;
|
||||
_customOrderSignalREndpoint = customOrderSignalREndpoint;
|
||||
_permissionService = permissionService;
|
||||
_genericAttributeService = genericAttributeService;
|
||||
//_genericAttributeService = genericAttributeService;
|
||||
_fruitBankAttributeService = fruitBankAttributeService;
|
||||
_notificationService = notificationService;
|
||||
_customerService = customerService;
|
||||
_productService = productService;
|
||||
|
|
@ -165,7 +174,11 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
|
|||
_taxService = taxService;
|
||||
_measurementService = measurementService;
|
||||
_workflowMessageService = workflowMessageService;
|
||||
_fruitBankNotificationService = fruitBankNotificationService;
|
||||
_addressService = addressService;
|
||||
|
||||
// ... initialize other deps
|
||||
|
||||
}
|
||||
|
||||
#region CustomOrderSignalREndpoint
|
||||
|
|
@ -434,7 +447,15 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
|
|||
|
||||
// store attributes in GenericAttribute table
|
||||
//await _genericAttributeService.SaveAttributeAsync(order, nameof(IMeasurable.IsMeasurable), model.IsMeasurable, _storeContext.GetCurrentStore().Id);
|
||||
await _genericAttributeService.SaveAttributeAsync(order, nameof(IOrderDto.DateOfReceipt), model.DateOfReceipt, _storeContext.GetCurrentStore().Id);
|
||||
//await _genericAttributeService.SaveAttributeAsync(order, nameof(IOrderDto.DateOfReceipt), model.DateOfReceipt, _storeContext.GetCurrentStore().Id);
|
||||
await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync<Order, string>(
|
||||
order.Id,
|
||||
nameof(IOrderDto.DateOfReceipt),
|
||||
model.DateOfReceipt.HasValue
|
||||
? model.DateOfReceipt.Value.ToString("MM/dd/yyyy HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture)
|
||||
: null,
|
||||
_storeContext.GetCurrentStore().Id);
|
||||
|
||||
|
||||
var orderDto = await _dbContext.OrderDtos.GetByIdAsync(model.OrderId, true);
|
||||
await _sendToClient.SendOrderChanged(orderDto);
|
||||
|
|
@ -494,6 +515,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
|
|||
{
|
||||
//no address at all, cannot create order
|
||||
_logger.Error($"Cannot create order for customer {customer.Id}, no billing address found.");
|
||||
_notificationService.ErrorNotification("Cannot create order for customer, no billing address found. Please create a billing address for the customer first.");
|
||||
return RedirectToAction("List");
|
||||
}
|
||||
}
|
||||
|
|
@ -547,7 +569,28 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
|
|||
//var orderDto = await _dbContext.OrderDtos.GetByIdAsync(order.Id, true);
|
||||
//await _sendToClient.SendMeasuringNotification("Módosult a rendelés, mérjétek újra!", orderDto);
|
||||
//var updatedOrder = await _orderService.GetOrderByIdAsync(order.Id);
|
||||
await _workflowMessageService.SendOrderPlacedCustomerNotificationAsync(order, order.CustomerLanguageId);
|
||||
//await _workflowMessageService.SendOrderPlacedCustomerNotificationAsync(order, order.CustomerLanguageId);
|
||||
if(customer.BillingAddressId.HasValue)
|
||||
{
|
||||
//var billingAddress = await _addressService.GetAddressByIdAsync((int)customer.BillingAddressId);
|
||||
if (billingAddress.Email != null)
|
||||
{
|
||||
if (!billingAddress.Email.EndsWith("inval.id"))
|
||||
{
|
||||
var messageResult = await _fruitBankNotificationService.SendOrderPlacedCustomerNotificationAsync(order, order.CustomerLanguageId);
|
||||
if (messageResult.First() != -1)
|
||||
{
|
||||
_notificationService.SuccessNotification("Order placed email sent to customer.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Warning($"Order placed email was not sent to customer {customer.Id} because of invalid email address: {billingAddress.Email}");
|
||||
_notificationService.WarningNotification("Order placed email was not sent to customer because of invalid email address.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
return RedirectToAction("Edit", "Order", new { id = order.Id });
|
||||
}
|
||||
|
||||
|
|
@ -602,7 +645,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
|
|||
{
|
||||
unitPricesIncludeDiscounts = true;
|
||||
}
|
||||
|
||||
|
||||
//itt ha includeDiscounts van, akkor már a beírt ár megy be?
|
||||
var orderItem = await CreateOrderItem(product, order, orderProductItem, isMeasurable, unitPricesIncludeDiscounts, customer, store);
|
||||
|
||||
|
|
@ -967,6 +1010,79 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
|
|||
}
|
||||
|
||||
[HttpGet]
|
||||
[CheckPermission(StandardPermission.Catalog.PRODUCTS_VIEW)]
|
||||
public virtual async Task<IActionResult> PreorderProductSearchAutoComplete(string term)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(term) || term.Length < 2)
|
||||
return Json(new List<object>());
|
||||
|
||||
const int maxResults = 30;
|
||||
var today = DateTime.UtcNow.Date;
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
|
||||
// Load preorder window attributes in two batch queries
|
||||
var gaStart = await _dbContext.GenericAttributes.Table
|
||||
.Where(ga => ga.KeyGroup == nameof(Product)
|
||||
&& ga.Key == FruitBankConst.PreorderWindowStart
|
||||
&& ga.StoreId == store.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var gaEnd = await _dbContext.GenericAttributes.Table
|
||||
.Where(ga => ga.KeyGroup == nameof(Product)
|
||||
&& ga.Key == FruitBankConst.PreorderWindowEnd
|
||||
&& ga.StoreId == store.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var startById = gaStart.ToDictionary(g => g.EntityId, g => g.Value);
|
||||
var endById = gaEnd.ToDictionary(g => g.EntityId, g => g.Value);
|
||||
|
||||
// Product IDs currently in the preorder window
|
||||
var availableIds = startById.Keys
|
||||
.Intersect(endById.Keys)
|
||||
.Where(id =>
|
||||
{
|
||||
DateTime.TryParse(startById[id], out var ws);
|
||||
DateTime.TryParse(endById[id], out var we);
|
||||
return ws.Date <= today && today <= we.Date;
|
||||
})
|
||||
.ToHashSet();
|
||||
|
||||
if (!availableIds.Any())
|
||||
return Json(new List<object>());
|
||||
|
||||
// Search within available products only
|
||||
var products = await _productService.SearchProductsAsync(
|
||||
keywords: term,
|
||||
pageIndex: 0,
|
||||
pageSize: maxResults);
|
||||
|
||||
var inWindow = products.Where(p => availableIds.Contains(p.Id)).ToList();
|
||||
if (!inWindow.Any())
|
||||
return Json(new List<object>());
|
||||
|
||||
var productDtosById = await _dbContext.ProductDtos
|
||||
.GetAllByIds(inWindow.Select(p => p.Id))
|
||||
.ToDictionaryAsync(k => k.Id, v => v);
|
||||
|
||||
var result = new List<object>();
|
||||
foreach (var product in inWindow)
|
||||
{
|
||||
productDtosById.TryGetValue(product.Id, out var dto);
|
||||
result.Add(new
|
||||
{
|
||||
label = $"{product.Name} [KÉSZLET: {(product.StockQuantity + (dto?.IncomingQuantity ?? 0))}] [ÁR: {product.Price}]",
|
||||
value = product.Id,
|
||||
sku = product.Sku,
|
||||
price = product.Price,
|
||||
stockQuantity = product.StockQuantity,
|
||||
incomingQuantity = dto?.IncomingQuantity ?? 0
|
||||
});
|
||||
}
|
||||
|
||||
return Json(result);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[CheckPermission(StandardPermission.Catalog.PRODUCTS_VIEW)]
|
||||
public virtual async Task<IActionResult> ProductSearchUnfilteredAutoComplete(string term)
|
||||
{
|
||||
|
|
@ -975,7 +1091,6 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
|
|||
|
||||
const int maxResults = 30;
|
||||
|
||||
// Search products by name or SKU
|
||||
var products = await _productService.SearchProductsAsync(
|
||||
keywords: term,
|
||||
pageIndex: 0,
|
||||
|
|
@ -989,24 +1104,21 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
|
|||
var productDto = productDtosById[product.Id];
|
||||
if (productDto != null)
|
||||
{
|
||||
|
||||
result.Add(new
|
||||
{
|
||||
label = $"{product.Name} [RENDELHETŐ: {(product.StockQuantity + productDto.IncomingQuantity)}] [ÁR: {product.Price}]",
|
||||
value = product.Id,
|
||||
sku = product.Sku,
|
||||
price = product.Price,
|
||||
stockQuantity = product.StockQuantity,
|
||||
label = $"{product.Name} [RENDELHETŐ: {(product.StockQuantity + productDto.IncomingQuantity)}] [ÁR: {product.Price}]",
|
||||
value = product.Id,
|
||||
sku = product.Sku,
|
||||
price = product.Price,
|
||||
stockQuantity = product.StockQuantity,
|
||||
incomingQuantity = productDto.IncomingQuantity,
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return Json(result);
|
||||
}
|
||||
|
||||
//[HttpPost]
|
||||
//public async Task<IActionResult> CreateInvoice(int orderId)
|
||||
//{
|
||||
// try
|
||||
|
|
@ -1404,14 +1516,38 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
|
|||
|
||||
|
||||
[HttpPost]
|
||||
//[IgnoreAntiforgeryToken]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> FruitBankAddProductToOrder(int orderId, string productsJson)
|
||||
[ValidateAntiForgeryToken]
|
||||
[CheckPermission(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE)]
|
||||
public async Task<IActionResult> SendOrderEmailToCustomer(int orderId)
|
||||
{
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.Info($"AddProductToOrder - OrderId: {orderId}, ProductsJson: {productsJson}");
|
||||
var order = await _orderService.GetOrderByIdAsync(orderId);
|
||||
if (order == null)
|
||||
return Json(new { success = false, message = "Rendelés nem található" });
|
||||
|
||||
var sentIds = await _fruitBankNotificationService.SendOrderInfoEmailAsync(order);
|
||||
var sentCount = sentIds?.Count(id => id > 0) ?? 0;
|
||||
|
||||
if (sentCount > 0)
|
||||
return Json(new { success = true, message = $"Email sikeresen elküldve ({sentCount} címzett)" });
|
||||
|
||||
return Json(new { success = false, message = "Az email nem került elküldésre. Ellenőrizze az email sablont és az ügyfél email címét." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"SendOrderEmailToCustomer error – orderId={orderId}: {ex.Message}", ex);
|
||||
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
[CheckPermission(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE)]
|
||||
public async Task<IActionResult> FruitBankAddProductToOrder(int orderId, string productsJson)
|
||||
{
|
||||
try {
|
||||
_logger.Info($"AddProductToOrder - OrderId: {orderId}, ProductsJson: {productsJson}");
|
||||
if (!await _permissionService.AuthorizeAsync(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE))
|
||||
return Json(new { success = false, message = "Access denied" });
|
||||
|
||||
|
|
@ -1759,235 +1895,372 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// FruitBank Order Grid – new server-side DataTables endpoint
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Returns the new FruitBank order list view (replaces the default NopCommerce grid).
|
||||
/// </summary>
|
||||
[CheckPermission(StandardPermission.Orders.ORDERS_VIEW)]
|
||||
public async Task<IActionResult> NewList(
|
||||
List<int> orderStatuses = null,
|
||||
List<int> paymentStatuses = null,
|
||||
List<int> shippingStatuses = null)
|
||||
{
|
||||
var model = await _orderModelFactory.PrepareOrderSearchModelAsync(new OrderSearchModelExtended
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// FruitBank Order Grid – new server-side DataTables endpoint
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Returns the new FruitBank order list view (replaces the default NopCommerce grid).
|
||||
/// </summary>
|
||||
[CheckPermission(StandardPermission.Orders.ORDERS_VIEW)]
|
||||
public async Task<IActionResult> NewList(
|
||||
List<int> orderStatuses = null,
|
||||
List<int> paymentStatuses = null,
|
||||
List<int> shippingStatuses = null)
|
||||
{
|
||||
OrderStatusIds = orderStatuses,
|
||||
PaymentStatusIds = paymentStatuses,
|
||||
ShippingStatusIds = shippingStatuses,
|
||||
Length = 50,
|
||||
AvailablePageSizes = "20,50,100,500",
|
||||
SortColumn = "Id",
|
||||
SortColumnDirection = "desc",
|
||||
});
|
||||
model.SetGridSort("Id", "desc");
|
||||
model.SetGridPageSize(50, "20,50,100,500");
|
||||
|
||||
return View(
|
||||
"~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Order/FruitBankOrderList.cshtml",
|
||||
model);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DataTables server-side endpoint for the FruitBank order grid.
|
||||
/// Handles NopCommerce base filters + FruitBank-specific filters + per-column search + sort + pagination.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[CheckPermission(StandardPermission.Orders.ORDERS_VIEW)]
|
||||
public async Task<IActionResult> FruitBankOrderList()
|
||||
{
|
||||
// ── 1. Parse DataTables protocol params ────────────────────────
|
||||
_ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1);
|
||||
_ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0);
|
||||
_ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 50 : Math.Min(length, 500);
|
||||
|
||||
// Sort column
|
||||
_ = int.TryParse(Request.Form["order[0][column]"].FirstOrDefault(), out int sortColIdx);
|
||||
var sortDir = Request.Form["order[0][dir]"].FirstOrDefault() ?? "desc";
|
||||
var sortColName = Request.Form[$"columns[{sortColIdx}][data]"].FirstOrDefault() ?? "Id";
|
||||
|
||||
// Per-column search values keyed by column data-field name
|
||||
var colSearch = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (int ci = 0; Request.Form.ContainsKey($"columns[{ci}][data]"); ci++)
|
||||
{
|
||||
var cData = Request.Form[$"columns[{ci}][data]"].FirstOrDefault();
|
||||
var cVal = Request.Form[$"columns[{ci}][search][value]"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(cData) && !string.IsNullOrWhiteSpace(cVal))
|
||||
colSearch[cData] = cVal.Trim();
|
||||
}
|
||||
|
||||
// ── 2. Parse custom filter params ─────────────────────────────
|
||||
DateTime? startDate = null, endDate = null;
|
||||
if (DateTime.TryParse(Request.Form["StartDate"].FirstOrDefault(), out var sd)) startDate = sd;
|
||||
if (DateTime.TryParse(Request.Form["EndDate"].FirstOrDefault(), out var ed)) endDate = ed;
|
||||
|
||||
var orderStatusIds = Request.Form["OrderStatusIds"]
|
||||
.Where(v => int.TryParse(v, out _)).Select(int.Parse).Where(id => id > 0).ToList();
|
||||
var paymentStatusIds = Request.Form["PaymentStatusIds"]
|
||||
.Where(v => int.TryParse(v, out _)).Select(int.Parse).Where(id => id > 0).ToList();
|
||||
var shippingStatusIds = Request.Form["ShippingStatusIds"]
|
||||
.Where(v => int.TryParse(v, out _)).Select(int.Parse).Where(id => id > 0).ToList();
|
||||
|
||||
var billingCompany = Request.Form["BillingCompany"].FirstOrDefault(); // holds customer ID (string)
|
||||
|
||||
bool? isMeasurableFilter = null;
|
||||
var imStr = Request.Form["IsMeasurable"].FirstOrDefault();
|
||||
if (imStr == "true") isMeasurableFilter = true;
|
||||
if (imStr == "false") isMeasurableFilter = false;
|
||||
|
||||
bool? hasInnvoiceFilter = null;
|
||||
var hiStr = Request.Form["HasInnvoiceTechId"].FirstOrDefault();
|
||||
if (hiStr == "true") hasInnvoiceFilter = true;
|
||||
if (hiStr == "false") hasInnvoiceFilter = false;
|
||||
|
||||
// ── 3. Fetch data via factory (applies NopCommerce base filters)
|
||||
// We ask for a large page so all matching records come back in one shot;
|
||||
// FruitBank-specific filtering + pagination happen below in-process.
|
||||
var searchModel = new OrderSearchModelExtended
|
||||
{
|
||||
StartDate = startDate,
|
||||
EndDate = endDate,
|
||||
OrderStatusIds = orderStatusIds.Any() ? orderStatusIds : null,
|
||||
PaymentStatusIds = paymentStatusIds.Any() ? paymentStatusIds : null,
|
||||
ShippingStatusIds = shippingStatusIds.Any() ? shippingStatusIds : null,
|
||||
BillingCompany = billingCompany,
|
||||
SortColumn = "Id",
|
||||
SortColumnDirection = "desc"
|
||||
};
|
||||
// SetGridPageSize is the proper NopCommerce way to override Page/PageSize
|
||||
searchModel.SetGridPageSize(5000, "5000");
|
||||
|
||||
OrderListModelExtended orderListModel;
|
||||
try
|
||||
{
|
||||
orderListModel = await _orderModelFactory.PrepareOrderListModelExtendedAsync(searchModel);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"FruitBankOrderList – factory error: {ex.Message}", ex);
|
||||
return Json(new { draw, recordsTotal = 0, recordsFiltered = 0, data = Array.Empty<object>() });
|
||||
}
|
||||
|
||||
var rows = orderListModel.Data?.ToList() ?? new List<OrderModelExtended>();
|
||||
int total = orderListModel.RecordsTotal;
|
||||
|
||||
// ── 4. Map to lightweight DTO ──────────────────────────────────
|
||||
var dtos = rows.Select(o => new Nop.Plugin.Misc.FruitBankPlugin.Models.Orders.FruitBankOrderRowDto
|
||||
{
|
||||
Id = o.Id,
|
||||
CustomOrderNumber = o.CustomOrderNumber,
|
||||
CustomerCompany = o.CustomerCompany,
|
||||
CustomerId = o.CustomerId,
|
||||
InnvoiceTechId = o.InnvoiceTechId,
|
||||
IsAllOrderItemAvgWeightValid = o.IsAllOrderItemAvgWeightValid,
|
||||
IsMeasurable = o.IsMeasurable,
|
||||
MeasuringStatus = (int)o.MeasuringStatus,
|
||||
MeasuringStatusString = o.MeasuringStatusString,
|
||||
DateOfReceipt = o.DateOfReceipt,
|
||||
OrderStatusId = o.OrderStatusId,
|
||||
OrderStatus = o.OrderStatus,
|
||||
PaymentStatusId = o.PaymentStatusId,
|
||||
PaymentStatus = o.PaymentStatus,
|
||||
ShippingStatusId = o.ShippingStatusId,
|
||||
ShippingStatus = o.ShippingStatus,
|
||||
StoreName = o.StoreName,
|
||||
CreatedOn = o.CreatedOn,
|
||||
OrderTotal = o.OrderTotal
|
||||
}).ToList();
|
||||
|
||||
// ── 5. Apply FruitBank-specific top-level filters ──────────────
|
||||
if (isMeasurableFilter.HasValue)
|
||||
dtos = dtos.Where(o => o.IsMeasurable == isMeasurableFilter.Value).ToList();
|
||||
|
||||
if (hasInnvoiceFilter.HasValue)
|
||||
dtos = hasInnvoiceFilter.Value
|
||||
? dtos.Where(o => !string.IsNullOrEmpty(o.InnvoiceTechId)).ToList()
|
||||
: dtos.Where(o => string.IsNullOrEmpty(o.InnvoiceTechId)).ToList();
|
||||
|
||||
// ── 6. Apply per-column search ─────────────────────────────────
|
||||
foreach (var (col, val) in colSearch)
|
||||
{
|
||||
dtos = col.ToLowerInvariant() switch
|
||||
var model = await _orderModelFactory.PrepareOrderSearchModelAsync(new OrderSearchModelExtended
|
||||
{
|
||||
"customordernumber" => dtos.Where(o => o.CustomOrderNumber?.Contains(val, StringComparison.OrdinalIgnoreCase) == true).ToList(),
|
||||
"customercompany" => dtos.Where(o => o.CustomerCompany?.Contains(val, StringComparison.OrdinalIgnoreCase) == true).ToList(),
|
||||
"orderstatusid" => int.TryParse(val, out int osId) ? dtos.Where(o => o.OrderStatusId == osId).ToList() : dtos,
|
||||
"measuringstatus" => int.TryParse(val, out int msId) ? dtos.Where(o => o.MeasuringStatus == msId).ToList() : dtos,
|
||||
"ismeasurable" => bool.TryParse(val, out bool bm) ? dtos.Where(o => o.IsMeasurable == bm).ToList() : dtos,
|
||||
// InnVoice column sends 'has' or 'none' strings
|
||||
"innvoicetechid" => val == "has" ? dtos.Where(o => !string.IsNullOrEmpty(o.InnvoiceTechId)).ToList()
|
||||
: val == "none" ? dtos.Where(o => string.IsNullOrEmpty(o.InnvoiceTechId)).ToList()
|
||||
: dtos,
|
||||
_ => dtos
|
||||
OrderStatusIds = orderStatuses,
|
||||
PaymentStatusIds = paymentStatuses,
|
||||
ShippingStatusIds = shippingStatuses,
|
||||
Length = 50,
|
||||
AvailablePageSizes = "20,50,100,500",
|
||||
SortColumn = "Id",
|
||||
SortColumnDirection = "desc",
|
||||
});
|
||||
model.SetGridSort("Id", "desc");
|
||||
model.SetGridPageSize(50, "20,50,100,500");
|
||||
|
||||
return View(
|
||||
"~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Order/FruitBankOrderList.cshtml",
|
||||
model);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DataTables server-side endpoint for the FruitBank order grid.
|
||||
/// Handles NopCommerce base filters + FruitBank-specific filters + per-column search + sort + pagination.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[CheckPermission(StandardPermission.Orders.ORDERS_VIEW)]
|
||||
public async Task<IActionResult> FruitBankOrderList()
|
||||
{
|
||||
var swTotal = System.Diagnostics.Stopwatch.StartNew();
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
// ── 1. Parse DataTables protocol params ────────────────────────
|
||||
_ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1);
|
||||
_ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0);
|
||||
_ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 50 : Math.Min(length, 500);
|
||||
|
||||
// Sort column
|
||||
_ = int.TryParse(Request.Form["order[0][column]"].FirstOrDefault(), out int sortColIdx);
|
||||
var sortDir = Request.Form["order[0][dir]"].FirstOrDefault() ?? "desc";
|
||||
var sortColName = Request.Form[$"columns[{sortColIdx}][data]"].FirstOrDefault() ?? "Id";
|
||||
|
||||
// Per-column search values keyed by column data-field name
|
||||
var colSearch = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (int ci = 0; Request.Form.ContainsKey($"columns[{ci}][data]"); ci++)
|
||||
{
|
||||
var cData = Request.Form[$"columns[{ci}][data]"].FirstOrDefault();
|
||||
var cVal = Request.Form[$"columns[{ci}][search][value]"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(cData) && !string.IsNullOrWhiteSpace(cVal))
|
||||
colSearch[cData] = cVal.Trim();
|
||||
}
|
||||
|
||||
// ── 2. Parse custom filter params ─────────────────────────────
|
||||
DateTime? startDate = null, endDate = null;
|
||||
if (DateTime.TryParse(Request.Form["StartDate"].FirstOrDefault(), out var sd)) startDate = sd;
|
||||
if (DateTime.TryParse(Request.Form["EndDate"].FirstOrDefault(), out var ed)) endDate = ed;
|
||||
|
||||
var orderStatusIds = Request.Form["OrderStatusIds"]
|
||||
.Where(v => int.TryParse(v, out _)).Select(int.Parse).Where(id => id > 0).ToList();
|
||||
var paymentStatusIds = Request.Form["PaymentStatusIds"]
|
||||
.Where(v => int.TryParse(v, out _)).Select(int.Parse).Where(id => id > 0).ToList();
|
||||
var shippingStatusIds = Request.Form["ShippingStatusIds"]
|
||||
.Where(v => int.TryParse(v, out _)).Select(int.Parse).Where(id => id > 0).ToList();
|
||||
|
||||
var billingCompany = Request.Form["BillingCompany"].FirstOrDefault();
|
||||
|
||||
bool? isMeasurableFilter = null;
|
||||
var imStr = Request.Form["IsMeasurable"].FirstOrDefault();
|
||||
if (imStr == "true") isMeasurableFilter = true;
|
||||
if (imStr == "false") isMeasurableFilter = false;
|
||||
|
||||
bool? hasInnvoiceFilter = null;
|
||||
var hiStr = Request.Form["HasInnvoiceTechId"].FirstOrDefault();
|
||||
if (hiStr == "true") hasInnvoiceFilter = true;
|
||||
if (hiStr == "false") hasInnvoiceFilter = false;
|
||||
|
||||
_logger.Info($"[PERF] FruitBankOrderList – params parsed in {sw.ElapsedMilliseconds} ms");
|
||||
sw.Restart();
|
||||
|
||||
// ── 3. Direct lean query – bypasses the factory N+1 problem ───────
|
||||
// OrderDtos already has all FruitBank fields + Customer + GenericAttributes.
|
||||
// LinqToDB LoadWith batches relations into 1 query each – 3 queries total
|
||||
// regardless of row count, vs the factory’s ~5 queries per row.
|
||||
int? filterCustomerId = int.TryParse(billingCompany, out var cid) && cid > 0 ? cid : null;
|
||||
|
||||
// UTC conversion for date filters (same logic as base factory)
|
||||
var currentTz = await _dateTimeHelper.GetCurrentTimeZoneAsync();
|
||||
DateTime? startUtc = startDate.HasValue ? (DateTime?)_dateTimeHelper.ConvertToUtcTime(startDate.Value, currentTz) : null;
|
||||
DateTime? endUtc = endDate.HasValue ? (DateTime?)_dateTimeHelper.ConvertToUtcTime(endDate.Value, currentTz).AddDays(1) : null;
|
||||
|
||||
var query = _dbContext.OrderDtos
|
||||
.GetAll(true) // loads GenericAttributes in 1 batch query
|
||||
.Where(o => !o.Deleted);
|
||||
|
||||
if (startUtc.HasValue) query = query.Where(o => o.CreatedOnUtc >= startUtc.Value);
|
||||
if (endUtc.HasValue) query = query.Where(o => o.CreatedOnUtc <= endUtc.Value);
|
||||
if (filterCustomerId.HasValue) query = query.Where(o => o.CustomerId == filterCustomerId.Value);
|
||||
if (orderStatusIds.Any()) query = query.Where(o => orderStatusIds.Contains(o.OrderStatusId));
|
||||
if (paymentStatusIds.Any()) query = query.Where(o => paymentStatusIds.Contains(o.PaymentStatusId));
|
||||
if (shippingStatusIds.Any()) query = query.Where(o => shippingStatusIds.Contains(o.ShippingStatusId));
|
||||
|
||||
// Apply sort at DB level
|
||||
bool asc = sortDir == "asc";
|
||||
query = sortColName.ToLowerInvariant() switch
|
||||
{
|
||||
"customordernumber" => asc ? query.OrderBy(o => o.CustomOrderNumber) : query.OrderByDescending(o => o.CustomOrderNumber),
|
||||
"createdon" => asc ? query.OrderBy(o => o.CreatedOnUtc) : query.OrderByDescending(o => o.CreatedOnUtc),
|
||||
"dateofreceipt" => asc ? query.OrderBy(o => o.DateOfReceipt) : query.OrderByDescending(o => o.DateOfReceipt),
|
||||
"orderstatusid" => asc ? query.OrderBy(o => o.OrderStatusId) : query.OrderByDescending(o => o.OrderStatusId),
|
||||
"measuringstatus" => asc ? query.OrderBy(o => o.MeasuringStatus) : query.OrderByDescending(o => o.MeasuringStatus),
|
||||
"customercompany" => asc ? query.OrderBy(o => o.CustomerId) : query.OrderByDescending(o => o.CustomerId),
|
||||
_ => query.OrderByDescending(o => o.Id)
|
||||
};
|
||||
|
||||
// Per-column DB-mappable filters
|
||||
if (colSearch.TryGetValue("CustomOrderNumber", out var coNum) && !string.IsNullOrEmpty(coNum))
|
||||
query = query.Where(o => o.CustomOrderNumber.Contains(coNum));
|
||||
if (colSearch.TryGetValue("OrderStatusId", out var osColStr) && int.TryParse(osColStr, out var osColId))
|
||||
query = query.Where(o => o.OrderStatusId == osColId);
|
||||
if (colSearch.TryGetValue("MeasuringStatus", out var msColStr) && int.TryParse(msColStr, out var msColId))
|
||||
query = query.Where(o => (int)o.MeasuringStatus == msColId);
|
||||
// IsMeasurable: computed from OrderItemDtos – pre-query the OrderItem table
|
||||
// to get order IDs where any item belongs to a measurable product, then filter SQL
|
||||
var isMeasurableColVal = colSearch.TryGetValue("IsMeasurable", out var imcs) ? imcs : null;
|
||||
bool? effectiveIsMeasurable = isMeasurableFilter;
|
||||
if (isMeasurableColVal != null && bool.TryParse(isMeasurableColVal, out var imcb))
|
||||
effectiveIsMeasurable = imcb;
|
||||
|
||||
if (effectiveIsMeasurable.HasValue)
|
||||
{
|
||||
// Get all order IDs where any item has a measurable product
|
||||
var measurableOrderIds = await _dbContext.OrderItemDtos
|
||||
.GetAll(false)
|
||||
.Where(oi => oi.ProductDto != null && oi.ProductDto.IsMeasurable)
|
||||
.Select(oi => oi.OrderId)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
|
||||
query = effectiveIsMeasurable.Value
|
||||
? query.Where(o => measurableOrderIds.Contains(o.Id))
|
||||
: query.Where(o => !measurableOrderIds.Contains(o.Id));
|
||||
|
||||
_logger.Info($"[PERF] FruitBankOrderList – IsMeasurable pre-query: {measurableOrderIds.Count} measurable order IDs");
|
||||
}
|
||||
|
||||
// CustomerCompany column search: pre-query Customer table for matching IDs
|
||||
if (colSearch.TryGetValue("CustomerCompany", out var ccColVal) && !string.IsNullOrEmpty(ccColVal))
|
||||
{
|
||||
var matchingCustomerIds = await _dbContext.Customers.Table
|
||||
.Where(c => c.Company.Contains(ccColVal) ||
|
||||
(c.FirstName + " " + c.LastName).Contains(ccColVal))
|
||||
.Select(c => c.Id)
|
||||
.ToListAsync();
|
||||
|
||||
query = query.Where(o => matchingCustomerIds.Contains(o.CustomerId));
|
||||
_logger.Info($"[PERF] FruitBankOrderList – CustomerCompany pre-query: {matchingCustomerIds.Count} matching customers");
|
||||
}
|
||||
|
||||
// COUNT – runs as a simple SELECT COUNT(*) against the filtered set
|
||||
int total;
|
||||
try { total = await query.CountAsync(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"FruitBankOrderList – count error: {ex.Message}", ex);
|
||||
return Json(new { draw, recordsTotal = 0, recordsFiltered = 0, data = Array.Empty<object>() });
|
||||
}
|
||||
|
||||
_logger.Info($"[PERF] FruitBankOrderList – COUNT {sw.ElapsedMilliseconds} ms | total: {total}");
|
||||
sw.Restart();
|
||||
|
||||
// Step 1: get just the IDs for the current page (plain SQL, no relations)
|
||||
List<int> pageIds;
|
||||
try
|
||||
{
|
||||
pageIds = await query.Skip(start).Take(length).Select(o => o.Id).ToListAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"FruitBankOrderList – page IDs query error: {ex.Message}", ex);
|
||||
return Json(new { draw, recordsTotal = 0, recordsFiltered = 0, data = Array.Empty<object>() });
|
||||
}
|
||||
|
||||
_logger.Info($"[PERF] FruitBankOrderList – page IDs (Skip+Take) {sw.ElapsedMilliseconds} ms | ids: {pageIds.Count}");
|
||||
sw.Restart();
|
||||
|
||||
// Step 2: reload those ~50 rows with only the relations we need.
|
||||
// LoadWith works here because it’s applied to the base table query, not a filtered IQueryable.
|
||||
List<OrderDto> rows;
|
||||
try
|
||||
{
|
||||
// GetAllByIds(ids, false) uses GetAll(false) which has LoadWith(GenericAttributes) baked in.
|
||||
// LoadWith on a chained IQueryable is not supported by LinqToDB.
|
||||
rows = await _dbContext.OrderDtos
|
||||
.GetAllByIds(pageIds, true)
|
||||
.ToListAsync();
|
||||
|
||||
// Re-sort to match the original query order (IN clause doesn’t guarantee order)
|
||||
rows = pageIds
|
||||
.Select(id => rows.FirstOrDefault(r => r.Id == id))
|
||||
.Where(r => r != null)
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"FruitBankOrderList – relations query error: {ex.Message}", ex);
|
||||
return Json(new { draw, recordsTotal = 0, recordsFiltered = 0, data = Array.Empty<object>() });
|
||||
}
|
||||
|
||||
_logger.Info($"[PERF] FruitBankOrderList – relations load (GetAll+LoadWith) {sw.ElapsedMilliseconds} ms | rows: {rows.Count}");
|
||||
sw.Restart();
|
||||
|
||||
var userTz = currentTz;
|
||||
|
||||
// ── 4. Map to lightweight DTO ──────────────────────────────────
|
||||
static string MeasuringStatusLabel(MeasuringStatus s) => s switch
|
||||
{
|
||||
MeasuringStatus.NotStarted => "Nincs elindítva",
|
||||
MeasuringStatus.Started => "Folyamatban",
|
||||
MeasuringStatus.Finnished => "Mérve",
|
||||
MeasuringStatus.Audited => "Lezárva",
|
||||
_ => s.ToString()
|
||||
};
|
||||
static string OrderStatusLabel(int id) => id switch
|
||||
{
|
||||
10 => "Függőben",
|
||||
20 => "Feldolgozás",
|
||||
30 => "Teljesítve",
|
||||
40 => "Törölve",
|
||||
_ => id.ToString()
|
||||
};
|
||||
static string PaymentStatusLabel(int id) => id switch
|
||||
{
|
||||
10 => "Fizetésre vár",
|
||||
20 => "Félig fizetve",
|
||||
30 => "Fizetve",
|
||||
35 => "Túlfizetve",
|
||||
40 => "Visszatérítve",
|
||||
_ => id.ToString()
|
||||
};
|
||||
static string ShippingStatusLabel(int id) => id switch
|
||||
{
|
||||
10 => "Szállítás nincs",
|
||||
20 => "Nincs kiszállítva",
|
||||
25 => "Részben kiszállítva",
|
||||
30 => "Kiszállítva",
|
||||
_ => id.ToString()
|
||||
};
|
||||
|
||||
var dtos = rows.Select(o =>
|
||||
{
|
||||
var ga = o.GenericAttributes;
|
||||
var dateOfReceipt = ga?.FirstOrDefault(a => a.Key == "DateOfReceipt")?.Value is string dv && DateTime.TryParse(dv, out var dp) ? dp : (DateTime?)null;
|
||||
var innvoiceTechId = ga?.FirstOrDefault(a => a.Key == "InnVoiceOrderTechId")?.Value;
|
||||
var company = o.Customer != null
|
||||
? $"{o.Customer.Company} {o.Customer.FirstName}_{o.Customer.LastName}".Trim()
|
||||
: string.Empty;
|
||||
|
||||
return new Nop.Plugin.Misc.FruitBankPlugin.Models.Orders.FruitBankOrderRowDto
|
||||
{
|
||||
Id = o.Id,
|
||||
CustomOrderNumber = o.CustomOrderNumber,
|
||||
CustomerCompany = company,
|
||||
CustomerId = o.CustomerId,
|
||||
InnvoiceTechId = innvoiceTechId,
|
||||
IsAllOrderItemAvgWeightValid = o.IsAllOrderItemAvgWeightValid,
|
||||
IsMeasurable = o.IsMeasurable,
|
||||
MeasuringStatus = (int)o.MeasuringStatus,
|
||||
MeasuringStatusString = MeasuringStatusLabel(o.MeasuringStatus),
|
||||
DateOfReceipt = dateOfReceipt,
|
||||
OrderStatusId = o.OrderStatusId,
|
||||
OrderStatus = OrderStatusLabel(o.OrderStatusId),
|
||||
PaymentStatusId = o.PaymentStatusId,
|
||||
PaymentStatus = PaymentStatusLabel(o.PaymentStatusId),
|
||||
ShippingStatusId = o.ShippingStatusId,
|
||||
ShippingStatus = ShippingStatusLabel(o.ShippingStatusId),
|
||||
StoreName = string.Empty, // not needed in grid
|
||||
CreatedOn = TimeZoneInfo.ConvertTimeFromUtc(o.CreatedOnUtc, userTz),
|
||||
OrderTotal = !o.IsComplete && o.IsMeasurable
|
||||
? "kalkuláció alatt..."
|
||||
: $"{o.OrderTotal:N0} Ft"
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
_logger.Info($"[PERF] FruitBankOrderList – DTO mapping {sw.ElapsedMilliseconds} ms");
|
||||
sw.Restart();
|
||||
|
||||
// InnVoice filter is post-query (it’s in GenericAttributes, not a plain column)
|
||||
if (hasInnvoiceFilter.HasValue)
|
||||
dtos = hasInnvoiceFilter.Value
|
||||
? dtos.Where(o => !string.IsNullOrEmpty(o.InnvoiceTechId)).ToList()
|
||||
: dtos.Where(o => string.IsNullOrEmpty(o.InnvoiceTechId)).ToList();
|
||||
|
||||
// InnVoice column-header filter (post-query: stored in GenericAttributes)
|
||||
if (colSearch.TryGetValue("InnvoiceTechId", out var innColVal))
|
||||
dtos = innColVal == "has" ? dtos.Where(o => !string.IsNullOrEmpty(o.InnvoiceTechId)).ToList()
|
||||
: innColVal == "none" ? dtos.Where(o => string.IsNullOrEmpty(o.InnvoiceTechId)).ToList()
|
||||
: dtos;
|
||||
|
||||
var result = Json(new { draw, recordsTotal = total, recordsFiltered = total, data = dtos });
|
||||
|
||||
_logger.Info($"[PERF] FruitBankOrderList – JSON serialize {sw.ElapsedMilliseconds} ms");
|
||||
_logger.Info($"[PERF] FruitBankOrderList – TOTAL {swTotal.ElapsedMilliseconds} ms | page: {dtos.Count}");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
int recordsFiltered = dtos.Count;
|
||||
|
||||
|
||||
// ── 7. Sort ────────────────────────────────────────────────────
|
||||
bool asc = sortDir == "asc";
|
||||
dtos = sortColName.ToLowerInvariant() switch
|
||||
/// <summary>
|
||||
/// Inline-edit save endpoint. Currently supports DateOfReceipt.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[CheckPermission(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE)]
|
||||
public async Task<IActionResult> UpdateOrderField(int orderId, string field, string value)
|
||||
{
|
||||
"id" => asc ? dtos.OrderBy(o => o.Id).ToList() : dtos.OrderByDescending(o => o.Id).ToList(),
|
||||
"customordernumber" => asc ? dtos.OrderBy(o => o.CustomOrderNumber).ToList() : dtos.OrderByDescending(o => o.CustomOrderNumber).ToList(),
|
||||
"customercompany" => asc ? dtos.OrderBy(o => o.CustomerCompany).ToList() : dtos.OrderByDescending(o => o.CustomerCompany).ToList(),
|
||||
"dateofreceipt" => asc ? dtos.OrderBy(o => o.DateOfReceipt ?? DateTime.MinValue).ToList() : dtos.OrderByDescending(o => o.DateOfReceipt ?? DateTime.MinValue).ToList(),
|
||||
"createdon" => asc ? dtos.OrderBy(o => o.CreatedOn).ToList() : dtos.OrderByDescending(o => o.CreatedOn).ToList(),
|
||||
"orderstatusid" => asc ? dtos.OrderBy(o => o.OrderStatusId).ToList() : dtos.OrderByDescending(o => o.OrderStatusId).ToList(),
|
||||
"measuringstatus" => asc ? dtos.OrderBy(o => o.MeasuringStatus).ToList() : dtos.OrderByDescending(o => o.MeasuringStatus).ToList(),
|
||||
_ => dtos.OrderByDescending(o => o.Id).ToList()
|
||||
};
|
||||
|
||||
// ── 8. Paginate ────────────────────────────────────────────────
|
||||
var page = dtos.Skip(start).Take(length).ToList();
|
||||
|
||||
return Json(new { draw, recordsTotal = total, recordsFiltered, data = page });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inline-edit save endpoint. Currently supports DateOfReceipt.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[CheckPermission(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE)]
|
||||
public async Task<IActionResult> UpdateOrderField(int orderId, string field, string value)
|
||||
{
|
||||
try
|
||||
{
|
||||
var order = await _orderService.GetOrderByIdAsync(orderId);
|
||||
if (order == null)
|
||||
return Json(new { success = false, error = "Rendelés nem található" });
|
||||
|
||||
switch (field?.ToUpperInvariant())
|
||||
try
|
||||
{
|
||||
case "DATEOFRECEIPT":
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
await _genericAttributeService.SaveAttributeAsync<DateTime?>(order, "DateOfReceipt", null);
|
||||
return Json(new { success = true, displayValue = (string)null });
|
||||
}
|
||||
if (DateTime.TryParse(value, out var newDate))
|
||||
{
|
||||
await _genericAttributeService.SaveAttributeAsync(order, "DateOfReceipt", newDate);
|
||||
return Json(new { success = true, displayValue = newDate.ToString("yyyy. MM. dd. HH:mm") });
|
||||
}
|
||||
return Json(new { success = false, error = "Érvénytelen dátum formátum" });
|
||||
var order = await _orderService.GetOrderByIdAsync(orderId);
|
||||
if (order == null)
|
||||
return Json(new { success = false, error = "Rendelés nem található" });
|
||||
|
||||
default:
|
||||
return Json(new { success = false, error = $"Ismeretlen mező: {field}" });
|
||||
switch (field?.ToUpperInvariant())
|
||||
{
|
||||
case "DATEOFRECEIPT":
|
||||
var dateOdReceiptDateTime = DateTime.TryParse(value, out var dp);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
//await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync<Order, string>(order.Id, nameof(IOrderDto.DateOfReceipt), null, _storeContext.GetCurrentStore().Id);
|
||||
await _fruitBankAttributeService.DeleteGenericAttributeAsync<Order>(order.Id, nameof(IOrderDto.DateOfReceipt));
|
||||
//await _genericAttributeService.SaveAttributeAsync<DateTime?>(order, "DateOfReceipt", null);
|
||||
return Json(new { success = true, displayValue = (string)null });
|
||||
}
|
||||
if (DateTime.TryParse(value, out var newDate))
|
||||
{
|
||||
// Store in the same format that NopCommerce's SaveAttributeAsync<DateTime?> uses (MM/dd/yyyy HH:mm:ss invariant)
|
||||
// so OrderDto deserialization in the Blazor app doesn't break.
|
||||
var formattedValue = newDate.ToString("MM/dd/yyyy HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture);
|
||||
await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync<Order, string>(order.Id, nameof(IOrderDto.DateOfReceipt), formattedValue, _storeContext.GetCurrentStore().Id);
|
||||
return Json(new { success = true, displayValue = newDate.ToString("yyyy. MM. dd. HH:mm") });
|
||||
}
|
||||
return Json(new { success = false, error = "Érvénytelen dátum formátum" });
|
||||
|
||||
default:
|
||||
return Json(new { success = false, error = $"Ismeretlen mező: {field}" });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"UpdateOrderField error – orderId={orderId} field={field}: {ex.Message}", ex);
|
||||
return Json(new { success = false, error = ex.Message });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"UpdateOrderField error – orderId={orderId} field={field}: {ex.Message}", ex);
|
||||
return Json(new { success = false, error = ex.Message });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
|
|||
private readonly FileStorageService _fileStorageService;
|
||||
private readonly FruitBankAttributeService _fruitBankAttributeService;
|
||||
private readonly IStoreContext _storeContext;
|
||||
private readonly PreorderConversionService _preorderConversionService;
|
||||
|
||||
public FileManagerController(
|
||||
IPermissionService permissionService,
|
||||
|
|
@ -53,7 +54,8 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
|
|||
IWorkContext workContext,
|
||||
FileStorageService fileStorageService,
|
||||
FruitBankAttributeService fruitBankAttributeService,
|
||||
IStoreContext storeContext)
|
||||
IStoreContext storeContext,
|
||||
PreorderConversionService preorderConversionService)
|
||||
{
|
||||
_permissionService = permissionService;
|
||||
_aiApiService = aiApiService;
|
||||
|
|
@ -66,6 +68,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
|
|||
_fileStorageService = fileStorageService;
|
||||
_fruitBankAttributeService = fruitBankAttributeService;
|
||||
_storeContext = storeContext;
|
||||
_preorderConversionService = preorderConversionService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -1120,6 +1123,32 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
|
|||
newIncomingQuantity, _storeContext.GetCurrentStore().Id
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step 3: Convert pending preorders that cover these products ──────────
|
||||
var productIdsWithIncoming = shippingDocument.ShippingItems
|
||||
.Where(x => x.ProductId != null)
|
||||
.Select(x => x.ProductId!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (productIdsWithIncoming.Any())
|
||||
{
|
||||
// Fire-and-forget with error isolation so a conversion failure
|
||||
// never blocks the shipping document save response
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _preorderConversionService
|
||||
.ConvertPreordersForProductsAsync(productIdsWithIncoming, shippingDocument.Id);
|
||||
}
|
||||
catch (Exception convEx)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[PreorderConversion] Error during conversion for document #{shippingDocument.Id}: {convEx.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return Json(new
|
||||
|
|
|
|||
|
|
@ -34,7 +34,9 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
|
|||
ApiBaseUrl = _settings.ApiBaseUrl,
|
||||
MaxTokens = _settings.MaxTokens,
|
||||
Temperature = _settings.Temperature,
|
||||
RequestTimeoutSeconds = _settings.RequestTimeoutSeconds
|
||||
RequestTimeoutSeconds = _settings.RequestTimeoutSeconds,
|
||||
ZaiApiKey = _settings.ZaiApiKey,
|
||||
ZaiModel = _settings.ZaiModel
|
||||
};
|
||||
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Configure/Configure.cshtml", model);
|
||||
}
|
||||
|
|
@ -58,6 +60,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
|
|||
_settings.MaxTokens = model.MaxTokens;
|
||||
_settings.Temperature = model.Temperature;
|
||||
_settings.RequestTimeoutSeconds = model.RequestTimeoutSeconds;
|
||||
_settings.ZaiApiKey = model.ZaiApiKey ?? string.Empty;
|
||||
_settings.ZaiModel = model.ZaiModel ?? "glm-ocr";
|
||||
|
||||
// Save settings
|
||||
await _settingService.SaveSettingAsync(_settings);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,452 @@
|
|||
using FruitBank.Common.Entities;
|
||||
using FruitBank.Common.Enums;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nop.Core.Domain.Customers;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||
using Nop.Services.Customers;
|
||||
using Nop.Services.Security;
|
||||
using Nop.Web.Framework;
|
||||
using Nop.Web.Framework.Controllers;
|
||||
using Nop.Web.Framework.Mvc.Filters;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers;
|
||||
|
||||
[AuthorizeAdmin]
|
||||
[Area(AreaNames.ADMIN)]
|
||||
[AutoValidateAntiforgeryToken]
|
||||
public class PreorderAdminController : BasePluginController
|
||||
{
|
||||
private readonly IPermissionService _permissionService;
|
||||
private readonly PreorderDbContext _preorderDbContext;
|
||||
private readonly FruitBankDbContext _dbContext;
|
||||
private readonly ICustomerService _customerService;
|
||||
private readonly PreorderConversionService _preorderConversionService;
|
||||
|
||||
private static readonly Dictionary<PreorderStatus, string> StatusLabels = new()
|
||||
{
|
||||
{ PreorderStatus.Pending, "Függőben" },
|
||||
{ PreorderStatus.Confirmed, "Megerősítve" },
|
||||
{ PreorderStatus.PartiallyFulfilled, "Részben teljesítve" },
|
||||
{ PreorderStatus.Cancelled, "Törölve" }
|
||||
};
|
||||
|
||||
private static readonly Dictionary<PreorderItemStatus, string> ItemStatusLabels = new()
|
||||
{
|
||||
{ PreorderItemStatus.Pending, "Függőben" },
|
||||
{ PreorderItemStatus.Fulfilled, "Teljesítve" },
|
||||
{ PreorderItemStatus.PartiallyFulfilled, "Részben" },
|
||||
{ PreorderItemStatus.Dropped, "Ejtve" }
|
||||
};
|
||||
|
||||
public PreorderAdminController(
|
||||
IPermissionService permissionService,
|
||||
PreorderDbContext preorderDbContext,
|
||||
FruitBankDbContext dbContext,
|
||||
ICustomerService customerService,
|
||||
PreorderConversionService preorderConversionService)
|
||||
{
|
||||
_permissionService = permissionService;
|
||||
_preorderDbContext = preorderDbContext;
|
||||
_dbContext = dbContext;
|
||||
_customerService = customerService;
|
||||
_preorderConversionService = preorderConversionService;
|
||||
}
|
||||
|
||||
// ── LIST PAGE ─────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet]
|
||||
[Route("Admin/Preorders")]
|
||||
public async Task<IActionResult> List()
|
||||
{
|
||||
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||
return AccessDeniedView();
|
||||
|
||||
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Preorder/List.cshtml");
|
||||
}
|
||||
|
||||
// ── DATATABLES SERVER-SIDE ────────────────────────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
[Route("Admin/Preorders/PreorderList")]
|
||||
public async Task<IActionResult> PreorderList()
|
||||
{
|
||||
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||
return Forbid();
|
||||
|
||||
_ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1);
|
||||
_ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0);
|
||||
_ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 25 : Math.Min(length, 500);
|
||||
|
||||
_ = int.TryParse(Request.Form["order[0][column]"].FirstOrDefault(), out int sortColIdx);
|
||||
var sortDir = Request.Form["order[0][dir]"].FirstOrDefault() ?? "desc";
|
||||
var sortColName = Request.Form[$"columns[{sortColIdx}][name]"].FirstOrDefault() ?? "CreatedOnUtc";
|
||||
|
||||
var globalSearch = Request.Form["search[value]"].FirstOrDefault()?.Trim() ?? "";
|
||||
var statusFilter = Request.Form["statusFilter"].FirstOrDefault()?.Trim() ?? "";
|
||||
|
||||
// 1. All preorders with items — two queries
|
||||
var preorders = await _preorderDbContext.Preorders.GetAll(false).ToListAsync();
|
||||
var allItems = await _preorderDbContext.PreorderItems.GetAll().ToListAsync();
|
||||
|
||||
var itemsByPreorder = allItems
|
||||
.GroupBy(i => i.PreorderId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
// 2. Customers — batch
|
||||
var customerIds = preorders.Select(p => p.CustomerId).Distinct().ToList();
|
||||
var customers = await _dbContext.Customers.Table
|
||||
.Where(c => customerIds.Contains(c.Id))
|
||||
.Select(c => new { c.Id, c.Email, c.FirstName, c.LastName })
|
||||
.ToListAsync();
|
||||
var customerById = customers.ToDictionary(c => c.Id);
|
||||
|
||||
// 3. Linked orders — find orders created from preorders via CustomOrderNumber lookup
|
||||
// We store the preorder id in the order note, but the simplest link is checking
|
||||
// OrderNotes for "előrendelésből" text matching preorderId.
|
||||
// For now we surface the link on the detail page only.
|
||||
|
||||
// 4. Build rows — derive status from quantities, not enum (LinqToDB enum reads unreliable)
|
||||
var rows = preorders.Select(p =>
|
||||
{
|
||||
customerById.TryGetValue(p.CustomerId, out var c);
|
||||
var items = itemsByPreorder.TryGetValue(p.Id, out var its) ? its : new();
|
||||
|
||||
// Derive status from quantities rather than relying on the enum read
|
||||
var fulfilledCount = items.Count(i => i.FulfilledQuantity > 0);
|
||||
var allFulfilled = items.Any() && items.All(i => i.FulfilledQuantity >= i.RequestedQuantity);
|
||||
var anyFulfilled = items.Any(i => i.FulfilledQuantity > 0);
|
||||
var hasOrderId = p.OrderId.HasValue;
|
||||
|
||||
// Derive a display status: use the DB enum if it looks valid (non-zero),
|
||||
// otherwise infer from quantities
|
||||
var effectiveStatus = (int)p.Status != 0
|
||||
? p.Status
|
||||
: allFulfilled ? PreorderStatus.Confirmed
|
||||
: anyFulfilled ? PreorderStatus.PartiallyFulfilled
|
||||
: PreorderStatus.Pending;
|
||||
|
||||
return new PreorderListRow
|
||||
{
|
||||
PreorderId = p.Id,
|
||||
CustomerId = p.CustomerId,
|
||||
CustomerName = c != null ? $"{c.FirstName} {c.LastName}".Trim() : $"#{p.CustomerId}",
|
||||
CustomerEmail = c?.Email ?? string.Empty,
|
||||
DateOfReceipt = p.DateOfReceipt.ToLocalTime().ToString("yyyy.MM.dd HH:mm"),
|
||||
CreatedOnUtc = p.CreatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd HH:mm"),
|
||||
Status = effectiveStatus,
|
||||
StatusLabel = StatusLabels.TryGetValue(effectiveStatus, out var sl) ? sl : effectiveStatus.ToString(),
|
||||
ItemCount = items.Count,
|
||||
FulfilledCount = fulfilledCount,
|
||||
OrderId = p.OrderId
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
int recordsTotal = rows.Count;
|
||||
|
||||
// 5. Filter by status
|
||||
if (!string.IsNullOrWhiteSpace(statusFilter) && Enum.TryParse<PreorderStatus>(statusFilter, out var statusEnum))
|
||||
rows = rows.Where(r => r.Status == statusEnum).ToList();
|
||||
|
||||
// 6. Global search
|
||||
if (!string.IsNullOrWhiteSpace(globalSearch))
|
||||
rows = rows.Where(r =>
|
||||
r.CustomerName.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) ||
|
||||
r.CustomerEmail.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) ||
|
||||
r.PreorderId.ToString().Contains(globalSearch)
|
||||
).ToList();
|
||||
|
||||
int recordsFiltered = rows.Count;
|
||||
|
||||
// 7. Sort
|
||||
bool asc = sortDir == "asc";
|
||||
rows = sortColName switch
|
||||
{
|
||||
"CustomerName" => asc ? rows.OrderBy(r => r.CustomerName).ToList() : rows.OrderByDescending(r => r.CustomerName).ToList(),
|
||||
"DateOfReceipt" => asc ? rows.OrderBy(r => r.DateOfReceipt).ToList() : rows.OrderByDescending(r => r.DateOfReceipt).ToList(),
|
||||
"Status" => asc ? rows.OrderBy(r => r.Status).ToList() : rows.OrderByDescending(r => r.Status).ToList(),
|
||||
_ => asc ? rows.OrderBy(r => r.CreatedOnUtc).ToList() : rows.OrderByDescending(r => r.CreatedOnUtc).ToList()
|
||||
};
|
||||
|
||||
// 8. Paginate
|
||||
var page = rows.Skip(start).Take(length).ToList();
|
||||
|
||||
return Json(new { draw, recordsTotal, recordsFiltered, data = page });
|
||||
}
|
||||
|
||||
// ── DETAIL PAGE ───────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet]
|
||||
[Route("Admin/Preorders/Detail/{id:int}")]
|
||||
public async Task<IActionResult> Detail(int id)
|
||||
{
|
||||
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||
return AccessDeniedView();
|
||||
|
||||
var preorder = await _preorderDbContext.Preorders.GetByIdAsync(id, loadRelations: false);
|
||||
if (preorder == null) return NotFound();
|
||||
|
||||
var items = await _preorderDbContext.PreorderItems
|
||||
.GetAllByPreorderIdAsync(id)
|
||||
.ToListAsync();
|
||||
|
||||
var customer = await _customerService.GetCustomerByIdAsync(preorder.CustomerId);
|
||||
|
||||
// Resolve product names in one batch
|
||||
var productIds = items.Select(i => i.ProductId).Distinct().ToList();
|
||||
var productDtos = await _dbContext.ProductDtos
|
||||
.GetAll(false)
|
||||
.Where(p => productIds.Contains(p.Id))
|
||||
.ToListAsync();
|
||||
var productById = productDtos.ToDictionary(p => p.Id);
|
||||
|
||||
// Use preorder.OrderId directly — stored on the entity at conversion time
|
||||
int? linkedOrderId = preorder.OrderId;
|
||||
|
||||
var model = new PreorderDetailModel
|
||||
{
|
||||
PreorderId = preorder.Id,
|
||||
CustomerId = preorder.CustomerId,
|
||||
CustomerName = customer != null ? $"{customer.FirstName} {customer.LastName}".Trim() : $"#{preorder.CustomerId}",
|
||||
CustomerEmail = customer?.Email ?? string.Empty,
|
||||
DateOfReceipt = preorder.DateOfReceipt.ToLocalTime().ToString("yyyy.MM.dd HH:mm"),
|
||||
CreatedOnUtc = preorder.CreatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd HH:mm"),
|
||||
UpdatedOnUtc = preorder.UpdatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd HH:mm"),
|
||||
Status = preorder.Status,
|
||||
CustomerNote = preorder.CustomerNote,
|
||||
OrderId = linkedOrderId,
|
||||
Items = items.Select(i =>
|
||||
{
|
||||
productById.TryGetValue(i.ProductId, out var dto);
|
||||
|
||||
// Derive item status from quantities — enum reads unreliable in LinqToDB
|
||||
var derivedStatus = i.FulfilledQuantity == 0
|
||||
? PreorderItemStatus.Pending
|
||||
: i.FulfilledQuantity >= i.RequestedQuantity
|
||||
? PreorderItemStatus.Fulfilled
|
||||
: PreorderItemStatus.PartiallyFulfilled;
|
||||
|
||||
// If DB enum read as non-zero, prefer it; otherwise use derived
|
||||
var effectiveItemStatus = (int)i.Status != 0 ? i.Status : derivedStatus;
|
||||
|
||||
return new PreorderDetailItemRow
|
||||
{
|
||||
ItemId = i.Id,
|
||||
ProductId = i.ProductId,
|
||||
ProductName = dto?.Name ?? $"Product #{i.ProductId}",
|
||||
IsMeasurable = dto?.IsMeasurable ?? false,
|
||||
RequestedQuantity = i.RequestedQuantity,
|
||||
FulfilledQuantity = i.FulfilledQuantity,
|
||||
UnitPriceInclTax = i.UnitPriceInclTax,
|
||||
Status = effectiveItemStatus,
|
||||
StatusLabel = ItemStatusLabels.TryGetValue(effectiveItemStatus, out var isl) ? isl : effectiveItemStatus.ToString()
|
||||
};
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Preorder/Detail.cshtml", model);
|
||||
}
|
||||
|
||||
// ── CREATE (admin phone order) ───────────────────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
[Route("Admin/Preorders/CreatePreorder")]
|
||||
public async Task<IActionResult> CreatePreorder(
|
||||
int customerId,
|
||||
string deliveryDateTime,
|
||||
string? customerNote,
|
||||
string productsJson)
|
||||
{
|
||||
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||
return Json(new { success = false, error = "Hozzáférés megtagadva" });
|
||||
|
||||
try
|
||||
{
|
||||
// Validate customer
|
||||
var customer = await _customerService.GetCustomerByIdAsync(customerId);
|
||||
if (customer == null)
|
||||
return Json(new { success = false, error = "Az ügyfél nem található" });
|
||||
|
||||
// Validate delivery date
|
||||
if (!DateTime.TryParse(deliveryDateTime, out var deliveryDate))
|
||||
return Json(new { success = false, error = "Érvénytelen szállítási dátum" });
|
||||
|
||||
// Parse products
|
||||
if (string.IsNullOrWhiteSpace(productsJson))
|
||||
return Json(new { success = false, error = "Nincs termék megadva" });
|
||||
|
||||
var productItems = System.Text.Json.JsonSerializer.Deserialize<List<ProductItemRequest>>(productsJson);
|
||||
if (productItems == null || !productItems.Any())
|
||||
return Json(new { success = false, error = "Nincs érvényes termék" });
|
||||
|
||||
// Get store
|
||||
var storeId = (await _dbContext.Shippings.GetAll().Select(s => s.Id).FirstOrDefaultAsync() > 0)
|
||||
? 1 : 1; // fallback to store 1
|
||||
// Use first available store from generic attributes context
|
||||
var gaStore = await _dbContext.GenericAttributes.Table
|
||||
.Select(g => g.StoreId).FirstOrDefaultAsync();
|
||||
storeId = gaStore > 0 ? gaStore : 1;
|
||||
|
||||
var preorder = new Preorder
|
||||
{
|
||||
CustomerId = customerId,
|
||||
StoreId = storeId,
|
||||
DateOfReceipt = deliveryDate,
|
||||
CustomerNote = customerNote?.Trim()
|
||||
};
|
||||
|
||||
var items = new List<PreorderItem>();
|
||||
foreach (var pi in productItems.Where(p => p.quantity > 0))
|
||||
{
|
||||
var product = await _dbContext.Products.GetByIdAsync(pi.id);
|
||||
if (product == null || product.Deleted || !product.Published) continue;
|
||||
|
||||
items.Add(new PreorderItem
|
||||
{
|
||||
ProductId = pi.id,
|
||||
RequestedQuantity = pi.quantity,
|
||||
UnitPriceInclTax = (decimal)pi.price
|
||||
});
|
||||
}
|
||||
|
||||
if (!items.Any())
|
||||
return Json(new { success = false, error = "Nincs érvényes termék az előrendelésben" });
|
||||
|
||||
var saved = await _preorderDbContext.InsertPreorderAsync(preorder, items);
|
||||
|
||||
Console.WriteLine($"[Admin] Created preorder #{saved.Id} for customer #{customerId} " +
|
||||
$"by admin, {items.Count} items, delivery {deliveryDate:u}");
|
||||
|
||||
// Immediately check if any items can be fulfilled from current stock —
|
||||
// same inline conversion as the customer-facing PlacePreorder endpoint.
|
||||
var productIds = items.Select(i => i.ProductId).Distinct().ToList();
|
||||
await _preorderConversionService.ConvertPreordersForProductsAsync(productIds, 0);
|
||||
|
||||
// Re-read to pick up OrderId if conversion created a real order
|
||||
var refreshed = await _preorderDbContext.Preorders.GetByIdAsync(saved.Id);
|
||||
|
||||
return Json(new { success = true, preorderId = saved.Id, orderId = refreshed?.OrderId });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Json(new { success = false, error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private class ProductItemRequest
|
||||
{
|
||||
public int id { get; set; }
|
||||
public string? name { get; set; }
|
||||
public int quantity { get; set; }
|
||||
public double price { get; set; }
|
||||
}
|
||||
|
||||
// ── CANCEL ────────────────────────────────────────────────────────────────
|
||||
|
||||
// ── CANCEL ───────────────────────────────────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
[Route("Admin/Preorders/Cancel/{id:int}")]
|
||||
public async Task<IActionResult> Cancel(int id)
|
||||
{
|
||||
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||
return Json(new { success = false, error = "Access denied" });
|
||||
|
||||
var preorder = await _preorderDbContext.Preorders.GetByIdAsync(id);
|
||||
if (preorder == null)
|
||||
return Json(new { success = false, error = "Preorder not found" });
|
||||
|
||||
if (preorder.Status != PreorderStatus.Pending)
|
||||
return Json(new { success = false, error = "Only pending preorders can be cancelled" });
|
||||
|
||||
await _preorderDbContext.CancelPreorderAsync(id);
|
||||
return Json(new { success = true });
|
||||
}
|
||||
|
||||
// ── DEMAND LIST ───────────────────────────────────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
[Route("Admin/Preorders/DemandList")]
|
||||
public async Task<IActionResult> DemandList(bool openOnly = true)
|
||||
{
|
||||
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||
return Forbid();
|
||||
|
||||
_ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1);
|
||||
_ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0);
|
||||
_ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 25 : Math.Min(length, 500);
|
||||
var openOnlyParam = Request.Form["openOnly"].FirstOrDefault();
|
||||
openOnly = openOnlyParam != "false";
|
||||
|
||||
// Fetch all preorder items + preorders in two queries
|
||||
var allItems = await _preorderDbContext.PreorderItems.GetAll().ToListAsync();
|
||||
var allPreorders = await _preorderDbContext.Preorders.GetAll(false).ToListAsync();
|
||||
|
||||
// For "open only": include only items from preorders that still have
|
||||
// unfulfilled demand (FulfilledQuantity < RequestedQuantity).
|
||||
// We use quantities rather than Status enum (enum reads unreliable).
|
||||
IEnumerable<PreorderItem> items = allItems;
|
||||
if (openOnly)
|
||||
{
|
||||
// Open preorders: those where at least one item still needs fulfillment
|
||||
var openPreorderIds = allPreorders
|
||||
.Where(p => allItems
|
||||
.Where(i => i.PreorderId == p.Id)
|
||||
.Any(i => i.FulfilledQuantity < i.RequestedQuantity))
|
||||
.Select(p => p.Id)
|
||||
.ToHashSet();
|
||||
|
||||
items = allItems.Where(i => openPreorderIds.Contains(i.PreorderId));
|
||||
}
|
||||
|
||||
// Group by product
|
||||
var grouped = items
|
||||
.GroupBy(i => i.ProductId)
|
||||
.Select(g => new
|
||||
{
|
||||
ProductId = g.Key,
|
||||
TotalRequested = g.Sum(i => i.RequestedQuantity),
|
||||
TotalFulfilled = g.Sum(i => i.FulfilledQuantity),
|
||||
TotalUnfulfilled = g.Sum(i => i.RequestedQuantity - i.FulfilledQuantity),
|
||||
PreorderCount = g.Select(i => i.PreorderId).Distinct().Count(),
|
||||
AvgUnitPrice = g.Where(i => i.UnitPriceInclTax > 0).Any()
|
||||
? g.Where(i => i.UnitPriceInclTax > 0).Average(i => i.UnitPriceInclTax)
|
||||
: 0m
|
||||
})
|
||||
.OrderByDescending(g => g.TotalUnfulfilled)
|
||||
.ThenByDescending(g => g.TotalRequested)
|
||||
.ToList();
|
||||
|
||||
// Resolve product names in one batch
|
||||
var productIds = grouped.Select(g => g.ProductId).Distinct().ToList();
|
||||
var productDtos = await _dbContext.ProductDtos
|
||||
.GetAll(false)
|
||||
.Where(p => productIds.Contains(p.Id))
|
||||
.ToListAsync();
|
||||
var productById = productDtos.ToDictionary(p => p.Id);
|
||||
|
||||
var rows = grouped.Select(g =>
|
||||
{
|
||||
productById.TryGetValue(g.ProductId, out var dto);
|
||||
return new PreorderDemandRow
|
||||
{
|
||||
ProductId = g.ProductId,
|
||||
ProductName = dto?.Name ?? $"Product #{g.ProductId}",
|
||||
Sku = dto?.Id.ToString(),
|
||||
IsMeasurable = dto?.IsMeasurable ?? false,
|
||||
TotalRequested = g.TotalRequested,
|
||||
TotalFulfilled = g.TotalFulfilled,
|
||||
TotalUnfulfilled = g.TotalUnfulfilled,
|
||||
PreorderCount = g.PreorderCount,
|
||||
AvgUnitPrice = Math.Round(g.AvgUnitPrice, 0)
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
int recordsTotal = rows.Count;
|
||||
int recordsFiltered = rows.Count;
|
||||
var page = rows.Skip(start).Take(length).ToList();
|
||||
|
||||
return Json(new { draw, recordsTotal, recordsFiltered, data = page });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,258 @@
|
|||
using FruitBank.Common.Server;
|
||||
using LinqToDB;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nop.Core;
|
||||
using Nop.Core.Domain.Catalog;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||
using Nop.Services.Security;
|
||||
using Nop.Web.Framework;
|
||||
using Nop.Web.Framework.Controllers;
|
||||
using Nop.Web.Framework.Mvc.Filters;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers;
|
||||
|
||||
[AuthorizeAdmin]
|
||||
[Area(AreaNames.ADMIN)]
|
||||
[AutoValidateAntiforgeryToken]
|
||||
public class PreorderAvailabilityController : BasePluginController
|
||||
{
|
||||
private readonly IPermissionService _permissionService;
|
||||
private readonly FruitBankDbContext _dbContext;
|
||||
private readonly FruitBankAttributeService _fruitBankAttributeService;
|
||||
private readonly IStoreContext _storeContext;
|
||||
|
||||
public PreorderAvailabilityController(
|
||||
IPermissionService permissionService,
|
||||
FruitBankDbContext dbContext,
|
||||
FruitBankAttributeService fruitBankAttributeService,
|
||||
IStoreContext storeContext)
|
||||
{
|
||||
_permissionService = permissionService;
|
||||
_dbContext = dbContext;
|
||||
_fruitBankAttributeService = fruitBankAttributeService;
|
||||
_storeContext = storeContext;
|
||||
}
|
||||
|
||||
// ── INDEX ─────────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet]
|
||||
[Route("Admin/PreorderAvailability")]
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||
return AccessDeniedView();
|
||||
|
||||
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/PreorderAvailability/Index.cshtml");
|
||||
}
|
||||
|
||||
// ── ALL PRODUCTS — DataTables server-side ─────────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
[Route("Admin/PreorderAvailability/ProductList")]
|
||||
public async Task<IActionResult> ProductList()
|
||||
{
|
||||
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||
return Forbid();
|
||||
|
||||
_ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1);
|
||||
_ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0);
|
||||
_ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 25 : Math.Min(length, 500);
|
||||
|
||||
var globalSearch = Request.Form["search[value]"].FirstOrDefault()?.Trim() ?? "";
|
||||
|
||||
var storeId = (await _storeContext.GetCurrentStoreAsync()).Id;
|
||||
|
||||
// 1. All published products
|
||||
var products = await _dbContext.Products.Table
|
||||
.Where(p => !p.Deleted && p.Published)
|
||||
.OrderBy(p => p.Name)
|
||||
.Select(p => new { p.Id, p.Name, p.Sku })
|
||||
.ToListAsync();
|
||||
|
||||
// 2. All preorder window generic attributes — two queries, no N+1
|
||||
var gaStart = await _dbContext.GenericAttributes.Table
|
||||
.Where(ga => ga.KeyGroup == nameof(Product)
|
||||
&& ga.Key == FruitBankConst.PreorderWindowStart
|
||||
&& ga.StoreId == storeId)
|
||||
.ToListAsync();
|
||||
|
||||
var gaEnd = await _dbContext.GenericAttributes.Table
|
||||
.Where(ga => ga.KeyGroup == nameof(Product)
|
||||
&& ga.Key == FruitBankConst.PreorderWindowEnd
|
||||
&& ga.StoreId == storeId)
|
||||
.ToListAsync();
|
||||
|
||||
var startByProduct = gaStart.ToDictionary(g => g.EntityId, g => g.Value);
|
||||
var endByProduct = gaEnd.ToDictionary(g => g.EntityId, g => g.Value);
|
||||
|
||||
var today = DateTime.UtcNow.Date;
|
||||
|
||||
// 3. Build rows
|
||||
var rows = products.Select(p =>
|
||||
{
|
||||
DateTime.TryParse(startByProduct.GetValueOrDefault(p.Id), out var ws);
|
||||
DateTime.TryParse(endByProduct.GetValueOrDefault(p.Id), out var we);
|
||||
|
||||
var hasStart = startByProduct.ContainsKey(p.Id);
|
||||
var hasEnd = endByProduct.ContainsKey(p.Id);
|
||||
|
||||
return new PreorderAvailabilityRow
|
||||
{
|
||||
ProductId = p.Id,
|
||||
ProductName = p.Name,
|
||||
Sku = p.Sku,
|
||||
WindowStart = hasStart ? ws.ToString("yyyy-MM-dd") : null,
|
||||
WindowEnd = hasEnd ? we.ToString("yyyy-MM-dd") : null,
|
||||
IsAvailableToday = hasStart && hasEnd && ws.Date <= today && today <= we.Date
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
int recordsTotal = rows.Count;
|
||||
|
||||
// 4. Global search
|
||||
if (!string.IsNullOrWhiteSpace(globalSearch))
|
||||
{
|
||||
rows = rows.Where(r =>
|
||||
r.ProductName.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) ||
|
||||
(r.Sku?.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) ?? false)
|
||||
).ToList();
|
||||
}
|
||||
|
||||
int recordsFiltered = rows.Count;
|
||||
|
||||
// 5. Paginate
|
||||
var page = rows.Skip(start).Take(length).ToList();
|
||||
|
||||
return Json(new { draw, recordsTotal, recordsFiltered, data = page });
|
||||
}
|
||||
|
||||
// ── AVAILABLE TODAY — DataTables server-side ──────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
[Route("Admin/PreorderAvailability/AvailableTodayList")]
|
||||
public async Task<IActionResult> AvailableTodayList()
|
||||
{
|
||||
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||
return Forbid();
|
||||
|
||||
_ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1);
|
||||
_ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0);
|
||||
_ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 25 : Math.Min(length, 500);
|
||||
|
||||
var storeId = (await _storeContext.GetCurrentStoreAsync()).Id;
|
||||
var today = DateTime.UtcNow.Date;
|
||||
|
||||
// Reuse same build logic — filter to available today only
|
||||
var products = await _dbContext.Products.Table
|
||||
.Where(p => !p.Deleted && p.Published)
|
||||
.OrderBy(p => p.Name)
|
||||
.Select(p => new { p.Id, p.Name, p.Sku })
|
||||
.ToListAsync();
|
||||
|
||||
var gaStart = await _dbContext.GenericAttributes.Table
|
||||
.Where(ga => ga.KeyGroup == nameof(Product)
|
||||
&& ga.Key == FruitBankConst.PreorderWindowStart
|
||||
&& ga.StoreId == storeId)
|
||||
.ToListAsync();
|
||||
|
||||
var gaEnd = await _dbContext.GenericAttributes.Table
|
||||
.Where(ga => ga.KeyGroup == nameof(Product)
|
||||
&& ga.Key == FruitBankConst.PreorderWindowEnd
|
||||
&& ga.StoreId == storeId)
|
||||
.ToListAsync();
|
||||
|
||||
var startByProduct = gaStart.ToDictionary(g => g.EntityId, g => g.Value);
|
||||
var endByProduct = gaEnd.ToDictionary(g => g.EntityId, g => g.Value);
|
||||
|
||||
var rows = products
|
||||
.Where(p =>
|
||||
{
|
||||
if (!startByProduct.TryGetValue(p.Id, out var sRaw)) return false;
|
||||
if (!endByProduct.TryGetValue(p.Id, out var eRaw)) return false;
|
||||
if (!DateTime.TryParse(sRaw, out var ws)) return false;
|
||||
if (!DateTime.TryParse(eRaw, out var we)) return false;
|
||||
return ws.Date <= today && today <= we.Date;
|
||||
})
|
||||
.Select(p =>
|
||||
{
|
||||
DateTime.TryParse(startByProduct[p.Id], out var ws);
|
||||
DateTime.TryParse(endByProduct[p.Id], out var we);
|
||||
return new PreorderAvailabilityRow
|
||||
{
|
||||
ProductId = p.Id,
|
||||
ProductName = p.Name,
|
||||
Sku = p.Sku,
|
||||
WindowStart = ws.ToString("yyyy-MM-dd"),
|
||||
WindowEnd = we.ToString("yyyy-MM-dd"),
|
||||
IsAvailableToday = true
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
int recordsTotal = rows.Count;
|
||||
int recordsFiltered = rows.Count;
|
||||
|
||||
var page = rows.Skip(start).Take(length).ToList();
|
||||
return Json(new { draw, recordsTotal, recordsFiltered, data = page });
|
||||
}
|
||||
|
||||
// ── SAVE WINDOW DATES for a product ───────────────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
[Route("Admin/PreorderAvailability/SaveWindow")]
|
||||
public async Task<IActionResult> SaveWindow(int productId, string? windowStart, string? windowEnd)
|
||||
{
|
||||
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||
return Json(new { success = false, error = "Access denied" });
|
||||
|
||||
try
|
||||
{
|
||||
var storeId = (await _storeContext.GetCurrentStoreAsync()).Id;
|
||||
|
||||
// WindowStart
|
||||
if (string.IsNullOrWhiteSpace(windowStart))
|
||||
{
|
||||
await _fruitBankAttributeService
|
||||
.DeleteGenericAttributeAsync<Product>(productId, FruitBankConst.PreorderWindowStart, storeId);
|
||||
}
|
||||
else if (DateTime.TryParse(windowStart, out var ws))
|
||||
{
|
||||
await _fruitBankAttributeService
|
||||
.InsertOrUpdateGenericAttributeAsync<Product, DateTime>(
|
||||
productId, FruitBankConst.PreorderWindowStart, ws.Date, storeId);
|
||||
}
|
||||
else return Json(new { success = false, error = $"Invalid start date: {windowStart}" });
|
||||
|
||||
// WindowEnd
|
||||
if (string.IsNullOrWhiteSpace(windowEnd))
|
||||
{
|
||||
await _fruitBankAttributeService
|
||||
.DeleteGenericAttributeAsync<Product>(productId, FruitBankConst.PreorderWindowEnd, storeId);
|
||||
}
|
||||
else if (DateTime.TryParse(windowEnd, out var we))
|
||||
{
|
||||
await _fruitBankAttributeService
|
||||
.InsertOrUpdateGenericAttributeAsync<Product, DateTime>(
|
||||
productId, FruitBankConst.PreorderWindowEnd, we.Date, storeId);
|
||||
}
|
||||
else return Json(new { success = false, error = $"Invalid end date: {windowEnd}" });
|
||||
|
||||
// Return the new availability state
|
||||
var today = DateTime.UtcNow.Date;
|
||||
DateTime.TryParse(windowStart, out var startParsed);
|
||||
DateTime.TryParse(windowEnd, out var endParsed);
|
||||
bool isAvailableToday = !string.IsNullOrWhiteSpace(windowStart)
|
||||
&& !string.IsNullOrWhiteSpace(windowEnd)
|
||||
&& startParsed.Date <= today
|
||||
&& today <= endParsed.Date;
|
||||
|
||||
return Json(new { success = true, isAvailableToday });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Json(new { success = false, error = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,25 +9,25 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models
|
|||
{
|
||||
public record ConfigureModel
|
||||
{
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ApiKey")]
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.CerebrasApiKey")]
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ModelName")]
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.CerebrasModelName")]
|
||||
public string ModelName { get; set; } = string.Empty;
|
||||
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ApiKey")]
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.OpenAIApiKey")]
|
||||
public string OpenAIApiKey { get; set; } = string.Empty;
|
||||
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ModelName")]
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.OpenAIModelName")]
|
||||
public string OpenAIModelName { get; set; } = string.Empty;
|
||||
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.IsEnabled")]
|
||||
public bool IsEnabled { get; set; }
|
||||
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ApiBaseUrl")]
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.CerebrasApiBaseUrl")]
|
||||
public string ApiBaseUrl { get; set; } = string.Empty;
|
||||
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ApiBaseUrl")]
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.OpenAIApiBaseUrl")]
|
||||
public string OpenAIApiBaseUrl { get; set; } = string.Empty;
|
||||
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.MaxTokens")]
|
||||
|
|
@ -38,6 +38,14 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models
|
|||
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.RequestTimeoutSeconds")]
|
||||
public int RequestTimeoutSeconds { get; set; }
|
||||
|
||||
// ── Z.ai GLM-OCR ──────────────────────────────────────────────────────────────
|
||||
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ZaiApiKey")]
|
||||
public string ZaiApiKey { get; set; } = string.Empty;
|
||||
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ZaiModel")]
|
||||
public string ZaiModel { get; set; } = "glm-ocr";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
using FruitBank.Common.Enums;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models;
|
||||
|
||||
public class PreorderListRow
|
||||
{
|
||||
public int PreorderId { get; set; }
|
||||
public int CustomerId { get; set; }
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public string CustomerEmail { get; set; } = string.Empty;
|
||||
public string DateOfReceipt { get; set; } = string.Empty; // formatted
|
||||
public string CreatedOnUtc { get; set; } = string.Empty; // formatted
|
||||
public PreorderStatus Status { get; set; }
|
||||
public string StatusLabel { get; set; } = string.Empty;
|
||||
public int ItemCount { get; set; }
|
||||
public int FulfilledCount { get; set; }
|
||||
public int? OrderId { get; set; } // linked real order, if created
|
||||
}
|
||||
|
||||
public class PreorderDetailModel
|
||||
{
|
||||
public int PreorderId { get; set; }
|
||||
public int CustomerId { get; set; }
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public string CustomerEmail { get; set; } = string.Empty;
|
||||
public string DateOfReceipt { get; set; } = string.Empty;
|
||||
public string CreatedOnUtc { get; set; } = string.Empty;
|
||||
public string UpdatedOnUtc { get; set; } = string.Empty;
|
||||
public PreorderStatus Status { get; set; }
|
||||
public string? CustomerNote { get; set; }
|
||||
public int? OrderId { get; set; }
|
||||
public List<PreorderDetailItemRow> Items { get; set; } = new();
|
||||
}
|
||||
|
||||
public class PreorderDetailItemRow
|
||||
{
|
||||
public int ItemId { get; set; }
|
||||
public int ProductId { get; set; }
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
public bool IsMeasurable { get; set; }
|
||||
public int RequestedQuantity { get; set; }
|
||||
public int FulfilledQuantity { get; set; }
|
||||
public decimal UnitPriceInclTax { get; set; }
|
||||
public PreorderItemStatus Status { get; set; }
|
||||
public string StatusLabel { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class PreorderDemandRow
|
||||
{
|
||||
public int ProductId { get; set; }
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
public string? Sku { get; set; }
|
||||
public bool IsMeasurable { get; set; }
|
||||
public int TotalRequested { get; set; } // sum of RequestedQuantity
|
||||
public int TotalFulfilled { get; set; } // sum of FulfilledQuantity
|
||||
public int TotalUnfulfilled { get; set; } // TotalRequested - TotalFulfilled
|
||||
public int PreorderCount { get; set; } // distinct preorders containing this product
|
||||
public decimal AvgUnitPrice { get; set; } // average snapshot price
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models;
|
||||
|
||||
public class PreorderAvailabilityRow
|
||||
{
|
||||
public int ProductId { get; set; }
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
public string? Sku { get; set; }
|
||||
public string? WindowStart { get; set; } // ISO date string "yyyy-MM-dd" or null
|
||||
public string? WindowEnd { get; set; } // ISO date string "yyyy-MM-dd" or null
|
||||
public bool IsAvailableToday { get; set; }
|
||||
}
|
||||
|
|
@ -23,40 +23,42 @@
|
|||
<label asp-for="ApiKey"></label>
|
||||
<input asp-for="ApiKey" class="form-control" type="password" placeholder="Adja meg az AI API kulcsot" />
|
||||
<span asp-validation-for="ApiKey" class="text-danger"></span>
|
||||
<small class="form-text text-muted">A Cerebras API kulcs</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label asp-for="ModelName"></label>
|
||||
<input asp-for="ModelName" class="form-control" placeholder="pl. gpt-3.5-turbo, gpt-4" />
|
||||
<span asp-validation-for="ModelName" class="text-danger"></span>
|
||||
<small class="form-text text-muted">Az AI modell neve (pl. gpt-3.5-turbo, gpt-4, claude-3-sonnet)</small>
|
||||
<small class="form-text text-muted">A Cerebras AI modell neve (pl. gpt-3.5-turbo, gpt-4, claude-3-sonnet)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label asp-for="OpenAIApiKey"></label>
|
||||
<input asp-for="OpenAIApiKey" class="form-control" type="password" placeholder="Adja meg az OpenAI API kulcsot" />
|
||||
<span asp-validation-for="OpenAIApiKey" class="text-danger"></span>
|
||||
<small class="form-text text-muted">Az OpenAI API kulcs</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label asp-for="OpenAIModelName"></label>
|
||||
<input asp-for="OpenAIModelName" class="form-control" placeholder="pl. gpt-3.5-turbo, gpt-4" />
|
||||
<span asp-validation-for="OpenAIModelName" class="text-danger"></span>
|
||||
<small class="form-text text-muted">Az AI modell neve (pl. gpt-3.5-turbo, gpt-4)</small>
|
||||
<small class="form-text text-muted">Az OpenAI AI modell neve (pl. gpt-3.5-turbo, gpt-4)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label asp-for="ApiBaseUrl"></label>
|
||||
<input asp-for="ApiBaseUrl" class="form-control" placeholder="https://api.openai.com/v1" />
|
||||
<span asp-validation-for="ApiBaseUrl" class="text-danger"></span>
|
||||
<small class="form-text text-muted">Az API alapcíme (OpenAI, Azure OpenAI, stb.)</small>
|
||||
<small class="form-text text-muted">A Cerebras API alapcíme (OpenAI, Azure OpenAI, stb.)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label asp-for="OpenAIApiBaseUrl"></label>
|
||||
<input asp-for="OpenAIApiBaseUrl" class="form-control" placeholder="https://api.openai.com/v1" />
|
||||
<span asp-validation-for="OpenAIApiBaseUrl" class="text-danger"></span>
|
||||
<small class="form-text text-muted">Az API alapcíme (OpenAI, Azure OpenAI, stb.)</small>
|
||||
<small class="form-text text-muted">Az OpenAI API alapcíme (OpenAI, Azure OpenAI, stb.)</small>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
|
|
@ -88,6 +90,28 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4" />
|
||||
<h5 class="mb-3"><i class="fas fa-file-alt me-2"></i>Z.ai GLM-OCR — Dokumentumfeldolgozás</h5>
|
||||
<p class="text-muted small mb-3">
|
||||
A GLM-OCR multimodális modell szállítólevelek és rendelési dokumentumok (kép, PDF) strukturált szövegkinyerésére.
|
||||
Táblázatokat HTML formátumban ad vissza, amit közvetlenül LLM promptba lehet illeszteni.
|
||||
API kulcs igénylése: <a href="https://bigmodel.cn" target="_blank">bigmodel.cn</a> — ingyenes tier elérhető.
|
||||
</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label asp-for="ZaiApiKey"></label>
|
||||
<input asp-for="ZaiApiKey" class="form-control" type="password" placeholder="Adja meg a Z.ai API kulcsot" />
|
||||
<span asp-validation-for="ZaiApiKey" class="text-danger"></span>
|
||||
<small class="form-text text-muted">Z.ai API kulcs (bigmodel.cn). Üres hagyva a GLM-OCR funkció nem érhető el.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label asp-for="ZaiModel"></label>
|
||||
<input asp-for="ZaiModel" class="form-control" placeholder="glm-ocr" />
|
||||
<span asp-validation-for="ZaiModel" class="text-danger"></span>
|
||||
<small class="form-text text-muted">GLM-OCR modell neve. Alapesetben: <code>glm-ocr</code></small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> Mentés
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
@T("Admin.Orders.EditOrderDetails") - @Model.CustomOrderNumber
|
||||
<small>
|
||||
<i class="fas fa-arrow-circle-left"></i>
|
||||
<a asp-action="List">@T("Admin.Orders.BackToList")</a>
|
||||
<a asp-controller="CustomOrder" asp-action="NewList">@T("Admin.Orders.BackToList")</a>
|
||||
</small>
|
||||
</h1>
|
||||
<div class="float-right">
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@
|
|||
<div class="card-body p-0">
|
||||
@* Anti-forgery token for AJAX POSTs *@
|
||||
@Html.AntiForgeryToken()
|
||||
<table id="fb-orders-grid" class="table table-bordered table-hover m-0" style="width:100%">
|
||||
<table id="fb-orders-grid" class="table table-bordered table-hover m-0 table-responsive" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" id="fb-check-all" title="Összes kijelölése"></th>
|
||||
|
|
@ -120,7 +120,7 @@
|
|||
<th>Partner</th>
|
||||
<th>InnVoice</th>
|
||||
<th>Súly</th>
|
||||
<th>Mérhető</th>
|
||||
<th>Mérendő</th>
|
||||
<th>Mérés</th>
|
||||
<th title="Kattintásra szerkeszthető">Átvétel <small class="text-muted">✏️</small></th>
|
||||
<th>Státusz</th>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,229 @@
|
|||
@model Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models.PreorderDetailModel
|
||||
@using FruitBank.Common.Enums
|
||||
|
||||
@{
|
||||
ViewBag.PageTitle = $"Előrendelés #{Model.PreorderId}";
|
||||
Layout = "~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/_FruitBankAdminLayout.cshtml";
|
||||
|
||||
var statusClass = Model.Status switch
|
||||
{
|
||||
PreorderStatus.Confirmed => "po-status-confirmed",
|
||||
PreorderStatus.PartiallyFulfilled => "po-status-partial",
|
||||
PreorderStatus.Cancelled => "po-status-cancelled",
|
||||
_ => "po-status-pending"
|
||||
};
|
||||
|
||||
var statusLabel = Model.Status switch
|
||||
{
|
||||
PreorderStatus.Confirmed => "Megerősítve",
|
||||
PreorderStatus.PartiallyFulfilled => "Részben teljesítve",
|
||||
PreorderStatus.Cancelled => "Törölve",
|
||||
_ => "Függőben"
|
||||
};
|
||||
}
|
||||
|
||||
@Html.AntiForgeryToken()
|
||||
|
||||
<style>
|
||||
.po-status-pending { background:#fff3cd; color:#856404; border-radius:6px; padding:4px 12px; font-weight:700; display:inline-block; }
|
||||
.po-status-confirmed { background:#d4edda; color:#155724; border-radius:6px; padding:4px 12px; font-weight:700; display:inline-block; }
|
||||
.po-status-partial { background:#fff8ee; color:#c87500; border-radius:6px; padding:4px 12px; font-weight:700; display:inline-block; }
|
||||
.po-status-cancelled { background:#f8d7da; color:#721c24; border-radius:6px; padding:4px 12px; font-weight:700; display:inline-block; }
|
||||
|
||||
.po-meta-card { background:#fff; border:1px solid #dde8da; border-radius:8px; padding:16px 20px; margin-bottom:20px; }
|
||||
.po-meta-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(200px,1fr)); gap:16px; }
|
||||
.po-meta-item .label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.5px; color:#6b7c6e; margin-bottom:4px; }
|
||||
.po-meta-item .value { font-size:15px; color:#1a3c22; font-weight:600; }
|
||||
|
||||
.item-fulfilled { background:#eaf7ee; }
|
||||
.item-partial { background:#fffbf0; }
|
||||
.item-dropped { background:#fdf0f0; color:#888; }
|
||||
.item-pending { }
|
||||
|
||||
.qty-bar-wrap { width:100px; display:inline-block; vertical-align:middle; }
|
||||
.qty-bar { height:6px; background:#dde8da; border-radius:3px; overflow:hidden; display:inline-block; width:100%; }
|
||||
.qty-bar-fill { height:100%; border-radius:3px; }
|
||||
</style>
|
||||
|
||||
<!-- Back link -->
|
||||
<a href="/Admin/Preorders" class="btn btn-default btn-sm mb-3">
|
||||
<i class="fas fa-arrow-left"></i> Vissza a listához
|
||||
</a>
|
||||
|
||||
<div class="content-header clearfix">
|
||||
<h1 class="float-left">
|
||||
<i class="fas fa-calendar-plus" style="color:#2d7a3a;"></i>
|
||||
Előrendelés <strong>#@Model.PreorderId</strong>
|
||||
<span class="@statusClass ml-2">@statusLabel</span>
|
||||
</h1>
|
||||
<div class="float-right">
|
||||
@if (Model.OrderId.HasValue)
|
||||
{
|
||||
<a href="/Admin/Order/Edit/@Model.OrderId" class="btn btn-success btn-sm" target="_blank">
|
||||
<i class="fas fa-external-link-alt"></i> Rendelés #@Model.OrderId
|
||||
</a>
|
||||
}
|
||||
@if (Model.Status == PreorderStatus.Pending)
|
||||
{
|
||||
<button id="cancelBtn" class="btn btn-danger btn-sm ml-2">
|
||||
<i class="fas fa-times"></i> Visszavonás
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="content">
|
||||
<div class="container-fluid">
|
||||
|
||||
<!-- ── Meta cards ──────────────────────────────────────────────────── -->
|
||||
<div class="po-meta-card">
|
||||
<div class="po-meta-grid">
|
||||
<div class="po-meta-item">
|
||||
<div class="label">Ügyfél</div>
|
||||
<div class="value">
|
||||
<a href="/Admin/Customer/Edit/@Model.CustomerId">@Model.CustomerName</a>
|
||||
</div>
|
||||
<small class="text-muted">@Model.CustomerEmail</small>
|
||||
</div>
|
||||
<div class="po-meta-item">
|
||||
<div class="label">Kért szállítási időpont</div>
|
||||
<div class="value"><i class="fas fa-calendar-day text-muted mr-1"></i>@Model.DateOfReceipt</div>
|
||||
</div>
|
||||
<div class="po-meta-item">
|
||||
<div class="label">Leadva</div>
|
||||
<div class="value">@Model.CreatedOnUtc</div>
|
||||
</div>
|
||||
<div class="po-meta-item">
|
||||
<div class="label">Utoljára frissítve</div>
|
||||
<div class="value">@Model.UpdatedOnUtc</div>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(Model.CustomerNote))
|
||||
{
|
||||
<div class="po-meta-item" style="grid-column:1/-1;">
|
||||
<div class="label">Ügyfél megjegyzése</div>
|
||||
<div class="value" style="font-weight:400;font-size:14px;color:#444;">@Model.CustomerNote</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Items table ─────────────────────────────────────────────────── -->
|
||||
<div class="card card-default">
|
||||
<div class="card-header">
|
||||
<strong>Tételek (@Model.Items.Count)</strong>
|
||||
@{
|
||||
var fulfilled = Model.Items.Count(i => i.Status == PreorderItemStatus.Fulfilled);
|
||||
var partial = Model.Items.Count(i => i.Status == PreorderItemStatus.PartiallyFulfilled);
|
||||
var dropped = Model.Items.Count(i => i.Status == PreorderItemStatus.Dropped);
|
||||
var pending = Model.Items.Count(i => i.Status == PreorderItemStatus.Pending);
|
||||
}
|
||||
<span class="ml-2 text-muted" style="font-size:13px;">
|
||||
@if (fulfilled > 0) { <span class="badge badge-success">@fulfilled teljesítve</span> }
|
||||
@if (partial > 0) { <span class="badge badge-warning ml-1">@partial részben</span> }
|
||||
@if (dropped > 0) { <span class="badge badge-danger ml-1">@dropped ejtve</span> }
|
||||
@if (pending > 0) { <span class="badge badge-secondary ml-1">@pending függőben</span> }
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-bordered table-hover table-sm m-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Termék</th>
|
||||
<th width="80" class="text-center">Kérve</th>
|
||||
<th width="80" class="text-center">Teljesítve</th>
|
||||
<th width="160">Teljesítés</th>
|
||||
<th width="130" class="text-right">Egységár</th>
|
||||
<th width="130" class="text-right">Becsült ár</th>
|
||||
<th width="110" class="text-center">Állapot</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var item in Model.Items)
|
||||
{
|
||||
var rowClass = item.Status switch
|
||||
{
|
||||
PreorderItemStatus.Fulfilled => "item-fulfilled",
|
||||
PreorderItemStatus.PartiallyFulfilled => "item-partial",
|
||||
PreorderItemStatus.Dropped => "item-dropped",
|
||||
_ => "item-pending"
|
||||
};
|
||||
var pct = item.RequestedQuantity > 0
|
||||
? (int)Math.Round((double)item.FulfilledQuantity / item.RequestedQuantity * 100)
|
||||
: 0;
|
||||
var barColor = pct == 100 ? "#2d7a3a" : pct > 0 ? "#f4a236" : "#dc3545";
|
||||
var estimatedPrice = item.IsMeasurable
|
||||
? "—"
|
||||
: (item.UnitPriceInclTax * item.FulfilledQuantity).ToString("N0") + " Ft";
|
||||
var unitPrice = item.IsMeasurable ? "súlymérés" : item.UnitPriceInclTax.ToString("N0") + " Ft";
|
||||
|
||||
<tr class="@rowClass">
|
||||
<td>
|
||||
<a href="/Admin/Product/Edit/@item.ProductId" target="_blank">@item.ProductName</a>
|
||||
@if (item.IsMeasurable)
|
||||
{
|
||||
<span class="badge badge-light ml-1" title="Súlymérést igényel">⚖️</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-center">@item.RequestedQuantity db</td>
|
||||
<td class="text-center">
|
||||
<strong>@item.FulfilledQuantity db</strong>
|
||||
</td>
|
||||
<td>
|
||||
<div class="qty-bar-wrap">
|
||||
<div class="qty-bar">
|
||||
<div class="qty-bar-fill" style="width:@pct%;background:@barColor;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<small class="ml-1">@pct%</small>
|
||||
</td>
|
||||
<td class="text-right">@unitPrice</td>
|
||||
<td class="text-right">@estimatedPrice</td>
|
||||
<td class="text-center">
|
||||
<span class="po-status-@item.Status.ToString().ToLower()" style="font-size:11px;padding:2px 6px;">
|
||||
@item.StatusLabel
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
@{
|
||||
var totalEstimated = Model.Items
|
||||
.Where(i => !i.IsMeasurable && (i.Status == PreorderItemStatus.Fulfilled || i.Status == PreorderItemStatus.PartiallyFulfilled))
|
||||
.Sum(i => i.UnitPriceInclTax * i.FulfilledQuantity);
|
||||
}
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="5" class="text-right"><strong>Becsült összeg:</strong></td>
|
||||
<td class="text-right"><strong>@totalEstimated.ToString("N0") Ft</strong></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (Model.Status == PreorderStatus.Pending)
|
||||
{
|
||||
<script>
|
||||
$(function () {
|
||||
$('#cancelBtn').click(function () {
|
||||
if (!confirm('Biztosan visszavonod ezt az előrendelést? Ez a művelet nem visszafordítható.')) return;
|
||||
$.ajax({
|
||||
url : '/Admin/Preorders/Cancel/@Model.PreorderId',
|
||||
type : 'POST',
|
||||
data : { __RequestVerificationToken: $('input[name="__RequestVerificationToken"]').val() },
|
||||
success: function (res) {
|
||||
if (res.success) {
|
||||
location.href = '/Admin/Preorders';
|
||||
} else {
|
||||
alert('Hiba: ' + (res.error || 'Ismeretlen hiba'));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
}
|
||||
|
|
@ -0,0 +1,497 @@
|
|||
@{
|
||||
ViewBag.PageTitle = "Előrendelések";
|
||||
NopHtml.SetActiveMenuItemSystemName("Preorders.List");
|
||||
Layout = "~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/_FruitBankAdminLayout.cshtml";
|
||||
}
|
||||
|
||||
@Html.AntiForgeryToken()
|
||||
|
||||
<div class="content-header clearfix">
|
||||
<h1 class="float-left">
|
||||
<i class="fas fa-calendar-plus" style="color:#2d7a3a;"></i>
|
||||
Előrendelések
|
||||
</h1>
|
||||
<div class="float-right">
|
||||
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#create-preorder-window">
|
||||
<i class="fas fa-plus"></i> Előrendelés rögzítése
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="content">
|
||||
<div class="container-fluid">
|
||||
|
||||
<!-- ── Tabs ─────────────────────────────────────────────────────── -->
|
||||
<ul class="nav nav-tabs mb-3" role="tablist">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" id="tab-list-link" data-toggle="tab" href="#tab-list" role="tab">
|
||||
<i class="fas fa-list"></i> Előrendelések
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" id="tab-demand-link" data-toggle="tab" href="#tab-demand" role="tab">
|
||||
<i class="fas fa-chart-bar"></i> Kereslet
|
||||
<span id="demandBadge" class="badge badge-warning ml-1" style="display:none;"></span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
|
||||
<!-- ══ TAB 1: Preorder list ══════════════════════════════════ -->
|
||||
<div class="tab-pane fade show active" id="tab-list" role="tabpanel">
|
||||
|
||||
<!-- Status filter bar -->
|
||||
<div class="card card-default mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex align-items-center" style="gap:8px; flex-wrap:wrap;">
|
||||
<span class="text-muted" style="font-size:13px;">Szűrő:</span>
|
||||
<button class="btn btn-sm btn-outline-secondary po-filter active" data-status="">Összes</button>
|
||||
<button class="btn btn-sm btn-outline-warning po-filter" data-status="0">Függőben</button>
|
||||
<button class="btn btn-sm btn-outline-success po-filter" data-status="10">Megerősítve</button>
|
||||
<button class="btn btn-sm po-filter" style="border-color:#f4a236;color:#f4a236;" data-status="20">Részben teljesítve</button>
|
||||
<button class="btn btn-sm btn-outline-danger po-filter" data-status="30">Törölve</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main grid -->
|
||||
<div class="card card-default">
|
||||
<div class="card-body p-0">
|
||||
<table id="po-grid" class="table table-bordered table-hover table-sm m-0" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="60">#</th>
|
||||
<th>Ügyfél</th>
|
||||
<th width="160" name="DateOfReceipt">Kért szállítás</th>
|
||||
<th width="160" name="CreatedOnUtc">Leadva</th>
|
||||
<th width="120" name="Status">Állapot</th>
|
||||
<th width="100" class="text-center">Tételek</th>
|
||||
<th width="70" class="text-center"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /#tab-list -->
|
||||
|
||||
<!-- ══ TAB 2: Demand overview ════════════════════════════════ -->
|
||||
<div class="tab-pane fade" id="tab-demand" role="tabpanel">
|
||||
|
||||
<!-- Toggle: open only / all time -->
|
||||
<div class="card card-default mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex align-items-center" style="gap:8px;">
|
||||
<span class="text-muted" style="font-size:13px;">Nézet:</span>
|
||||
<button class="btn btn-sm btn-warning demand-scope active" data-open="true">
|
||||
<i class="fas fa-clock"></i> Nyitott előrendelések
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary demand-scope" data-open="false">
|
||||
<i class="fas fa-history"></i> Összes idő
|
||||
</button>
|
||||
<small class="text-muted ml-3" id="demandScopeLabel">
|
||||
Termékek amelyekre még van teljesítetlen igény
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-default">
|
||||
<div class="card-body p-0">
|
||||
<table id="demand-grid" class="table table-bordered table-hover table-sm m-0" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Termék</th>
|
||||
<th width="80">SKU</th>
|
||||
<th width="110" class="text-center">Igényelt</th>
|
||||
<th width="110" class="text-center">Teljesített</th>
|
||||
<th width="130" class="text-center">Hiány ▼</th>
|
||||
<th width="90" class="text-center">Rendelések</th>
|
||||
<th width="120" class="text-right">Átlagár</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /#tab-demand -->
|
||||
|
||||
</div><!-- /.tab-content -->
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* Fix: jQuery UI autocomplete must appear above Bootstrap modals (z-index 1050) */
|
||||
.ui-autocomplete { z-index: 1060 !important; }
|
||||
|
||||
.po-status-pending { background:#fff3cd; color:#856404; border-radius:4px; padding:2px 8px; font-size:12px; font-weight:600; }
|
||||
.po-status-confirmed { background:#d4edda; color:#155724; border-radius:4px; padding:2px 8px; font-size:12px; font-weight:600; }
|
||||
.po-status-partial { background:#fff8ee; color:#c87500; border-radius:4px; padding:2px 8px; font-size:12px; font-weight:600; }
|
||||
.po-status-cancelled { background:#f8d7da; color:#721c24; border-radius:4px; padding:2px 8px; font-size:12px; font-weight:600; }
|
||||
.po-filter.active { font-weight:700; }
|
||||
.demand-scope.active { font-weight:700; }
|
||||
.demand-unfulfilled-high { color:#dc3545; font-weight:700; }
|
||||
.demand-unfulfilled-mid { color:#c87500; font-weight:600; }
|
||||
.demand-unfulfilled-ok { color:#6b7c6e; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
$(function () {
|
||||
var _token = $('input[name="__RequestVerificationToken"]').val();
|
||||
var activeStatus = '';
|
||||
var demandOpenOnly = true;
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
function statusBadge(row) {
|
||||
switch (row.Status) {
|
||||
case 0: return '<span class="po-status-pending">' + row.StatusLabel + '</span>';
|
||||
case 10: return '<span class="po-status-confirmed">' + row.StatusLabel + '</span>';
|
||||
case 20: return '<span class="po-status-partial">' + row.StatusLabel + '</span>';
|
||||
case 30: return '<span class="po-status-cancelled">' + row.StatusLabel + '</span>';
|
||||
default: return row.StatusLabel;
|
||||
}
|
||||
}
|
||||
|
||||
function itemProgress(row) {
|
||||
var total = row.ItemCount;
|
||||
var done = row.FulfilledCount;
|
||||
if (total === 0) return '—';
|
||||
var pct = Math.round(done / total * 100);
|
||||
var cls = pct === 100 ? 'bg-success' : pct > 0 ? 'bg-warning' : 'bg-danger';
|
||||
return '<div style="min-width:80px">' +
|
||||
'<div class="progress" style="height:6px;margin-bottom:3px;">' +
|
||||
'<div class="progress-bar ' + cls + '" style="width:' + pct + '%"></div></div>' +
|
||||
'<small>' + done + '/' + total + ' tétel</small></div>';
|
||||
}
|
||||
|
||||
function fmtQty(n) { return n.toLocaleString('hu-HU') + ' db'; }
|
||||
function fmtPrice(n) { return n > 0 ? Math.round(n).toLocaleString('hu-HU') + ' Ft' : '—'; }
|
||||
|
||||
function unfulfilledCell(n) {
|
||||
var cls = n > 100 ? 'demand-unfulfilled-high' : n > 20 ? 'demand-unfulfilled-mid' : 'demand-unfulfilled-ok';
|
||||
return '<span class="' + cls + '">' + fmtQty(n) + '</span>';
|
||||
}
|
||||
|
||||
// ── TAB 1: Preorder list ─────────────────────────────────────────────────
|
||||
var poTable = $('#po-grid').DataTable({
|
||||
serverSide : true,
|
||||
processing : true,
|
||||
pageLength : 25,
|
||||
lengthMenu : [[25, 50, 100], [25, 50, 100]],
|
||||
order : [[3, 'desc']],
|
||||
language : {
|
||||
processing : 'Betöltés...',
|
||||
search : 'Keresés:',
|
||||
lengthMenu : '_MENU_ sor/oldal',
|
||||
info : '_START_–_END_ / _TOTAL_ előrendelés',
|
||||
infoEmpty : '0 előrendelés',
|
||||
infoFiltered : '(szűrve _MAX_-ból)',
|
||||
paginate : { first: '««', previous: '«', next: '»', last: '»»' },
|
||||
emptyTable : 'Nincs előrendelés',
|
||||
zeroRecords : 'Nincs találat'
|
||||
},
|
||||
ajax: {
|
||||
url : '/Admin/Preorders/PreorderList',
|
||||
type: 'POST',
|
||||
data: function (d) {
|
||||
d.__RequestVerificationToken = _token;
|
||||
d.statusFilter = activeStatus;
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
{ data: 'PreorderId', name: 'PreorderId',
|
||||
render: function(d) { return '<strong>#' + d + '</strong>'; } },
|
||||
{ data: 'CustomerName', name: 'CustomerName',
|
||||
render: function(d, t, row) {
|
||||
return '<div>' + d + '</div><small class="text-muted">' + row.CustomerEmail + '</small>';
|
||||
}},
|
||||
{ data: 'DateOfReceipt', name: 'DateOfReceipt',
|
||||
render: function(d) { return '<i class="fas fa-calendar-day text-muted mr-1"></i>' + d; }},
|
||||
{ data: 'CreatedOnUtc', name: 'CreatedOnUtc',
|
||||
render: function(d) { return '<small>' + d + '</small>'; }},
|
||||
{ data: 'Status', name: 'Status', orderable: false,
|
||||
render: function(d, t, row) { return statusBadge(row); }},
|
||||
{ data: 'ItemCount', orderable: false, className: 'text-center',
|
||||
render: function(d, t, row) { return itemProgress(row); }},
|
||||
{ data: 'PreorderId', orderable: false, searchable: false,
|
||||
className: 'text-center', width: '60px',
|
||||
render: function(d) {
|
||||
return '<a href="/Admin/Preorders/Detail/' + d + '" class="btn btn-xs btn-default" title="Részletek">' +
|
||||
'<i class="fas fa-eye"></i></a>';
|
||||
}}
|
||||
]
|
||||
});
|
||||
|
||||
$(document).on('click', '.po-filter', function () {
|
||||
$('.po-filter').removeClass('active');
|
||||
$(this).addClass('active');
|
||||
activeStatus = $(this).data('status').toString();
|
||||
poTable.ajax.reload();
|
||||
});
|
||||
|
||||
// ── TAB 2: Demand grid ───────────────────────────────────────────────────
|
||||
var demandTable = $('#demand-grid').DataTable({
|
||||
serverSide : true,
|
||||
processing : true,
|
||||
pageLength : 50,
|
||||
lengthMenu : [[25, 50, 100, 250], [25, 50, 100, 250]],
|
||||
order : [[4, 'desc']],
|
||||
language : {
|
||||
processing : 'Betöltés...',
|
||||
search : 'Keresés:',
|
||||
lengthMenu : '_MENU_ sor/oldal',
|
||||
info : '_START_–_END_ / _TOTAL_ termék',
|
||||
infoEmpty : 'Nincs adat',
|
||||
infoFiltered : '(szűrve _MAX_-ból)',
|
||||
paginate : { first: '««', previous: '«', next: '»', last: '»»' },
|
||||
emptyTable : 'Nincs előrendelési igény',
|
||||
zeroRecords : 'Nincs találat'
|
||||
},
|
||||
ajax: {
|
||||
url : '/Admin/Preorders/DemandList',
|
||||
type: 'POST',
|
||||
data: function (d) {
|
||||
d.__RequestVerificationToken = _token;
|
||||
d.openOnly = demandOpenOnly ? 'true' : 'false';
|
||||
},
|
||||
dataSrc: function (json) {
|
||||
// Update badge with number of products that have unfulfilled demand
|
||||
var withDemand = (json.data || []).filter(function (r) { return r.TotalUnfulfilled > 0; }).length;
|
||||
if (withDemand > 0) { $('#demandBadge').text(withDemand).show(); }
|
||||
else { $('#demandBadge').hide(); }
|
||||
return json.data;
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
{ data: 'ProductName', name: 'ProductName',
|
||||
render: function(d, t, row) {
|
||||
var badge = row.IsMeasurable ? ' <span class="badge badge-light" title="Súlymérést igényel">⚖️</span>' : '';
|
||||
return '<a href="/Admin/Product/Edit/' + row.ProductId + '" target="_blank">' + d + '</a>' + badge;
|
||||
}},
|
||||
{ data: 'Sku', orderable: false,
|
||||
render: function(d) { return d ? '<code>' + d + '</code>' : ''; }},
|
||||
{ data: 'TotalRequested', orderable: false, className: 'text-center',
|
||||
render: function(d) { return fmtQty(d); }},
|
||||
{ data: 'TotalFulfilled', orderable: false, className: 'text-center',
|
||||
render: function(d, t, row) {
|
||||
var pct = row.TotalRequested > 0 ? Math.round(d / row.TotalRequested * 100) : 0;
|
||||
var cls = pct === 100 ? 'bg-success' : pct > 0 ? 'bg-warning' : 'bg-secondary';
|
||||
return '<div>' + fmtQty(d) + '</div>' +
|
||||
'<div class="progress mt-1" style="height:4px;">' +
|
||||
'<div class="progress-bar ' + cls + '" style="width:' + pct + '%"></div></div>';
|
||||
}},
|
||||
{ data: 'TotalUnfulfilled', orderable: false, className: 'text-center',
|
||||
render: function(d) { return unfulfilledCell(d); }},
|
||||
{ data: 'PreorderCount', orderable: false, className: 'text-center',
|
||||
render: function(d) { return '<span class="badge badge-secondary">' + d + '</span>'; }},
|
||||
{ data: 'AvgUnitPrice', orderable: false, className: 'text-right',
|
||||
render: function(d) { return fmtPrice(d); }}
|
||||
]
|
||||
});
|
||||
|
||||
// Load demand tab lazily on first click, reload on subsequent
|
||||
var demandLoaded = false;
|
||||
$('#tab-demand-link').on('shown.bs.tab', function () {
|
||||
if (!demandLoaded) { demandTable.ajax.reload(); demandLoaded = true; }
|
||||
else { demandTable.ajax.reload(); }
|
||||
});
|
||||
|
||||
// Scope toggle
|
||||
$(document).on('click', '.demand-scope', function () {
|
||||
$('.demand-scope').removeClass('active');
|
||||
$(this).addClass('active');
|
||||
demandOpenOnly = $(this).data('open') === true;
|
||||
$('#demandScopeLabel').text(demandOpenOnly
|
||||
? 'Termékek amelyekre még van teljesítetlen igény'
|
||||
: 'Összesített kereslet az összes előrendelésből');
|
||||
demandTable.ajax.reload();
|
||||
});
|
||||
|
||||
/* ── Create Preorder Modal ───────────────────────────────────── */
|
||||
var cpProducts = [];
|
||||
|
||||
// Customer autocomplete
|
||||
$('#cp-customer-search').autocomplete({
|
||||
delay : 400,
|
||||
minLength: 2,
|
||||
source : '/Admin/CustomOrder/CustomerSearchAutoComplete',
|
||||
select : function (e, ui) {
|
||||
$('#cp-customer-id').val(ui.item.value);
|
||||
$('#cp-customer-name').html('<strong>' + ui.item.label + '</strong>');
|
||||
$('#cp-customer-search').val('');
|
||||
$('#cp-product-search-section').slideDown();
|
||||
$('#cp-customer-error').hide();
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Product autocomplete — filtered by active preorder window (same logic as customer-facing page)
|
||||
$('#cp-product-search').autocomplete({
|
||||
delay : 400,
|
||||
minLength: 2,
|
||||
source : '/Admin/CustomOrder/PreorderProductSearchAutoComplete',
|
||||
select : function (e, ui) {
|
||||
addCpProduct(ui.item);
|
||||
$('#cp-product-search').val('');
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
function addCpProduct(item) {
|
||||
if (cpProducts.find(function (p) { return p.id === item.value; })) return;
|
||||
cpProducts.push({ id: item.value, name: item.label, quantity: 1, price: item.price || 0 });
|
||||
renderCpProducts();
|
||||
}
|
||||
|
||||
function renderCpProducts() {
|
||||
var $body = $('#cp-products-body').empty();
|
||||
if (!cpProducts.length) { $('#cp-products-section').hide(); return; }
|
||||
$('#cp-products-section').show();
|
||||
cpProducts.forEach(function (p, i) {
|
||||
$body.append(
|
||||
'<tr>' +
|
||||
'<td><strong>' + p.name + '</strong></td>' +
|
||||
'<td><input type="number" class="form-control form-control-sm" min="1" value="' + p.quantity + '" data-idx="' + i + '" onchange="window._cpUpdateQty(this)"></td>' +
|
||||
'<td><input type="text" class="form-control form-control-sm" value="' + p.price + '" data-idx="' + i + '" onchange="window._cpUpdatePrice(this)"></td>' +
|
||||
'<td class="text-center"><button type="button" class="btn btn-danger btn-xs" onclick="window._cpRemove(' + i + ')"><i class="fas fa-trash"></i></button></td>' +
|
||||
'</tr>'
|
||||
);
|
||||
});
|
||||
$('#cp-products-json').val(JSON.stringify(cpProducts));
|
||||
}
|
||||
|
||||
window._cpUpdateQty = function (el) { cpProducts[+el.dataset.idx].quantity = +el.value; renderCpProducts(); };
|
||||
window._cpUpdatePrice = function (el) { cpProducts[+el.dataset.idx].price = +el.value; renderCpProducts(); };
|
||||
window._cpRemove = function (i) { cpProducts.splice(i, 1); renderCpProducts(); };
|
||||
|
||||
$('#cp-form').on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
if (!$('#cp-customer-id').val()) {
|
||||
$('#cp-customer-error').show(); return;
|
||||
}
|
||||
if (!cpProducts.length) {
|
||||
alert('Legalább egy terméket adj hozzá!'); return;
|
||||
}
|
||||
if (!$('#cp-delivery').val()) {
|
||||
alert('Add meg a szállítási időpontot!'); return;
|
||||
}
|
||||
var btn = $(this).find('[type=submit]').prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Mentés...');
|
||||
$.ajax({
|
||||
url : '/Admin/Preorders/CreatePreorder',
|
||||
type: 'POST',
|
||||
data: {
|
||||
customerId : $('#cp-customer-id').val(),
|
||||
deliveryDateTime : $('#cp-delivery').val(),
|
||||
customerNote : $('#cp-note').val().trim(),
|
||||
productsJson : $('#cp-products-json').val(),
|
||||
__RequestVerificationToken: _token
|
||||
},
|
||||
success: function (r) {
|
||||
if (r.success) {
|
||||
$('#create-preorder-window').modal('hide');
|
||||
poTable.ajax.reload();
|
||||
demandTable.ajax.reload();
|
||||
if (r.orderId) {
|
||||
alert('Előrendelés rögzítve (#' + r.preorderId + '). Az azonnal elérhető tételek alapján rendelés is készült: #' + r.orderId);
|
||||
}
|
||||
} else {
|
||||
alert('Hiba: ' + (r.error || 'Ismeretlen hiba'));
|
||||
btn.prop('disabled', false).html('<i class="fas fa-save"></i> Mentés');
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
btn.prop('disabled', false).html('<i class="fas fa-save"></i> Mentés');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$('#create-preorder-window').on('hidden.bs.modal', function () {
|
||||
$('#cp-customer-search').val('');
|
||||
$('#cp-customer-id, #cp-customer-name').val('').html('');
|
||||
$('#cp-customer-error').hide();
|
||||
$('#cp-product-search-section').hide();
|
||||
$('#cp-delivery').val('');
|
||||
$('#cp-note').val('');
|
||||
cpProducts = [];
|
||||
renderCpProducts();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@* ── Create Preorder Modal ──────────────────────────────────────────────── *@
|
||||
<div id="create-preorder-window" class="modal fade" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header" style="background:#2d7a3a;color:#fff;">
|
||||
<h4 class="modal-title"><i class="fas fa-calendar-plus"></i> Előrendelés rögzítése (telefónos)</h4>
|
||||
<button type="button" class="close" data-dismiss="modal" style="color:#fff;"><span>×</span></button>
|
||||
</div>
|
||||
<form id="cp-form">
|
||||
<div class="form-horizontal">
|
||||
<div class="modal-body">
|
||||
|
||||
@* ─ Customer ─ *@
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3"><label class="col-form-label">Ugyfél</label></div>
|
||||
<div class="col-md-9">
|
||||
<input type="text" id="cp-customer-search" autocomplete="off" class="form-control" placeholder="Ugyfél neve, e-mail vagy cég neve..." />
|
||||
<span id="cp-customer-name" class="mt-1 d-block text-success"></span>
|
||||
<input type="hidden" id="cp-customer-id" />
|
||||
<span class="field-validation-error" id="cp-customer-error" style="display:none;">Kérjük válasszon ügyfelet</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ─ Delivery date+time ─ *@
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3"><label class="col-form-label">Szállítási időpont</label></div>
|
||||
<div class="col-md-9">
|
||||
<input type="datetime-local" id="cp-delivery" class="form-control" />
|
||||
<small class="text-muted">Kívánt szállítási nap és időpont</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ─ Product search ─ *@
|
||||
<div class="form-group row" id="cp-product-search-section" style="display:none;">
|
||||
<div class="col-md-3"><label class="col-form-label">Termék hozzáadása</label></div>
|
||||
<div class="col-md-9">
|
||||
<input type="text" id="cp-product-search" autocomplete="off" class="form-control" placeholder="Termék neve vagy SKU..." />
|
||||
<small class="text-muted">Csak az előrendelési ablakban szereplő termékek jelennek meg</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ─ Products table ─ *@
|
||||
<div id="cp-products-section" style="display:none;">
|
||||
<table class="table table-sm table-bordered" id="cp-products-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Termék</th>
|
||||
<th style="width:100px">Mennyiség</th>
|
||||
<th style="width:120px">Egységár</th>
|
||||
<th style="width:40px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="cp-products-body"></tbody>
|
||||
</table>
|
||||
<input type="hidden" id="cp-products-json" />
|
||||
</div>
|
||||
|
||||
@* ─ Note ─ *@
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3"><label class="col-form-label">Megjegyzés <small class="text-muted">(nem köt.)</small></label></div>
|
||||
<div class="col-md-9">
|
||||
<textarea id="cp-note" class="form-control" rows="2" maxlength="1000" placeholder="Esetleges megjegyzés az előrendeléssel kapcsolatban..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">Mégse</button>
|
||||
<button type="submit" class="btn btn-primary"><i class="fas fa-save"></i> Mentés</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,282 @@
|
|||
@{
|
||||
ViewBag.PageTitle = "Előrendelés — termékelérhetőség";
|
||||
NopHtml.SetActiveMenuItemSystemName("PreorderAvailability");
|
||||
Layout = "~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/_FruitBankAdminLayout.cshtml";
|
||||
}
|
||||
|
||||
@Html.AntiForgeryToken()
|
||||
|
||||
<div class="content-header clearfix">
|
||||
<h1 class="float-left">
|
||||
<i class="fas fa-calendar-check" style="color:#2d7a3a;"></i>
|
||||
Előrendelés — termékelérhetőség
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<section class="content">
|
||||
<div class="container-fluid">
|
||||
|
||||
<!-- ── Tabs ─────────────────────────────────────────────────────── -->
|
||||
<ul class="nav nav-tabs mb-3" id="paTabs" role="tablist">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" id="tab-all-link" data-toggle="tab" href="#tab-all" role="tab">
|
||||
<i class="fas fa-list"></i> Összes termék
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" id="tab-today-link" data-toggle="tab" href="#tab-today" role="tab">
|
||||
<i class="fas fa-calendar-day"></i> Elérhető ma
|
||||
<span id="todayBadge" class="badge badge-success ml-1" style="display:none;"></span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
|
||||
<!-- ── TAB 1: All products ──────────────────────────────────── -->
|
||||
<div class="tab-pane fade show active" id="tab-all" role="tabpanel">
|
||||
<div class="card card-default">
|
||||
<div class="card-header">
|
||||
<div class="d-flex align-items-center gap-2" style="gap:10px;">
|
||||
<span class="text-muted" style="font-size:13px;">
|
||||
Kattints a dátum cellákra a szerkesztéshez.
|
||||
Törléshez hagyd üresen és nyomj Entert.
|
||||
</span>
|
||||
<button id="btnSaveAll" class="btn btn-sm btn-success ml-auto" style="display:none;">
|
||||
<i class="fas fa-save"></i> Összes módosítás mentése
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table id="pa-grid" class="table table-bordered table-hover table-sm m-0" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Termék neve</th>
|
||||
<th width="100">SKU</th>
|
||||
<th width="160" title="Kattints a szerkesztéshez">Elérhetőség kezdete ✏️</th>
|
||||
<th width="160" title="Kattints a szerkesztéshez">Elérhetőség vége ✏️</th>
|
||||
<th width="110" class="text-center">Ma elérhető?</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── TAB 2: Available today ───────────────────────────────── -->
|
||||
<div class="tab-pane fade" id="tab-today" role="tabpanel">
|
||||
<div class="card card-default">
|
||||
<div class="card-header">
|
||||
<span class="text-muted" style="font-size:13px;">
|
||||
Termékek, amelyek ma meg tudják rendelni az ügyfelek előrendelésként.
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table id="today-grid" class="table table-bordered table-hover table-sm m-0" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Termék neve</th>
|
||||
<th width="100">SKU</th>
|
||||
<th width="160">Elérhetőség kezdete</th>
|
||||
<th width="160">Elérhetőség vége</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /.tab-content -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* ── Editable date cells ──────────────────────────────────────── */
|
||||
#pa-grid tbody td.pa-date {
|
||||
cursor: pointer;
|
||||
min-width: 130px;
|
||||
}
|
||||
#pa-grid tbody td.pa-date:hover {
|
||||
background-color: #fff8e1;
|
||||
}
|
||||
#pa-grid tbody td.pa-date input[type="date"] {
|
||||
width: 135px;
|
||||
font-size: 13px;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid #80bdff;
|
||||
border-radius: 3px;
|
||||
color: #333;
|
||||
}
|
||||
.pa-available-yes { color: #2d7a3a; font-weight: 600; }
|
||||
.pa-available-no { color: #999; }
|
||||
.pa-date-set { color: #1a3c22; }
|
||||
.pa-date-empty { color: #bbb; font-style: italic; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
$(function () {
|
||||
var _token = $('input[name="__RequestVerificationToken"]').val();
|
||||
|
||||
function fmtDate(val) {
|
||||
if (!val) return '<span class="pa-date-empty">—</span>';
|
||||
// "2026-04-21" → "2026. 04. 21."
|
||||
var p = val.split('-');
|
||||
return '<span class="pa-date-set">' + p[0] + '. ' + p[1] + '. ' + p[2] + '.</span>';
|
||||
}
|
||||
|
||||
function fmtAvailable(row) {
|
||||
return row.IsAvailableToday
|
||||
? '<span class="pa-available-yes"><i class="fas fa-check-circle"></i> Igen</span>'
|
||||
: '<span class="pa-available-no">—</span>';
|
||||
}
|
||||
|
||||
// ── ALL PRODUCTS TABLE ───────────────────────────────────────────────────
|
||||
var paTable = $('#pa-grid').DataTable({
|
||||
serverSide : true,
|
||||
processing : true,
|
||||
pageLength : 50,
|
||||
lengthMenu : [[25, 50, 100, 250], [25, 50, 100, 250]],
|
||||
order : [[0, 'asc']],
|
||||
language : {
|
||||
processing : 'Betöltés...',
|
||||
search : 'Keresés:',
|
||||
lengthMenu : '_MENU_ sor/oldal',
|
||||
info : '_START_–_END_ / _TOTAL_ termék',
|
||||
infoEmpty : '0 termék',
|
||||
infoFiltered : '(szűrve _MAX_-ból)',
|
||||
paginate : { first: '««', previous: '«', next: '»', last: '»»' },
|
||||
emptyTable : 'Nincs termék',
|
||||
zeroRecords : 'Nincs találat'
|
||||
},
|
||||
ajax: {
|
||||
url : '/Admin/PreorderAvailability/ProductList',
|
||||
type: 'POST',
|
||||
data: function (d) { d.__RequestVerificationToken = _token; }
|
||||
},
|
||||
columns: [
|
||||
/* 0 */ { data: 'ProductName', name: 'ProductName' },
|
||||
/* 1 */ { data: 'Sku', name: 'Sku', orderable: false,
|
||||
render: function (d) { return d ? '<code>' + d + '</code>' : ''; } },
|
||||
/* 2 */ { data: 'WindowStart', name: 'WindowStart', className: 'pa-date', orderable: false,
|
||||
render: function (d) { return fmtDate(d); } },
|
||||
/* 3 */ { data: 'WindowEnd', name: 'WindowEnd', className: 'pa-date', orderable: false,
|
||||
render: function (d) { return fmtDate(d); } },
|
||||
/* 4 */ { data: 'IsAvailableToday', orderable: false, className: 'text-center',
|
||||
render: function (d, t, row) { return fmtAvailable(row); } }
|
||||
]
|
||||
});
|
||||
|
||||
// ── INLINE DATE EDITING ──────────────────────────────────────────────────
|
||||
$(document).on('click', '#pa-grid tbody td.pa-date', function () {
|
||||
var $td = $(this);
|
||||
if ($td.find('input').length) return; // already open
|
||||
|
||||
var $row = $td.closest('tr');
|
||||
var rowData = paTable.row($row).data();
|
||||
if (!rowData) return;
|
||||
|
||||
var colIdx = paTable.cell($td).index().column;
|
||||
var isStart = colIdx === 2;
|
||||
var current = isStart ? (rowData.WindowStart || '') : (rowData.WindowEnd || '');
|
||||
var savedHtml = $td.html();
|
||||
|
||||
var $inp = $('<input type="date">').val(current);
|
||||
$td.html('').append($inp);
|
||||
$inp.focus();
|
||||
|
||||
function restore() { $td.html(savedHtml); }
|
||||
|
||||
function persist() {
|
||||
var newVal = $inp.val().trim(); // "yyyy-MM-dd" or ""
|
||||
var oldVal = current;
|
||||
|
||||
if (newVal === oldVal) { restore(); return; }
|
||||
|
||||
// Optimistically update local row data
|
||||
if (isStart) rowData.WindowStart = newVal || null;
|
||||
else rowData.WindowEnd = newVal || null;
|
||||
|
||||
$.ajax({
|
||||
url : '/Admin/PreorderAvailability/SaveWindow',
|
||||
type : 'POST',
|
||||
data : {
|
||||
__RequestVerificationToken : _token,
|
||||
productId : rowData.ProductId,
|
||||
windowStart : rowData.WindowStart || '',
|
||||
windowEnd : rowData.WindowEnd || ''
|
||||
},
|
||||
success: function (res) {
|
||||
if (res.success) {
|
||||
rowData.IsAvailableToday = res.isAvailableToday;
|
||||
paTable.row($row).data(rowData).invalidate().draw(false);
|
||||
} else {
|
||||
alert('Mentési hiba: ' + (res.error || 'Ismeretlen hiba'));
|
||||
restore();
|
||||
}
|
||||
},
|
||||
error: function () { restore(); }
|
||||
});
|
||||
}
|
||||
|
||||
$inp.on('blur', function () { persist(); });
|
||||
$inp.on('keydown', function (e) {
|
||||
if (e.key === 'Enter') { $inp.off('blur'); persist(); }
|
||||
if (e.key === 'Escape') { $inp.off('blur'); restore(); }
|
||||
});
|
||||
});
|
||||
|
||||
// ── AVAILABLE TODAY TABLE ────────────────────────────────────────────────
|
||||
var todayTable = $('#today-grid').DataTable({
|
||||
serverSide : true,
|
||||
processing : true,
|
||||
pageLength : 50,
|
||||
order : [[0, 'asc']],
|
||||
language : {
|
||||
processing : 'Betöltés...',
|
||||
search : 'Keresés:',
|
||||
lengthMenu : '_MENU_ sor/oldal',
|
||||
info : '_START_–_END_ / _TOTAL_ termék',
|
||||
infoEmpty : 'Egy termék sincs ma elérhető előrendelésre.',
|
||||
infoFiltered : '(szűrve _MAX_-ból)',
|
||||
paginate : { first: '««', previous: '«', next: '»', last: '»»' },
|
||||
emptyTable : 'Egy termék sincs ma elérhető előrendelésre.',
|
||||
zeroRecords : 'Nincs találat'
|
||||
},
|
||||
ajax: {
|
||||
url : '/Admin/PreorderAvailability/AvailableTodayList',
|
||||
type: 'POST',
|
||||
data: function (d) { d.__RequestVerificationToken = _token; },
|
||||
dataSrc: function (json) {
|
||||
// Update the badge on the tab
|
||||
var count = json.recordsTotal;
|
||||
if (count > 0) {
|
||||
$('#todayBadge').text(count).show();
|
||||
} else {
|
||||
$('#todayBadge').hide();
|
||||
}
|
||||
return json.data;
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
/* 0 */ { data: 'ProductName', name: 'ProductName' },
|
||||
/* 1 */ { data: 'Sku', orderable: false,
|
||||
render: function (d) { return d ? '<code>' + d + '</code>' : ''; } },
|
||||
/* 2 */ { data: 'WindowStart', orderable: false,
|
||||
render: function (d) { return fmtDate(d); } },
|
||||
/* 3 */ { data: 'WindowEnd', orderable: false,
|
||||
render: function (d) { return fmtDate(d); } }
|
||||
]
|
||||
});
|
||||
|
||||
// Load today table when that tab is first clicked
|
||||
var todayLoaded = false;
|
||||
$('#tab-today-link').on('shown.bs.tab', function () {
|
||||
if (!todayLoaded) { todayTable.ajax.reload(); todayLoaded = true; }
|
||||
});
|
||||
|
||||
// Always reload today table when switching to it (data may have changed)
|
||||
$('#tab-today-link').on('show.bs.tab', function () {
|
||||
if (todayLoaded) todayTable.ajax.reload();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nop.Web.Framework.Components;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Components;
|
||||
|
||||
public class CustomerPreorderNavViewComponent : NopViewComponent
|
||||
{
|
||||
public IViewComponentResult Invoke(string widgetZone, object additionalData)
|
||||
{
|
||||
return View("~/Plugins/Misc.FruitBankPlugin/Views/CustomerPreorder/NavItem.cshtml");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
using FruitBank.Common.Enums;
|
||||
using LinqToDB;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nop.Core;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||
using Nop.Services.Customers;
|
||||
using Nop.Web.Framework.Controllers;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers;
|
||||
|
||||
public class CustomerPreorderController : BasePluginController
|
||||
{
|
||||
private readonly IWorkContext _workContext;
|
||||
private readonly ICustomerService _customerService;
|
||||
private readonly PreorderDbContext _preorderDbContext;
|
||||
private readonly FruitBankDbContext _dbContext;
|
||||
|
||||
public CustomerPreorderController(
|
||||
IWorkContext workContext,
|
||||
ICustomerService customerService,
|
||||
PreorderDbContext preorderDbContext,
|
||||
FruitBankDbContext dbContext)
|
||||
{
|
||||
_workContext = workContext;
|
||||
_customerService = customerService;
|
||||
_preorderDbContext = preorderDbContext;
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List()
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Challenge();
|
||||
|
||||
// Load this customer's preorders, newest first
|
||||
var preorders = await _preorderDbContext.Preorders
|
||||
.GetAllByCustomerIdAsync(customer.Id, false)
|
||||
.OrderByDescending(p => p.CreatedOnUtc)
|
||||
.ToListAsync();
|
||||
|
||||
var allItems = await _preorderDbContext.PreorderItems.GetAll()
|
||||
.Where(i => preorders.Select(p => p.Id).Contains(i.PreorderId))
|
||||
.ToListAsync();
|
||||
|
||||
// Resolve product names
|
||||
var productIds = allItems.Select(i => i.ProductId).Distinct().ToList();
|
||||
var productDtos = await _dbContext.ProductDtos
|
||||
.GetAll(false)
|
||||
.Where(p => productIds.Contains(p.Id))
|
||||
.ToListAsync();
|
||||
var productById = productDtos.ToDictionary(p => p.Id);
|
||||
|
||||
var rows = preorders.Select(p =>
|
||||
{
|
||||
var items = allItems.Where(i => i.PreorderId == p.Id).ToList();
|
||||
|
||||
// Derive status from quantities (enum reads unreliable in LinqToDB)
|
||||
var allFulfilled = items.Any() && items.All(i => i.FulfilledQuantity >= i.RequestedQuantity);
|
||||
var anyFulfilled = items.Any(i => i.FulfilledQuantity > 0);
|
||||
var allDropped = items.Any() && items.All(i => i.FulfilledQuantity == 0 &&
|
||||
i.RequestedQuantity > 0);
|
||||
|
||||
var effectiveStatus = (int)p.Status != 0 ? p.Status
|
||||
: allFulfilled ? PreorderStatus.Confirmed
|
||||
: anyFulfilled ? PreorderStatus.PartiallyFulfilled
|
||||
: PreorderStatus.Pending;
|
||||
|
||||
return new CustomerPreorderRow
|
||||
{
|
||||
PreorderId = p.Id,
|
||||
OrderId = p.OrderId,
|
||||
DateOfReceipt = p.DateOfReceipt,
|
||||
CreatedOnUtc = p.CreatedOnUtc,
|
||||
Status = effectiveStatus,
|
||||
CustomerNote = p.CustomerNote,
|
||||
Items = items.Select(i =>
|
||||
{
|
||||
productById.TryGetValue(i.ProductId, out var dto);
|
||||
return new CustomerPreorderItemRow
|
||||
{
|
||||
ProductName = dto?.Name ?? $"Termék #{i.ProductId}",
|
||||
IsMeasurable = dto?.IsMeasurable ?? false,
|
||||
RequestedQuantity = i.RequestedQuantity,
|
||||
FulfilledQuantity = i.FulfilledQuantity,
|
||||
UnitPriceInclTax = i.UnitPriceInclTax,
|
||||
Status = i.FulfilledQuantity == 0
|
||||
? PreorderItemStatus.Pending
|
||||
: i.FulfilledQuantity >= i.RequestedQuantity
|
||||
? PreorderItemStatus.Fulfilled
|
||||
: PreorderItemStatus.PartiallyFulfilled
|
||||
};
|
||||
}).ToList()
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
return View("~/Plugins/Misc.FruitBankPlugin/Views/CustomerPreorder/List.cshtml", rows);
|
||||
}
|
||||
|
||||
// ── Inner models ──────────────────────────────────────────────────────────
|
||||
|
||||
public class CustomerPreorderRow
|
||||
{
|
||||
public int PreorderId { get; set; }
|
||||
public int? OrderId { get; set; }
|
||||
public DateTime DateOfReceipt { get; set; }
|
||||
public DateTime CreatedOnUtc { get; set; }
|
||||
public PreorderStatus Status { get; set; }
|
||||
public string? CustomerNote { get; set; }
|
||||
public List<CustomerPreorderItemRow> Items { get; set; } = new();
|
||||
}
|
||||
|
||||
public class CustomerPreorderItemRow
|
||||
{
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
public bool IsMeasurable { get; set; }
|
||||
public int RequestedQuantity { get; set; }
|
||||
public int FulfilledQuantity { get; set; }
|
||||
public decimal UnitPriceInclTax { get; set; }
|
||||
public PreorderItemStatus Status { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nop.Web.Framework.Controllers;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers;
|
||||
|
||||
public class HelpController : BasePluginController
|
||||
{
|
||||
[HttpGet]
|
||||
public IActionResult Index()
|
||||
{
|
||||
return View("~/Plugins/Misc.FruitBankPlugin/Views/Help/Index.cshtml");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,630 @@
|
|||
using FruitBank.Common.Entities;
|
||||
using FruitBank.Common.Server;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nop.Core;
|
||||
using Nop.Core.Domain.Catalog;
|
||||
using Nop.Core.Domain.Orders;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||
using Nop.Services.Catalog;
|
||||
using Nop.Services.Customers;
|
||||
using Nop.Services.Localization;
|
||||
using Nop.Services.Orders;
|
||||
using Nop.Web.Framework.Controllers;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers;
|
||||
|
||||
[AutoValidateAntiforgeryToken]
|
||||
public class OrderController : BasePluginController
|
||||
{
|
||||
private readonly IWorkContext _workContext;
|
||||
private readonly IStoreContext _storeContext;
|
||||
private readonly ICustomerService _customerService;
|
||||
private readonly ILocalizationService _localizationService;
|
||||
private readonly FruitBankDbContext _dbContext;
|
||||
private readonly PreorderDbContext _preorderDbContext;
|
||||
private readonly FruitBankAttributeService _fruitBankAttributeService;
|
||||
private readonly CustomPriceCalculationService _customPriceCalculationService;
|
||||
private readonly IShoppingCartService _shoppingCartService;
|
||||
private readonly IProductService _productService;
|
||||
private readonly OpenAIApiService _aiApiService;
|
||||
private readonly CerebrasAPIService _cerebrasApiService;
|
||||
private readonly PreorderConversionService _preorderConversionService;
|
||||
|
||||
private const string PendingDeliveryKey = "OrderFlowPendingDeliveryDateTime";
|
||||
|
||||
public OrderController(
|
||||
IWorkContext workContext,
|
||||
IStoreContext storeContext,
|
||||
ICustomerService customerService,
|
||||
ILocalizationService localizationService,
|
||||
FruitBankDbContext dbContext,
|
||||
PreorderDbContext preorderDbContext,
|
||||
FruitBankAttributeService fruitBankAttributeService,
|
||||
IPriceCalculationService priceCalculationService,
|
||||
IShoppingCartService shoppingCartService,
|
||||
IProductService productService,
|
||||
OpenAIApiService aiApiService,
|
||||
CerebrasAPIService cerebrasApiService,
|
||||
PreorderConversionService preorderConversionService)
|
||||
{
|
||||
_workContext = workContext;
|
||||
_storeContext = storeContext;
|
||||
_customerService = customerService;
|
||||
_localizationService = localizationService;
|
||||
_dbContext = dbContext;
|
||||
_preorderDbContext = preorderDbContext;
|
||||
_fruitBankAttributeService = fruitBankAttributeService;
|
||||
_customPriceCalculationService = priceCalculationService as CustomPriceCalculationService;
|
||||
_shoppingCartService = shoppingCartService;
|
||||
_productService = productService;
|
||||
_aiApiService = aiApiService;
|
||||
_cerebrasApiService = cerebrasApiService;
|
||||
_preorderConversionService = preorderConversionService;
|
||||
}
|
||||
|
||||
// ── INDEX ─────────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Challenge();
|
||||
|
||||
return View("~/Plugins/Misc.FruitBankPlugin/Views/Order/Index.cshtml");
|
||||
}
|
||||
|
||||
// ── FLOW TYPE ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Mon/Tue/Wed → preorder regardless of delivery date.
|
||||
/// Thu/Fri/Sat/Sun + delivery this week → quickorder.
|
||||
/// Thu/Fri/Sat/Sun + delivery next week or later → preorder.
|
||||
/// </summary>
|
||||
public static string ComputeFlowType(DateTime deliveryDate)
|
||||
{
|
||||
var today = DateTime.Today;
|
||||
var todayDow = (int)today.DayOfWeek; // 0=Sun 1=Mon … 6=Sat
|
||||
|
||||
// This week's Thursday
|
||||
int daysSinceMon = todayDow == 0 ? 6 : todayDow - 1;
|
||||
var weekStart = today.AddDays(-daysSinceMon); // Monday
|
||||
var thisThursday = weekStart.AddDays(3); // Thursday
|
||||
var weekEnd = weekStart.AddDays(6); // Sunday
|
||||
|
||||
bool deliveryBeforeThursday = deliveryDate.Date < thisThursday;
|
||||
bool isLateWeek = todayDow == 0 || todayDow >= 4; // Thu-Sun
|
||||
bool deliveryThisWeek = deliveryDate.Date >= weekStart && deliveryDate.Date <= weekEnd;
|
||||
|
||||
// Quick Order: delivery needs current stock (before Thursday)
|
||||
// OR goods already arrived (Thu-Sun) and delivery still this week
|
||||
// Preorder: delivery is Thursday+ but today is still Mon/Tue/Wed (goods not yet here)
|
||||
return (deliveryBeforeThursday || (isLateWeek && deliveryThisWeek))
|
||||
? "quickorder"
|
||||
: "preorder";
|
||||
}
|
||||
|
||||
// ── GET / SET DELIVERY DATETIME ───────────────────────────────────────────
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetDeliveryDateTime()
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Json(new { success = false });
|
||||
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
var saved = await _fruitBankAttributeService
|
||||
.GetGenericAttributeValueAsync<Nop.Core.Domain.Customers.Customer, DateTime?>(
|
||||
customer.Id, PendingDeliveryKey, store.Id);
|
||||
|
||||
if (!saved.HasValue)
|
||||
return Json(new { success = true, hasValue = false });
|
||||
|
||||
var flowType = ComputeFlowType(saved.Value);
|
||||
|
||||
return Json(new
|
||||
{
|
||||
success = true,
|
||||
hasValue = true,
|
||||
date = saved.Value.ToString("yyyy-MM-dd"),
|
||||
time = saved.Value.ToString("HH:mm"),
|
||||
iso = saved.Value.ToString("yyyy-MM-ddTHH:mm"),
|
||||
flowType
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> SetDeliveryDateTime(string deliveryDateTime)
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Json(new { success = false, message = "Nincs bejelentkezve" });
|
||||
|
||||
if (string.IsNullOrWhiteSpace(deliveryDateTime) ||
|
||||
!DateTime.TryParse(deliveryDateTime, out var parsed))
|
||||
return Json(new { success = false, message = "Érvénytelen dátum/idő formátum" });
|
||||
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync<Nop.Core.Domain.Customers.Customer, DateTime>(
|
||||
customer.Id, PendingDeliveryKey, parsed, store.Id);
|
||||
|
||||
var flowType = ComputeFlowType(parsed);
|
||||
|
||||
Console.WriteLine($"[OrderFlow] SetDeliveryDateTime — customer #{customer.Id}, {parsed:u}, flowType={flowType}");
|
||||
return Json(new { success = true, flowType });
|
||||
}
|
||||
|
||||
// ── PRODUCTS — Quick Order flow (all available stock) ─────────────────────
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAllProducts(string deliveryDate = null, string deliveryTime = null)
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Json(new { success = false, message = "Nincs bejelentkezve" });
|
||||
|
||||
try
|
||||
{
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
var allProductDtos = (await _dbContext.ProductDtos.GetAll(true).ToListAsync())
|
||||
.Where(pd => pd.AvailableQuantity > 0);
|
||||
|
||||
var result = new List<object>();
|
||||
foreach (var product in allProductDtos)
|
||||
{
|
||||
var availableQty = product.StockQuantity + product.IncomingQuantity;
|
||||
if (availableQty <= 0) continue;
|
||||
|
||||
decimal? unitPrice = null;
|
||||
if (!product.IsMeasurable && _customPriceCalculationService != null)
|
||||
{
|
||||
var tproduct = await _productService.GetProductByIdAsync(product.Id);
|
||||
if (tproduct != null)
|
||||
{
|
||||
var pr = await _customPriceCalculationService.GetFinalPriceAsync(
|
||||
tproduct, customer, store, null, 0, true, 1, null, null);
|
||||
unitPrice = pr.finalPrice;
|
||||
}
|
||||
}
|
||||
|
||||
result.Add(new
|
||||
{
|
||||
id = product.Id,
|
||||
name = product.Name,
|
||||
quantity = 1,
|
||||
unitPrice,
|
||||
stockQuantity = availableQty,
|
||||
searchTerm = (string)null,
|
||||
isQuantityReduced = false,
|
||||
isMeasurable = product.IsMeasurable
|
||||
});
|
||||
}
|
||||
|
||||
return Json(new { success = true, products = result });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
// ── PRODUCTS — Preorder flow (curated window list) ────────────────────────
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetPreorderProducts()
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Json(new { success = false, message = "Nincs bejelentkezve" });
|
||||
|
||||
try
|
||||
{
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
var today = DateTime.UtcNow.Date;
|
||||
|
||||
var gaStart = await _dbContext.GenericAttributes.Table
|
||||
.Where(ga => ga.KeyGroup == nameof(Product)
|
||||
&& ga.Key == FruitBankConst.PreorderWindowStart
|
||||
&& ga.StoreId == store.Id)
|
||||
.ToListAsync();
|
||||
var gaEnd = await _dbContext.GenericAttributes.Table
|
||||
.Where(ga => ga.KeyGroup == nameof(Product)
|
||||
&& ga.Key == FruitBankConst.PreorderWindowEnd
|
||||
&& ga.StoreId == store.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var startById = gaStart.ToDictionary(g => g.EntityId, g => g.Value);
|
||||
var endById = gaEnd.ToDictionary(g => g.EntityId, g => g.Value);
|
||||
|
||||
var availableIds = startById.Keys.Intersect(endById.Keys)
|
||||
.Where(id =>
|
||||
{
|
||||
DateTime.TryParse(startById[id], out var ws);
|
||||
DateTime.TryParse(endById[id], out var we);
|
||||
return ws.Date <= today && today <= we.Date;
|
||||
})
|
||||
.ToHashSet();
|
||||
|
||||
if (!availableIds.Any())
|
||||
return Json(new { success = true, products = Array.Empty<object>() });
|
||||
|
||||
var productDtos = await _dbContext.ProductDtos
|
||||
.GetAll(true)
|
||||
.Where(p => availableIds.Contains(p.Id))
|
||||
.ToListAsync();
|
||||
|
||||
var result = new List<object>();
|
||||
foreach (var dto in productDtos.OrderBy(p => p.Name))
|
||||
{
|
||||
decimal? unitPrice = null;
|
||||
if (!dto.IsMeasurable && _customPriceCalculationService != null)
|
||||
{
|
||||
var product = await _dbContext.Products.GetByIdAsync(dto.Id);
|
||||
if (product != null)
|
||||
{
|
||||
var pr = await _customPriceCalculationService.GetFinalPriceAsync(
|
||||
product, customer, store, null, 0, true, 1, null, null);
|
||||
unitPrice = pr.finalPrice;
|
||||
}
|
||||
}
|
||||
|
||||
result.Add(new
|
||||
{
|
||||
id = dto.Id,
|
||||
name = dto.Name,
|
||||
isMeasurable = dto.IsMeasurable,
|
||||
unitPrice,
|
||||
stockQuantity = dto.AvailableQuantity
|
||||
});
|
||||
}
|
||||
|
||||
return Json(new { success = true, products = result });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
// ── SEARCH (Quick Order flow) ─────────────────────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> SearchProducts(string text, string deliveryDate = null, string deliveryTime = null)
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Json(new { success = false, message = "Nincs bejelentkezve" });
|
||||
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return Json(new { success = false, message = "Nincs szöveg megadva" });
|
||||
|
||||
try
|
||||
{
|
||||
var parsedProducts = await ParseProductsFromText(text);
|
||||
if (parsedProducts == null || !parsedProducts.Any())
|
||||
return Json(new { success = false, message = "Nem sikerült termékeket azonosítani", transcription = text });
|
||||
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
var enriched = await EnrichProductData(parsedProducts, customer, store);
|
||||
return Json(new { success = true, transcription = text, products = enriched });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
// ── VOICE (Quick Order flow) ──────────────────────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> TranscribeAndSearch(
|
||||
Microsoft.AspNetCore.Http.IFormFile audioFile,
|
||||
string deliveryDate = null, string deliveryTime = null)
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Json(new { success = false, message = "Nincs bejelentkezve" });
|
||||
|
||||
if (audioFile == null || audioFile.Length == 0)
|
||||
return Json(new { success = false, message = "Nem érkezett hangfájl" });
|
||||
|
||||
try
|
||||
{
|
||||
var text = await TranscribeAudioFile(audioFile, "hu");
|
||||
if (string.IsNullOrEmpty(text))
|
||||
return Json(new { success = false, message = "Nem sikerült a hangfelismerés" });
|
||||
|
||||
var parsedProducts = await ParseProductsFromText(text);
|
||||
if (parsedProducts == null || !parsedProducts.Any())
|
||||
return Json(new { success = false, message = "Nem sikerült termékeket azonosítani", transcription = text });
|
||||
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
var enriched = await EnrichProductData(parsedProducts, customer, store);
|
||||
return Json(new { success = true, transcription = text, products = enriched });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
// ── ADD TO CART (Quick Order flow) ────────────────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> AddToCart(int productId, int quantity)
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Json(new { success = false, message = "Nincs bejelentkezve" });
|
||||
|
||||
if (productId <= 0 || quantity <= 0)
|
||||
return Json(new { success = false, message = "Érvénytelen termék vagy mennyiség" });
|
||||
|
||||
try
|
||||
{
|
||||
var product = await _productService.GetProductByIdAsync(productId);
|
||||
if (product == null || product.Deleted || !product.Published)
|
||||
return Json(new { success = false, message = "A termék nem elérhető" });
|
||||
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
var warnings = await _shoppingCartService.AddToCartAsync(
|
||||
customer, product, ShoppingCartType.ShoppingCart, store.Id, quantity: quantity);
|
||||
|
||||
if (warnings.Any())
|
||||
return Json(new { success = false, message = string.Join("; ", warnings) });
|
||||
|
||||
var cartItems = await GetCartItemsJson(customer, store);
|
||||
return Json(new { success = true, cartItems });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET CART (Quick Order flow) ───────────────────────────────────────────
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetCartItems()
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Json(new { success = false });
|
||||
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
return Json(new { success = true, cartItems = await GetCartItemsJson(customer, store) });
|
||||
}
|
||||
|
||||
// ── PLACE PREORDER (Preorder flow) ────────────────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> PlacePreorder([FromBody] PlacePreorderRequest request)
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Json(new { success = false, message = "Nincs bejelentkezve" });
|
||||
|
||||
if (request?.Items == null || !request.Items.Any())
|
||||
return Json(new { success = false, message = "Nincs kiválasztott termék" });
|
||||
|
||||
if (!DateTime.TryParse(request.DeliveryDateTime, out var deliveryDateTime))
|
||||
return Json(new { success = false, message = "Érvénytelen szállítási dátum/idő" });
|
||||
|
||||
try
|
||||
{
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
|
||||
var preorder = new Preorder
|
||||
{
|
||||
CustomerId = customer.Id,
|
||||
StoreId = store.Id,
|
||||
DateOfReceipt = deliveryDateTime,
|
||||
CustomerNote = request.CustomerNote?.Trim()
|
||||
};
|
||||
|
||||
var items = new List<PreorderItem>();
|
||||
foreach (var req in request.Items.Where(i => i.Quantity > 0))
|
||||
{
|
||||
var product = await _dbContext.Products.GetByIdAsync(req.ProductId);
|
||||
if (product == null || product.Deleted || !product.Published) continue;
|
||||
|
||||
decimal unitPrice = 0;
|
||||
if (_customPriceCalculationService != null)
|
||||
{
|
||||
var pr = await _customPriceCalculationService.GetFinalPriceAsync(
|
||||
product, customer, store, null, 0, true, req.Quantity, null, null);
|
||||
unitPrice = pr.finalPrice;
|
||||
}
|
||||
|
||||
items.Add(new PreorderItem
|
||||
{
|
||||
ProductId = req.ProductId,
|
||||
RequestedQuantity = req.Quantity,
|
||||
UnitPriceInclTax = unitPrice
|
||||
});
|
||||
}
|
||||
|
||||
if (!items.Any())
|
||||
return Json(new { success = false, message = "Nincs érvényes termék az előrendelésben" });
|
||||
|
||||
var saved = await _preorderDbContext.InsertPreorderAsync(preorder, items);
|
||||
|
||||
// Clean up the pending datetime attribute
|
||||
await _fruitBankAttributeService
|
||||
.DeleteGenericAttributeAsync<Nop.Core.Domain.Customers.Customer>(
|
||||
customer.Id, PendingDeliveryKey, store.Id);
|
||||
|
||||
// Immediately check if any items can be fulfilled from current available stock.
|
||||
// Awaited inline (not fire-and-forget) so we can return the order ID if one is created.
|
||||
// shippingDocumentId = 0 signals this was triggered at preorder placement, not by a document.
|
||||
var productIds = items.Select(i => i.ProductId).Distinct().ToList();
|
||||
await _preorderConversionService.ConvertPreordersForProductsAsync(productIds, 0);
|
||||
|
||||
// Re-read to pick up OrderId if conversion created a real order
|
||||
var refreshed = await _preorderDbContext.Preorders.GetByIdAsync(saved.Id);
|
||||
|
||||
Console.WriteLine($"[OrderFlow] PlacePreorder #{saved.Id} — orderId={refreshed?.OrderId}");
|
||||
|
||||
return Json(new
|
||||
{
|
||||
success = true,
|
||||
preorderId = saved.Id,
|
||||
orderId = refreshed?.OrderId
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
// ── PRIVATE HELPERS ───────────────────────────────────────────────────────
|
||||
|
||||
private async Task<string> TranscribeAudioFile(Microsoft.AspNetCore.Http.IFormFile audioFile, string language)
|
||||
{
|
||||
var fileName = $"order_{DateTime.Now:yyyyMMdd_HHmmss}.webm";
|
||||
var uploadsFolder = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "uploads", "voice");
|
||||
if (!Directory.Exists(uploadsFolder)) Directory.CreateDirectory(uploadsFolder);
|
||||
|
||||
var filePath = Path.Combine(uploadsFolder, fileName);
|
||||
using (var stream = new FileStream(filePath, FileMode.Create))
|
||||
await audioFile.CopyToAsync(stream);
|
||||
|
||||
string text;
|
||||
using (var audioStream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
|
||||
text = await _aiApiService.TranscribeAudioAsync(audioStream, fileName, language, null);
|
||||
|
||||
if (!string.IsNullOrEmpty(text) && (text.EndsWith(".") || text.EndsWith("!") || text.EndsWith("?")))
|
||||
text = text[..^1];
|
||||
|
||||
try { System.IO.File.Delete(filePath); } catch { }
|
||||
return text;
|
||||
}
|
||||
|
||||
private async Task<List<ParsedProduct>> ParseProductsFromText(string text)
|
||||
{
|
||||
var systemPrompt = @"You are a product parser for a Hungarian fruit and vegetable wholesale company.
|
||||
Parse the product names and quantities from the user's input.
|
||||
CRITICAL RULES:
|
||||
1. Normalize product names to singular, lowercase
|
||||
2. Handle Hungarian number words
|
||||
3. Fix common transcription/typing errors
|
||||
4. Return ONLY valid JSON array
|
||||
OUTPUT FORMAT: [{""product"": ""narancs"", ""quantity"": 100}]";
|
||||
|
||||
var aiResponse = await _cerebrasApiService.GetSimpleResponseAsync(systemPrompt, $"Parse this: {text}");
|
||||
var jsonMatch = Regex.Match(aiResponse, @"\[.*\]", RegexOptions.Singleline);
|
||||
if (!jsonMatch.Success) return new List<ParsedProduct>();
|
||||
try
|
||||
{
|
||||
return System.Text.Json.JsonSerializer.Deserialize<List<ParsedProduct>>(jsonMatch.Value)
|
||||
?? new List<ParsedProduct>();
|
||||
}
|
||||
catch { return new List<ParsedProduct>(); }
|
||||
}
|
||||
|
||||
private async Task<List<object>> EnrichProductData(
|
||||
List<ParsedProduct> parsedProducts,
|
||||
Nop.Core.Domain.Customers.Customer customer,
|
||||
Nop.Core.Domain.Stores.Store store)
|
||||
{
|
||||
var enriched = new List<object>();
|
||||
var allDtos = await _dbContext.ProductDtos.GetAll(true).ToListAsync();
|
||||
|
||||
foreach (var parsed in parsedProducts)
|
||||
{
|
||||
var dbProducts = await _productService.SearchProductsAsync(
|
||||
keywords: parsed.Product, pageIndex: 0, pageSize: 20);
|
||||
|
||||
foreach (var product in dbProducts)
|
||||
{
|
||||
var dto = allDtos.FirstOrDefault(x => x.Id == product.Id);
|
||||
if (dto == null) continue;
|
||||
var available = product.StockQuantity + dto.IncomingQuantity;
|
||||
if (available <= 0) continue;
|
||||
|
||||
var finalQty = Math.Min(parsed.Quantity, available);
|
||||
var isReduced = finalQty < parsed.Quantity;
|
||||
decimal? price = null;
|
||||
if (!dto.IsMeasurable && _customPriceCalculationService != null)
|
||||
{
|
||||
var pr = await _customPriceCalculationService.GetFinalPriceAsync(
|
||||
product, customer, store, null, 0, true, finalQty, null, null);
|
||||
price = pr.finalPrice;
|
||||
}
|
||||
|
||||
enriched.Add(new
|
||||
{
|
||||
id = product.Id,
|
||||
name = product.Name,
|
||||
quantity = finalQty,
|
||||
requestedQuantity = parsed.Quantity,
|
||||
unitPrice = price,
|
||||
stockQuantity = available,
|
||||
searchTerm = parsed.Product,
|
||||
isQuantityReduced = isReduced,
|
||||
isMeasurable = dto.IsMeasurable
|
||||
});
|
||||
}
|
||||
}
|
||||
return enriched;
|
||||
}
|
||||
|
||||
private async Task<List<object>> GetCartItemsJson(
|
||||
Nop.Core.Domain.Customers.Customer customer,
|
||||
Nop.Core.Domain.Stores.Store store)
|
||||
{
|
||||
var cart = await _shoppingCartService.GetShoppingCartAsync(customer, ShoppingCartType.ShoppingCart, store.Id);
|
||||
var allDtos = await _dbContext.ProductDtos.GetAll(true).ToListAsync();
|
||||
var result = new List<object>();
|
||||
|
||||
foreach (var item in cart)
|
||||
{
|
||||
var product = await _productService.GetProductByIdAsync(item.ProductId);
|
||||
if (product == null) continue;
|
||||
var dto = allDtos.FirstOrDefault(x => x.Id == product.Id);
|
||||
var isMeasurable = dto?.IsMeasurable ?? false;
|
||||
decimal? price = null;
|
||||
if (!isMeasurable && _customPriceCalculationService != null)
|
||||
{
|
||||
var pr = await _customPriceCalculationService.GetFinalPriceAsync(
|
||||
product, customer, store, null, 0, true, item.Quantity, null, null);
|
||||
price = pr.finalPrice;
|
||||
}
|
||||
result.Add(new { id = item.Id, productId = item.ProductId, name = product.Name,
|
||||
quantity = item.Quantity, unitPrice = price, isMeasurable });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Inner models ──────────────────────────────────────────────────────────
|
||||
|
||||
public class PlacePreorderRequest
|
||||
{
|
||||
public string? DeliveryDateTime { get; set; }
|
||||
public string? CustomerNote { get; set; }
|
||||
public List<PreorderItemRequest> Items { get; set; } = new();
|
||||
}
|
||||
|
||||
public class PreorderItemRequest
|
||||
{
|
||||
public int ProductId { get; set; }
|
||||
public int Quantity { get; set; }
|
||||
}
|
||||
|
||||
private class ParsedProduct
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("product")]
|
||||
public string Product { get; set; }
|
||||
[System.Text.Json.Serialization.JsonPropertyName("quantity")]
|
||||
public int Quantity { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,290 @@
|
|||
using FruitBank.Common.Entities;
|
||||
using FruitBank.Common.Server;
|
||||
using LinqToDB;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nop.Core;
|
||||
using Nop.Core.Domain.Catalog;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||
using Nop.Services.Catalog;
|
||||
using Nop.Services.Customers;
|
||||
using Nop.Services.Localization;
|
||||
using Nop.Web.Framework.Controllers;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers;
|
||||
|
||||
[AutoValidateAntiforgeryToken]
|
||||
public class PreorderController : BasePluginController
|
||||
{
|
||||
private readonly IWorkContext _workContext;
|
||||
private readonly IStoreContext _storeContext;
|
||||
private readonly ICustomerService _customerService;
|
||||
private readonly ILocalizationService _localizationService;
|
||||
private readonly FruitBankDbContext _dbContext;
|
||||
private readonly PreorderDbContext _preorderDbContext;
|
||||
private readonly FruitBankAttributeService _fruitBankAttributeService;
|
||||
private readonly CustomPriceCalculationService _customPriceCalculationService;
|
||||
|
||||
private const string PendingDeliveryDateTimeKey = "PreorderPendingDeliveryDateTime";
|
||||
private const string Prefix = "Plugins.Misc.FruitBankPlugin.Preorder.";
|
||||
|
||||
public PreorderController(
|
||||
IWorkContext workContext,
|
||||
IStoreContext storeContext,
|
||||
ICustomerService customerService,
|
||||
ILocalizationService localizationService,
|
||||
FruitBankDbContext dbContext,
|
||||
PreorderDbContext preorderDbContext,
|
||||
FruitBankAttributeService fruitBankAttributeService,
|
||||
IPriceCalculationService priceCalculationService)
|
||||
{
|
||||
_workContext = workContext;
|
||||
_storeContext = storeContext;
|
||||
_customerService = customerService;
|
||||
_localizationService = localizationService;
|
||||
_dbContext = dbContext;
|
||||
_preorderDbContext = preorderDbContext;
|
||||
_fruitBankAttributeService = fruitBankAttributeService;
|
||||
_customPriceCalculationService = priceCalculationService as CustomPriceCalculationService;
|
||||
}
|
||||
|
||||
private Task<string> L(string keySuffix)
|
||||
=> _localizationService.GetResourceAsync(Prefix + keySuffix);
|
||||
|
||||
// ── INDEX ─────────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Challenge();
|
||||
|
||||
return View("~/Plugins/Misc.FruitBankPlugin/Views/Preorder/Index.cshtml");
|
||||
}
|
||||
|
||||
// ── GET SAVED DELIVERY DATETIME (page restore) ────────────────────────────
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetDeliveryDateTime()
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Json(new { success = false });
|
||||
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
var saved = await _fruitBankAttributeService
|
||||
.GetGenericAttributeValueAsync<Nop.Core.Domain.Customers.Customer, DateTime?>(
|
||||
customer.Id, PendingDeliveryDateTimeKey, store.Id);
|
||||
|
||||
if (!saved.HasValue)
|
||||
return Json(new { success = true, hasValue = false });
|
||||
|
||||
return Json(new
|
||||
{
|
||||
success = true,
|
||||
hasValue = true,
|
||||
date = saved.Value.ToString("yyyy-MM-dd"),
|
||||
time = saved.Value.ToString("HH:mm"),
|
||||
iso = saved.Value.ToString("yyyy-MM-ddTHH:mm")
|
||||
});
|
||||
}
|
||||
|
||||
// ── SET DELIVERY DATETIME ─────────────────────────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> SetDeliveryDateTime(string deliveryDateTime)
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Json(new { success = false, message = await L("NotLoggedIn") });
|
||||
|
||||
if (string.IsNullOrWhiteSpace(deliveryDateTime))
|
||||
return Json(new { success = false, message = await L("NoDeliveryDateTimeProvided") });
|
||||
|
||||
if (!DateTime.TryParse(deliveryDateTime, out var parsed))
|
||||
return Json(new { success = false, message = await L("InvalidDeliveryDateTime") });
|
||||
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync<Nop.Core.Domain.Customers.Customer, DateTime>(
|
||||
customer.Id, PendingDeliveryDateTimeKey, parsed, store.Id);
|
||||
|
||||
return Json(new { success = true });
|
||||
}
|
||||
|
||||
// ── GET AVAILABLE PRODUCTS (filtered by preorder window) ──────────────────
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAvailableProducts()
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Json(new { success = false, message = await L("NotLoggedIn") });
|
||||
|
||||
try
|
||||
{
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
var today = DateTime.UtcNow.Date;
|
||||
|
||||
// Load preorder window generic attributes — two queries, no N+1
|
||||
var gaStart = await _dbContext.GenericAttributes.Table
|
||||
.Where(ga => ga.KeyGroup == nameof(Product)
|
||||
&& ga.Key == FruitBankConst.PreorderWindowStart
|
||||
&& ga.StoreId == store.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var gaEnd = await _dbContext.GenericAttributes.Table
|
||||
.Where(ga => ga.KeyGroup == nameof(Product)
|
||||
&& ga.Key == FruitBankConst.PreorderWindowEnd
|
||||
&& ga.StoreId == store.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var startById = gaStart.ToDictionary(g => g.EntityId, g => g.Value);
|
||||
var endById = gaEnd.ToDictionary(g => g.EntityId, g => g.Value);
|
||||
|
||||
// Product IDs that are available today for preorder
|
||||
var availableProductIds = startById.Keys
|
||||
.Intersect(endById.Keys)
|
||||
.Where(id =>
|
||||
{
|
||||
DateTime.TryParse(startById[id], out var ws);
|
||||
DateTime.TryParse(endById[id], out var we);
|
||||
return ws.Date <= today && today <= we.Date;
|
||||
})
|
||||
.ToHashSet();
|
||||
|
||||
if (!availableProductIds.Any())
|
||||
return Json(new { success = true, products = Array.Empty<object>() });
|
||||
|
||||
// Load product DTOs for those IDs
|
||||
var productDtos = await _dbContext.ProductDtos
|
||||
.GetAll(true)
|
||||
.Where(p => availableProductIds.Contains(p.Id))
|
||||
.ToListAsync();
|
||||
|
||||
var result = new List<object>();
|
||||
|
||||
foreach (var dto in productDtos.OrderBy(p => p.Name))
|
||||
{
|
||||
decimal? unitPrice = null;
|
||||
if (!dto.IsMeasurable && _customPriceCalculationService != null)
|
||||
{
|
||||
var product = await _dbContext.Products.GetByIdAsync(dto.Id);
|
||||
if (product != null)
|
||||
{
|
||||
var priceResult = await _customPriceCalculationService.GetFinalPriceAsync(
|
||||
product, customer, store, null, 0, true, 1, null, null);
|
||||
unitPrice = priceResult.finalPrice;
|
||||
}
|
||||
}
|
||||
|
||||
result.Add(new
|
||||
{
|
||||
id = dto.Id,
|
||||
name = dto.Name,
|
||||
isMeasurable = dto.IsMeasurable,
|
||||
unitPrice,
|
||||
stockQuantity = dto.AvailableQuantity
|
||||
});
|
||||
}
|
||||
|
||||
return Json(new { success = true, products = result });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Preorder] GetAvailableProducts error: {ex.Message}");
|
||||
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
// ── PLACE PREORDER ────────────────────────────────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> PlacePreorder([FromBody] PlacePreorderRequest request)
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Json(new { success = false, message = await L("NotLoggedIn") });
|
||||
|
||||
if (request?.Items == null || !request.Items.Any())
|
||||
return Json(new { success = false, message = await L("NoItemsSelected") });
|
||||
|
||||
if (!DateTime.TryParse(request.DeliveryDateTime, out var deliveryDateTime))
|
||||
return Json(new { success = false, message = await L("InvalidDeliveryDateTime") });
|
||||
|
||||
try
|
||||
{
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
|
||||
var preorder = new Preorder
|
||||
{
|
||||
CustomerId = customer.Id,
|
||||
StoreId = store.Id,
|
||||
DateOfReceipt = deliveryDateTime,
|
||||
CustomerNote = request.CustomerNote?.Trim()
|
||||
};
|
||||
|
||||
var items = new List<PreorderItem>();
|
||||
foreach (var req in request.Items.Where(i => i.Quantity > 0))
|
||||
{
|
||||
var product = await _dbContext.Products.GetByIdAsync(req.ProductId);
|
||||
if (product == null || product.Deleted || !product.Published)
|
||||
continue;
|
||||
|
||||
decimal unitPrice = 0;
|
||||
if (_customPriceCalculationService != null)
|
||||
{
|
||||
var pr = await _customPriceCalculationService.GetFinalPriceAsync(
|
||||
product, customer, store, null, 0, true, req.Quantity, null, null);
|
||||
unitPrice = pr.finalPrice;
|
||||
}
|
||||
|
||||
items.Add(new PreorderItem
|
||||
{
|
||||
ProductId = req.ProductId,
|
||||
RequestedQuantity = req.Quantity,
|
||||
UnitPriceInclTax = unitPrice
|
||||
});
|
||||
}
|
||||
|
||||
if (!items.Any())
|
||||
return Json(new { success = false, message = await L("NoValidItems") });
|
||||
|
||||
var saved = await _preorderDbContext.InsertPreorderAsync(preorder, items);
|
||||
|
||||
// Clean up the pending delivery datetime attribute
|
||||
await _fruitBankAttributeService
|
||||
.DeleteGenericAttributeAsync<Nop.Core.Domain.Customers.Customer>(
|
||||
customer.Id, PendingDeliveryDateTimeKey, store.Id);
|
||||
|
||||
Console.WriteLine($"[Preorder] Placed #{saved.Id} — customer #{customer.Id}, {items.Count} items, delivery {deliveryDateTime:u}");
|
||||
|
||||
return Json(new
|
||||
{
|
||||
success = true,
|
||||
preorderId = saved.Id,
|
||||
message = await L("PlacedSuccessfully")
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Preorder] PlacePreorder error: {ex.Message}");
|
||||
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
// ── INNER MODELS ──────────────────────────────────────────────────────────
|
||||
|
||||
public class PlacePreorderRequest
|
||||
{
|
||||
public string? DeliveryDateTime { get; set; }
|
||||
public string? CustomerNote { get; set; }
|
||||
public List<PreorderItemRequest> Items { get; set; } = new();
|
||||
}
|
||||
|
||||
public class PreorderItemRequest
|
||||
{
|
||||
public int ProductId { get; set; }
|
||||
public int Quantity { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,9 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
|
|||
private readonly OpenAIApiService _aiApiService;
|
||||
private readonly CerebrasAPIService _cerebrasApiService;
|
||||
private readonly FruitBankDbContext _dbContext;
|
||||
private readonly FruitBankAttributeService _fruitBankAttributeService;
|
||||
|
||||
private const string PendingDeliveryDateTimeKey = "QuickOrderPendingDeliveryDateTime";
|
||||
|
||||
// Resource key prefix
|
||||
private const string Prefix = "Plugins.Misc.FruitBankPlugin.QuickOrder.";
|
||||
|
|
@ -45,7 +48,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
|
|||
IPriceCalculationService priceCalculationService,
|
||||
OpenAIApiService aiApiService,
|
||||
CerebrasAPIService cerebrasApiService,
|
||||
FruitBankDbContext dbContext)
|
||||
FruitBankDbContext dbContext,
|
||||
FruitBankAttributeService fruitBankAttributeService)
|
||||
{
|
||||
_workContext = workContext;
|
||||
_storeContext = storeContext;
|
||||
|
|
@ -57,6 +61,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
|
|||
_aiApiService = aiApiService;
|
||||
_cerebrasApiService = cerebrasApiService;
|
||||
_dbContext = dbContext;
|
||||
_fruitBankAttributeService = fruitBankAttributeService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
|
|
@ -70,10 +75,64 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return all available products with prices (for initial page load)
|
||||
/// Return the previously saved delivery datetime for this customer, if any.
|
||||
/// Used on page load to restore state when the customer revisits or opens a new tab.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAllProducts()
|
||||
public async Task<IActionResult> GetDeliveryDateTime()
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Json(new { success = false });
|
||||
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
var saved = await _fruitBankAttributeService
|
||||
.GetGenericAttributeValueAsync<Nop.Core.Domain.Customers.Customer, DateTime?>(
|
||||
customer.Id, PendingDeliveryDateTimeKey, store.Id);
|
||||
|
||||
if (!saved.HasValue)
|
||||
return Json(new { success = true, hasValue = false });
|
||||
|
||||
return Json(new
|
||||
{
|
||||
success = true,
|
||||
hasValue = true,
|
||||
date = saved.Value.ToString("yyyy-MM-dd"),
|
||||
time = saved.Value.ToString("HH:mm"),
|
||||
iso = saved.Value.ToString("yyyy-MM-ddTHH:mm")
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return all available products with prices, optionally filtered by delivery date/slot.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Save the customer's chosen delivery date+time as a generic attribute.
|
||||
/// The OrderPlacedEvent handler will transfer it to the order as DateOfReceipt.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> SetDeliveryDateTime(string deliveryDateTime)
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Json(new { success = false, message = await L("NotLoggedIn") });
|
||||
|
||||
if (string.IsNullOrWhiteSpace(deliveryDateTime))
|
||||
return Json(new { success = false, message = await L("NoDeliveryDateTimeProvided") });
|
||||
|
||||
if (!DateTime.TryParse(deliveryDateTime, out var parsedDateTime))
|
||||
return Json(new { success = false, message = await L("InvalidDeliveryDateTime") });
|
||||
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync<Nop.Core.Domain.Customers.Customer, DateTime>(
|
||||
customer.Id, PendingDeliveryDateTimeKey, parsedDateTime, store.Id);
|
||||
|
||||
Console.WriteLine($"[QuickOrder] SetDeliveryDateTime – customerId={customer.Id}, dateTime={parsedDateTime:u}");
|
||||
return Json(new { success = true });
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAllProducts(string deliveryDate = null, string deliveryTime = null)
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
|
|
@ -81,13 +140,14 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
|
|||
|
||||
try
|
||||
{
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
var allProductDtos = (await _dbContext.ProductDtos.GetAll(true).ToListAsync()).Where(pd => pd.AvailableQuantity > 0);
|
||||
Console.WriteLine($"[QuickOrder] GetAllProducts – deliveryDate={deliveryDate}, time={deliveryTime}");
|
||||
|
||||
//var dbProducts = await _productService.SearchProductsAsync(
|
||||
// pageIndex: 0,
|
||||
// pageSize: 500,
|
||||
// orderBy: );
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
var allProductDtos = (await _dbContext.ProductDtos.GetAll(true).ToListAsync())
|
||||
.Where(pd => pd.AvailableQuantity > 0);
|
||||
|
||||
// TODO: filter allProductDtos by deliveryDate + deliverySlot once
|
||||
// availability data model is defined (e.g. scheduled stock, delivery windows).
|
||||
|
||||
var result = new List<object>();
|
||||
|
||||
|
|
@ -132,10 +192,11 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a manually typed product list and return matching products with prices
|
||||
/// Parse a manually typed product list and return matching products with prices,
|
||||
/// optionally filtered by delivery date/slot.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> SearchProducts(string text)
|
||||
public async Task<IActionResult> SearchProducts(string text, string deliveryDate = null, string deliveryTime = null)
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
|
|
@ -144,6 +205,10 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
|
|||
if (string.IsNullOrWhiteSpace(text))
|
||||
return Json(new { success = false, message = await L("NoTextProvided") });
|
||||
|
||||
Console.WriteLine($"[QuickOrder] SearchProducts – deliveryDate={deliveryDate}, time={deliveryTime}");
|
||||
|
||||
// TODO: pass deliveryDate + deliverySlot to EnrichProductData when availability filtering is implemented.
|
||||
|
||||
try
|
||||
{
|
||||
var parsedProducts = await ParseProductsFromText(text);
|
||||
|
|
@ -163,10 +228,11 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transcribe voice audio (Hungarian) then parse and match products
|
||||
/// Transcribe voice audio (Hungarian) then parse and match products,
|
||||
/// optionally filtered by delivery date/slot.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> TranscribeAndSearch(IFormFile audioFile)
|
||||
public async Task<IActionResult> TranscribeAndSearch(IFormFile audioFile, string deliveryDate = null, string deliveryTime = null)
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
|
|
@ -175,6 +241,10 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
|
|||
if (audioFile == null || audioFile.Length == 0)
|
||||
return Json(new { success = false, message = await L("NoAudioReceived") });
|
||||
|
||||
Console.WriteLine($"[QuickOrder] TranscribeAndSearch – deliveryDate={deliveryDate}, time={deliveryTime}");
|
||||
|
||||
// TODO: pass deliveryDate + deliverySlot to EnrichProductData when availability filtering is implemented.
|
||||
|
||||
try
|
||||
{
|
||||
var transcribedText = await TranscribeAudioFile(audioFile, "hu");
|
||||
|
|
@ -200,7 +270,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a product to the current customer's shopping cart and return the updated cart
|
||||
/// Add a product to the current customer's shopping cart and return the updated cart.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> AddToCart(int productId, int quantity)
|
||||
|
|
@ -241,7 +311,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return the current customer's cart as JSON (for cart panel refresh)
|
||||
/// Return the current customer's cart as JSON (for cart panel refresh).
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetCartItems()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
using FruitBank.Common.Entities;
|
||||
using Mango.Nop.Data.Interfaces;
|
||||
using Nop.Data;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer.Interfaces;
|
||||
|
||||
public interface IPreorderDbSet<TDbTable> : IMgDbTableBase where TDbTable : IRepository<Preorder>
|
||||
{
|
||||
public TDbTable Preorders { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
using FruitBank.Common.Entities;
|
||||
using Mango.Nop.Data.Interfaces;
|
||||
using Nop.Data;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer.Interfaces;
|
||||
|
||||
public interface IPreorderItemDbSet<TDbTable> : IMgDbTableBase where TDbTable : IRepository<PreorderItem>
|
||||
{
|
||||
public TDbTable PreorderItems { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
using AyCode.Core.Loggers;
|
||||
using FruitBank.Common.Entities;
|
||||
using FruitBank.Common.Enums;
|
||||
using LinqToDB;
|
||||
using Mango.Nop.Core.Loggers;
|
||||
using Mango.Nop.Data.Repositories;
|
||||
using Nop.Core.Caching;
|
||||
using Nop.Core.Domain.Catalog;
|
||||
using Nop.Core.Domain.Customers;
|
||||
using Nop.Core.Domain.Orders;
|
||||
using Nop.Core.Events;
|
||||
using Nop.Data;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer.Interfaces;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||
|
||||
public class PreorderDbContext :
|
||||
IPreorderDbSet<PreorderDbTable>,
|
||||
IPreorderItemDbSet<PreorderItemDbTable>
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public PreorderDbTable Preorders { get; set; }
|
||||
public PreorderItemDbTable PreorderItems { get; set; }
|
||||
|
||||
// Read-only access to related NopCommerce repositories needed during conversion
|
||||
public IRepository<Customer> Customers { get; set; }
|
||||
public IRepository<Product> Products { get; set; }
|
||||
public IRepository<Order> Orders { get; set; }
|
||||
public IRepository<OrderItem> OrderItems { get; set; }
|
||||
|
||||
public PreorderDbContext(
|
||||
PreorderDbTable preorderDbTable,
|
||||
PreorderItemDbTable preorderItemDbTable,
|
||||
IRepository<Customer> customerRepository,
|
||||
IRepository<Product> productRepository,
|
||||
IRepository<Order> orderRepository,
|
||||
IRepository<OrderItem> orderItemRepository,
|
||||
IEnumerable<IAcLogWriterBase> logWriters)
|
||||
{
|
||||
Preorders = preorderDbTable;
|
||||
PreorderItems = preorderItemDbTable;
|
||||
Customers = customerRepository;
|
||||
Products = productRepository;
|
||||
Orders = orderRepository;
|
||||
OrderItems = orderItemRepository;
|
||||
_logger = new Logger<PreorderDbContext>(logWriters.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Insert a complete preorder with all its items in one operation.
|
||||
/// Returns the saved preorder (with Id populated).
|
||||
/// </summary>
|
||||
public async Task<Preorder> InsertPreorderAsync(Preorder preorder, IList<PreorderItem> items)
|
||||
{
|
||||
preorder.CreatedOnUtc = DateTime.UtcNow;
|
||||
preorder.UpdatedOnUtc = DateTime.UtcNow;
|
||||
preorder.Status = PreorderStatus.Pending;
|
||||
|
||||
await Preorders.InsertAsync(preorder);
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
item.PreorderId = preorder.Id;
|
||||
item.FulfilledQuantity = 0;
|
||||
item.Status = PreorderItemStatus.Pending;
|
||||
await PreorderItems.InsertAsync(item);
|
||||
}
|
||||
|
||||
_logger.Info($"PreorderDbContext: inserted Preorder #{preorder.Id} with {items.Count} items for customer #{preorder.CustomerId}");
|
||||
return preorder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all pending preorder items for a set of productIds, ordered by PreorderId (FCFS).
|
||||
/// Used by PreorderConversionService after IncomingQuantity is written.
|
||||
/// </summary>
|
||||
public async Task<List<PreorderItem>> GetPendingItemsForProductsAsync(IList<int> productIds)
|
||||
{
|
||||
// Fetch all items for these products first, then filter by status in memory
|
||||
// LinqToDB cannot translate enum comparisons to SQL in this codebase
|
||||
var all = await PreorderItems.Table
|
||||
.Where(i => productIds.Contains(i.ProductId))
|
||||
.OrderBy(i => i.PreorderId)
|
||||
.ToListAsync();
|
||||
|
||||
return all.Where(i =>
|
||||
i.Status == PreorderItemStatus.Pending ||
|
||||
i.Status == PreorderItemStatus.PartiallyFulfilled)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// After conversion: check if all items in a preorder are resolved and update the preorder's status.
|
||||
/// </summary>
|
||||
public async Task RefreshPreorderStatusAsync(int preorderId)
|
||||
{
|
||||
var preorder = await Preorders.GetByIdAsync(preorderId);
|
||||
if (preorder == null) return;
|
||||
|
||||
var items = await PreorderItems.GetAllByPreorderIdAsync(preorderId).ToListAsync();
|
||||
|
||||
var hasDropped = items.Any(i => i.Status == PreorderItemStatus.Dropped);
|
||||
var hasPartial = items.Any(i => i.Status == PreorderItemStatus.PartiallyFulfilled);
|
||||
var hasPending = items.Any(i => i.Status == PreorderItemStatus.Pending);
|
||||
var allFulfilled = items.All(i => i.Status == PreorderItemStatus.Fulfilled);
|
||||
|
||||
preorder.Status = (hasDropped || hasPartial) && !hasPending ? PreorderStatus.PartiallyFulfilled
|
||||
: allFulfilled ? PreorderStatus.Confirmed
|
||||
: PreorderStatus.Pending;
|
||||
|
||||
preorder.UpdatedOnUtc = DateTime.UtcNow;
|
||||
await Preorders.UpdateAsync(preorder);
|
||||
|
||||
_logger.Info($"PreorderDbContext: Preorder #{preorderId} status → {preorder.Status}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mark a preorder as cancelled (customer or admin action).
|
||||
/// </summary>
|
||||
public async Task CancelPreorderAsync(int preorderId)
|
||||
{
|
||||
var preorder = await Preorders.GetByIdAsync(preorderId);
|
||||
if (preorder == null) return;
|
||||
|
||||
preorder.Status = PreorderStatus.Cancelled;
|
||||
preorder.UpdatedOnUtc = DateTime.UtcNow;
|
||||
await Preorders.UpdateAsync(preorder);
|
||||
|
||||
var items = await PreorderItems.GetAllByPreorderIdAsync(preorderId).ToListAsync();
|
||||
var cancellableStatuses = new[] { PreorderItemStatus.Pending, PreorderItemStatus.PartiallyFulfilled };
|
||||
foreach (var item in items.Where(i => cancellableStatuses.Contains(i.Status)))
|
||||
{
|
||||
item.Status = PreorderItemStatus.Dropped;
|
||||
await PreorderItems.UpdateAsync(item);
|
||||
}
|
||||
|
||||
_logger.Info($"PreorderDbContext: Preorder #{preorderId} cancelled");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
using FruitBank.Common.Entities;
|
||||
using FruitBank.Common.Enums;
|
||||
using LinqToDB;
|
||||
using Mango.Nop.Core.Loggers;
|
||||
using Mango.Nop.Data.Repositories;
|
||||
using Nop.Core.Caching;
|
||||
using Nop.Core.Configuration;
|
||||
using Nop.Core.Events;
|
||||
using Nop.Data;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||
|
||||
public class PreorderDbTable : MgDbTableBase<Preorder>
|
||||
{
|
||||
public PreorderDbTable(
|
||||
IEventPublisher eventPublisher,
|
||||
INopDataProvider dataProvider,
|
||||
IShortTermCacheManager shortTermCacheManager,
|
||||
IStaticCacheManager staticCacheManager,
|
||||
AppSettings appSettings)
|
||||
: base(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings)
|
||||
{
|
||||
}
|
||||
|
||||
public IQueryable<Preorder> GetAll(bool loadRelations)
|
||||
{
|
||||
return loadRelations
|
||||
? GetAll()
|
||||
.LoadWith(p => p.PreorderItems)
|
||||
: GetAll();
|
||||
}
|
||||
|
||||
public Task<Preorder?> GetByIdAsync(int id, bool loadRelations)
|
||||
=> GetAll(loadRelations).FirstOrDefaultAsync(p => p.Id == id);
|
||||
|
||||
public IQueryable<Preorder> GetAllByCustomerIdAsync(int customerId, bool loadRelations)
|
||||
=> GetAll(loadRelations).Where(p => p.CustomerId == customerId);
|
||||
|
||||
public IQueryable<Preorder> GetAllPendingAsync(bool loadRelations)
|
||||
{
|
||||
var pendingStatuses = new[] { PreorderStatus.Pending, PreorderStatus.PartiallyFulfilled };
|
||||
return GetAll(loadRelations).Where(p => pendingStatuses.Contains(p.Status));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
using FruitBank.Common.Entities;
|
||||
using FruitBank.Common.Enums;
|
||||
using LinqToDB;
|
||||
using Mango.Nop.Core.Loggers;
|
||||
using Mango.Nop.Data.Repositories;
|
||||
using Nop.Core.Caching;
|
||||
using Nop.Core.Configuration;
|
||||
using Nop.Core.Events;
|
||||
using Nop.Data;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||
|
||||
public class PreorderItemDbTable : MgDbTableBase<PreorderItem>
|
||||
{
|
||||
public PreorderItemDbTable(
|
||||
IEventPublisher eventPublisher,
|
||||
INopDataProvider dataProvider,
|
||||
IShortTermCacheManager shortTermCacheManager,
|
||||
IStaticCacheManager staticCacheManager,
|
||||
AppSettings appSettings)
|
||||
: base(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings)
|
||||
{
|
||||
}
|
||||
|
||||
public IQueryable<PreorderItem> GetAllByPreorderIdAsync(int preorderId)
|
||||
=> GetAll().Where(i => i.PreorderId == preorderId);
|
||||
|
||||
public IQueryable<PreorderItem> GetAllByProductIdAsync(int productId)
|
||||
=> GetAll().Where(i => i.ProductId == productId);
|
||||
|
||||
/// <summary>
|
||||
/// All pending/partially-fulfilled items for a product, ordered by their parent preorder's
|
||||
/// CreatedOnUtc for first-come-first-served allocation.
|
||||
/// </summary>
|
||||
public IQueryable<PreorderItem> GetPendingByProductIdOrderedAsync(int productId)
|
||||
{
|
||||
var pendingStatuses = new[] { PreorderItemStatus.Pending, PreorderItemStatus.PartiallyFulfilled };
|
||||
return GetAll()
|
||||
.Where(i => i.ProductId == productId && pendingStatuses.Contains(i.Status))
|
||||
.OrderBy(i => i.PreorderId);
|
||||
}
|
||||
}
|
||||
|
|
@ -37,13 +37,15 @@ public class FruitBankEventConsumer :
|
|||
private readonly FruitBankDbContext _ctx;
|
||||
private readonly MeasurementService _measurementService;
|
||||
private readonly FruitBankAttributeService _fruitBankAttributeService;
|
||||
private readonly PreorderConversionService _preorderConversionService;
|
||||
|
||||
public FruitBankEventConsumer(IHttpContextAccessor httpContextAcc, FruitBankDbContext ctx, MeasurementService measurementService,
|
||||
FruitBankAttributeService fruitBankAttributeService, IEnumerable<IAcLogWriterBase> logWriters) : base(ctx, httpContextAcc, logWriters)
|
||||
FruitBankAttributeService fruitBankAttributeService, PreorderConversionService preorderConversionService, IEnumerable<IAcLogWriterBase> logWriters) : base(ctx, httpContextAcc, logWriters)
|
||||
{
|
||||
_ctx = ctx;
|
||||
_measurementService = measurementService;
|
||||
_fruitBankAttributeService = fruitBankAttributeService;
|
||||
_preorderConversionService = preorderConversionService;
|
||||
}
|
||||
|
||||
public override async Task HandleEventAsync(EntityUpdatedEvent<Product> eventMessage)
|
||||
|
|
@ -192,6 +194,24 @@ public class FruitBankEventConsumer :
|
|||
Logger.Info($"HandleEventAsync->EntityInsertedEvent<ShippingItemPallet>; id: {eventMessage.Entity.Id}");
|
||||
|
||||
await UpdateShippingDocumentIsAllMeasuredAsync(eventMessage.Entity);
|
||||
|
||||
// Trigger preorder conversion if the item has a matched product and a quantity
|
||||
var item = eventMessage.Entity;
|
||||
if (item.ProductId != null && item.QuantityOnDocument > 0)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _preorderConversionService.ConvertPreordersForProductsAsync(
|
||||
new List<int> { item.ProductId.Value }, item.ShippingDocumentId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"[FruitBankEventConsumer] Preorder conversion failed for ProductId={item.ProductId}: {ex.Message}", ex);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#region Update
|
||||
|
|
@ -201,15 +221,25 @@ public class FruitBankEventConsumer :
|
|||
Logger.Info($"HandleEventAsync->EntityUpdatedEvent<ShippingItem>; id: {eventMessage.Entity.Id}");
|
||||
|
||||
var shippingItem = eventMessage.Entity;
|
||||
//var isMeasured = shippingItem.IsValidMeasuringValues();
|
||||
|
||||
//if (shippingItem.IsMeasured != isMeasured)
|
||||
//{
|
||||
// shippingItem.IsMeasured = isMeasured;
|
||||
// await ctx.ShippingItems.UpdateAsync(shippingItem, false);
|
||||
//}
|
||||
|
||||
await UpdateShippingDocumentIsAllMeasuredAsync(shippingItem);
|
||||
|
||||
// Trigger preorder conversion when quantity or product assignment changes
|
||||
if (shippingItem.ProductId != null && shippingItem.QuantityOnDocument > 0)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _preorderConversionService.ConvertPreordersForProductsAsync(
|
||||
new List<int> { shippingItem.ProductId.Value }, shippingItem.ShippingDocumentId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"[FruitBankEventConsumer] Preorder conversion failed for ProductId={shippingItem.ProductId}: {ex.Message}", ex);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateShippingDocumentIsAllMeasuredAsync(ShippingItem shippingItem)
|
||||
|
|
|
|||
|
|
@ -88,6 +88,30 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
|
|||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MenuLabel", "Quick Order", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MenuLabel", "Gyors rendel\u00e9s", hu);
|
||||
|
||||
// Delivery step
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Title", "When do you want to receive your order?", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Title", "Mikor k\u00e9red a rendel\u00e9st?", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Subtitle", "Choose a delivery day and time slot", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Subtitle", "V\u00e1lassz sz\u00e1ll\u00edt\u00e1si napot \u00e9s id\u0151ablakot", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.DayLabel", "Delivery day", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.DayLabel", "Sz\u00e1ll\u00edt\u00e1si nap", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.TimeLabel", "Delivery time", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.TimeLabel", "Sz\u00e1ll\u00edt\u00e1si id\u0151pont", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.TimeHint", "Choose an exact time", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.TimeHint", "V\u00e1lassz pontos id\u0151pontot", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Saving", "Saving...", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Saving", "Ment\u00e9s...", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ConfirmButton", "Show products", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ConfirmButton", "Term\u00e9kek mutat\u00e1sa", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ChangeLabel", "Delivery:", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ChangeLabel", "Sz\u00e1ll\u00edt\u00e1s:", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ChangeButton", "Change", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ChangeButton", "M\u00f3dos\u00edt\u00e1s", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Today", "Today", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Today", "Ma", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Tomorrow", "Tomorrow", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Tomorrow", "Holnap", hu);
|
||||
|
||||
// Search bar
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicButtonTitle", "Start voice recording", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicButtonTitle", "Hangfelv\u00e9tel ind\u00edt\u00e1sa", hu);
|
||||
|
|
@ -182,6 +206,12 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
|
|||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ErrorPrefix", "Error: ", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ErrorPrefix", "Hiba: ", hu);
|
||||
|
||||
// Delivery datetime errors
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoDeliveryDateTimeProvided", "No delivery date/time provided", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoDeliveryDateTimeProvided", "Nincs sz\u00e1ll\u00edt\u00e1si id\u0151pont megadva", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.InvalidDeliveryDateTime", "Invalid delivery date/time format", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.InvalidDeliveryDateTime", "\u00c9rv\u00e9nytelen sz\u00e1ll\u00edt\u00e1si d\u00e1tum/id\u0151 form\u00e1tum", hu);
|
||||
|
||||
// Controller JSON error messages
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NotLoggedIn", "Not logged in", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NotLoggedIn", "Nincs bejelentkezve", hu);
|
||||
|
|
@ -198,6 +228,93 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
|
|||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.InvalidProductOrQuantity", "Invalid product or quantity", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.InvalidProductOrQuantity", "\u00c9rv\u00e9nytelen term\u00e9k vagy mennyis\u00e9g", hu);
|
||||
|
||||
// ── Preorder page ───────────────────────────────────────────────────
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.PageTitle", "Preorder", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.PageTitle", "El\u0151rendel\u00e9s", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.MenuLabel", "Preorder", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.MenuLabel", "El\u0151rendel\u00e9s", hu);
|
||||
// Delivery step
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Title", "When do you want to receive your preorder?", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Title", "Mikor k\u00e9red a rendel\u00e9st?", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Subtitle", "Choose a delivery day and time (we\u2019ll confirm availability)", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Subtitle", "V\u00e1lassz sz\u00e1ll\u00edt\u00e1si napot \u00e9s id\u0151pontot (az el\u00e9rhet\u0151s\u00e9get meger\u0151s\u00edtj\u00fck)", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.DayLabel", "Delivery day", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.DayLabel", "K\u00edv\u00e1nt sz\u00e1ll\u00edt\u00e1si nap", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.TimeLabel", "Delivery time", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.TimeLabel", "K\u00edv\u00e1nt id\u0151pont", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.TimeHint", "Choose an exact time", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.TimeHint", "V\u00e1lassz pontos id\u0151pontot", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ConfirmButton", "Show available products", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ConfirmButton", "El\u00e9rhet\u0151 term\u00e9kek mutat\u00e1sa", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ChangeLabel", "Delivery:", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ChangeLabel", "Sz\u00e1ll\u00edt\u00e1s:", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ChangeButton", "Change", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ChangeButton", "M\u00f3dos\u00edt\u00e1s", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Today", "Today", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Today", "Ma", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Tomorrow", "Tomorrow", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Tomorrow", "Holnap", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Saving", "Saving...", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Saving", "Ment\u00e9s...", hu);
|
||||
// Products
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.InfoBanner", "Preorders are wishes \u2014 we will confirm availability when the shipment arrives and notify you of any changes.", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.InfoBanner", "Az el\u0151rendel\u00e9s egy k\u00edv\u00e1ns\u00e1glista \u2014 az áruk meger\u0151s\u00edt\u00e9se a sz\u00e1ll\u00edtm\u00e1ny be\u00e9rkez\u00e9sekor t\u00f6rt\u00e9nik, \u00e9s az esetleges v\u00e1ltoz\u00e1sokr\u00f3l \u00e9rtes\u00edt\u00fcnk.", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.LoadingProducts", "Loading available products...", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.LoadingProducts", "El\u00e9rhet\u0151 term\u00e9kek bet\u00f6lt\u00e9se...", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.NoProductsAvailable", "No products are currently available for preorder. Please check back later.", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.NoProductsAvailable", "Jelenleg nincs el\u0151rendelhet\u0151 term\u00e9k. K\u00e9rj\u00fck, l\u00e1togass vissza k\u00e9s\u0151bb.", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.ProductsLabel", "Available for preorder \u2014 set quantities:", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.ProductsLabel", "El\u0151rendelhet\u0151 term\u00e9kek \u2014 add meg a mennyis\u00e9geket:", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.MeasurableBadge", "Requires weighing", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.MeasurableBadge", "S\u00falym\u00e9r\u00e9st ig\u00e9nyel", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.PricePerPiece", "Ft/pcs", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.PricePerPiece", "Ft/db", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.PieceUnit", "pcs", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.PieceUnit", "db", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.StockLabel", "Incoming stock:", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.StockLabel", "V\u00e1rhat\u00f3 k\u00e9szlet:", hu);
|
||||
// Note + submit
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.NoteLabel", "Additional note (optional)", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.NoteLabel", "Megjegyz\u00e9s (nem k\u00f6telez\u0151)", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.NotePlaceholder", "Any special requests or notes for this preorder...", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.NotePlaceholder", "Esetleges megjegyz\u00e9sek az el\u0151rendel\u00e9ssel kapcsolatban...", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.SelectionNone", "No products selected yet", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.SelectionNone", "M\u00e9g nincs kiv\u00e1lasztott term\u00e9k", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.SelectionItems", "product(s) selected", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.SelectionItems", "term\u00e9k kiv\u00e1lasztva", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.SubmitButton", "Place preorder", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.SubmitButton", "El\u0151rendel\u00e9s lead\u00e1sa", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.Submitting", "Placing preorder...", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.Submitting", "El\u0151rendel\u00e9s ment\u00e9se...", hu);
|
||||
// Summary panel
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.SummaryTitle", "Your preorder", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.SummaryTitle", "El\u0151rendel\u00e9sed", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.SummaryEmpty", "Set quantities above to build your preorder.", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.SummaryEmpty", "Add meg a mennyis\u00e9geket a term\u00e9kekn\u00e9l.", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.SummaryNote", "Prices for weighed items will be finalised after measurement. Preorder quantities may change depending on actual shipment.", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.SummaryNote", "A s\u00falym\u00e9r\u00e9st ig\u00e9nyl\u0151 t\u00e9teleikn\u00e9l az \u00e1r a m\u00e9r\u00e9s ut\u00e1n v\u00e9glegesedik. A mennyis\u00e9gek a t\u00e9nyleges sz\u00e1ll\u00edtm\u00e1nyt\u00f3l f\u00fcgg\u0151en v\u00e1ltozhatnak.", hu);
|
||||
// Success + errors
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.SuccessTitle", "Preorder placed!", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.SuccessTitle", "El\u0151rendel\u00e9s leadva!", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.SuccessMessage", "Your preorder #{0} has been received. We will notify you when the shipment arrives.", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.SuccessMessage", "#{0} sz\u00e1m\u00fa el\u0151rendel\u00e9sed be\u00e9rkezett. A sz\u00e1ll\u00edtm\u00e1ny meger\u0151s\u00edt\u00e9sekor \u00e9rtes\u00edt\u00fcnk.", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.BackToHome", "Back to home", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.BackToHome", "Vissza a f\u0151oldalra", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.ErrorPrefix", "Error: ", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.ErrorPrefix", "Hiba: ", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.NotLoggedIn", "Not logged in", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.NotLoggedIn", "Nincs bejelentkezve", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.NoItemsSelected", "No items selected", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.NoItemsSelected", "Nincs kiv\u00e1lasztott term\u00e9k", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.NoValidItems", "No valid items in preorder", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.NoValidItems", "Nincs \u00e9rv\u00e9nyes term\u00e9k az el\u0151rendel\u00e9sben", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.NoDeliveryDateTimeProvided", "No delivery date/time provided", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.NoDeliveryDateTimeProvided", "Nincs sz\u00e1ll\u00edt\u00e1si id\u0151pont megadva", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.InvalidDeliveryDateTime", "Invalid delivery date/time format", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.InvalidDeliveryDateTime", "\u00c9rv\u00e9nytelen sz\u00e1ll\u00edt\u00e1si d\u00e1tum/id\u0151 form\u00e1tum", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.PlacedSuccessfully", "Preorder placed successfully", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.PlacedSuccessfully", "El\u0151rendel\u00e9s sikeresen leadva", hu);
|
||||
|
||||
// ── Customer Credit ────────────────────────────────────────────────────
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle", "Customer Credit Management", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle", "\u00dcgyf\u00e9l hitelkeret kezel\u00e9s", hu);
|
||||
|
|
@ -304,7 +421,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
|
|||
PublicWidgetZones.ProductDetailsBottom,
|
||||
AdminWidgetZones.ProductDetailsBlock,
|
||||
AdminWidgetZones.OrderDetailsBlock,
|
||||
AdminWidgetZones.CustomerDetailsBlock
|
||||
AdminWidgetZones.CustomerDetailsBlock,
|
||||
PublicWidgetZones.AccountNavigationAfter
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -344,6 +462,10 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
|
|||
{
|
||||
return typeof(CustomerCreditWidgetViewComponent);
|
||||
}
|
||||
else if (widgetZone == PublicWidgetZones.AccountNavigationAfter)
|
||||
{
|
||||
return typeof(CustomerPreorderNavViewComponent);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -54,6 +54,19 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
|
|||
/// Gets or sets the timeout for API requests in seconds
|
||||
/// </summary>
|
||||
public int RequestTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
// ── Z.ai GLM-OCR ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Z.ai API kulcs a GLM-OCR dokumentumfeldolgozóhoz.
|
||||
/// Igénylés: https://bigmodel.cn
|
||||
/// </summary>
|
||||
public string ZaiApiKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Z.ai GLM-OCR modell neve (default: "glm-ocr").
|
||||
/// </summary>
|
||||
public string ZaiModel { get; set; } = "glm-ocr";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -82,6 +82,9 @@ public class PluginNopStartup : INopStartup
|
|||
services.AddScoped<StockTakingItemDbTable>();
|
||||
services.AddScoped<StockTakingItemPalletDbTable>();
|
||||
services.AddScoped<CustomerCreditDbTable>();
|
||||
services.AddScoped<PreorderDbTable>();
|
||||
services.AddScoped<PreorderItemDbTable>();
|
||||
services.AddScoped<PreorderDbContext>();
|
||||
|
||||
services.AddScoped<StockTakingDbContext>();
|
||||
services.AddScoped<FruitBankDbContext>();
|
||||
|
|
@ -127,8 +130,15 @@ public class PluginNopStartup : INopStartup
|
|||
services.AddScoped<OpenAIApiService>();
|
||||
//services.AddScoped<IAIAPIService, OpenAIApiService>();
|
||||
services.AddScoped<AICalculationService>();
|
||||
|
||||
// Z.ai GLM-OCR — nagy PDF-eknél 3 perces timeout szükséges (1.86 oldal/mp sebesség)
|
||||
services.AddHttpClient<ZaiService>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromMinutes(3);
|
||||
});
|
||||
services.AddScoped<PdfToImageService>();
|
||||
services.AddScoped<FruitBankNotificationService>();
|
||||
services.AddScoped<PreorderConversionService>();
|
||||
services.AddSingleton<IFileStorageProvider>(sp =>
|
||||
new LocalFileStorageProvider() // Uses default wwwroot/uploads
|
||||
// Or specify custom path:
|
||||
|
|
|
|||
|
|
@ -197,6 +197,147 @@ public class RouteProvider : IRouteProvider
|
|||
pattern: "Admin/CustomerCredit/UpdateCreditLimit",
|
||||
defaults: new { controller = "CustomerCredit", action = "UpdateCreditLimit", area = AreaNames.ADMIN });
|
||||
|
||||
// ── Admin: Preorder list ───────────────────────────────────────────
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Preorders.List",
|
||||
pattern: "Admin/Preorders",
|
||||
defaults: new { controller = "PreorderAdmin", action = "List", area = AreaNames.ADMIN });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Preorders.PreorderList",
|
||||
pattern: "Admin/Preorders/PreorderList",
|
||||
defaults: new { controller = "PreorderAdmin", action = "PreorderList", area = AreaNames.ADMIN });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Preorders.Detail",
|
||||
pattern: "Admin/Preorders/Detail/{id:int}",
|
||||
defaults: new { controller = "PreorderAdmin", action = "Detail", area = AreaNames.ADMIN });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Preorders.Cancel",
|
||||
pattern: "Admin/Preorders/Cancel/{id:int}",
|
||||
defaults: new { controller = "PreorderAdmin", action = "Cancel", area = AreaNames.ADMIN });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Preorders.CreatePreorder",
|
||||
pattern: "Admin/Preorders/CreatePreorder",
|
||||
defaults: new { controller = "PreorderAdmin", action = "CreatePreorder", area = AreaNames.ADMIN });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Preorders.DemandList",
|
||||
pattern: "Admin/Preorders/DemandList",
|
||||
defaults: new { controller = "PreorderAdmin", action = "DemandList", area = AreaNames.ADMIN });
|
||||
|
||||
// ── Admin: Preorder availability ─────────────────────────────────
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.PreorderAvailability.Index",
|
||||
pattern: "Admin/PreorderAvailability",
|
||||
defaults: new { controller = "PreorderAvailability", action = "Index", area = AreaNames.ADMIN });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.PreorderAvailability.ProductList",
|
||||
pattern: "Admin/PreorderAvailability/ProductList",
|
||||
defaults: new { controller = "PreorderAvailability", action = "ProductList", area = AreaNames.ADMIN });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.PreorderAvailability.AvailableTodayList",
|
||||
pattern: "Admin/PreorderAvailability/AvailableTodayList",
|
||||
defaults: new { controller = "PreorderAvailability", action = "AvailableTodayList", area = AreaNames.ADMIN });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.PreorderAvailability.SaveWindow",
|
||||
pattern: "Admin/PreorderAvailability/SaveWindow",
|
||||
defaults: new { controller = "PreorderAvailability", action = "SaveWindow", area = AreaNames.ADMIN });
|
||||
|
||||
// ── Public: Unified Order flow ─────────────────────────────────────────
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Order.Index",
|
||||
pattern: "rendeles",
|
||||
defaults: new { controller = "Order", action = "Index" });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Order.GetDeliveryDateTime",
|
||||
pattern: "rendeles/szallitas-idopont",
|
||||
defaults: new { controller = "Order", action = "GetDeliveryDateTime" });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Order.SetDeliveryDateTime",
|
||||
pattern: "rendeles/szallitas-idopont-beallitas",
|
||||
defaults: new { controller = "Order", action = "SetDeliveryDateTime" });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Order.GetAllProducts",
|
||||
pattern: "rendeles/osszes-termek",
|
||||
defaults: new { controller = "Order", action = "GetAllProducts" });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Order.GetPreorderProducts",
|
||||
pattern: "rendeles/elozetes-termekek",
|
||||
defaults: new { controller = "Order", action = "GetPreorderProducts" });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Order.SearchProducts",
|
||||
pattern: "rendeles/kereses",
|
||||
defaults: new { controller = "Order", action = "SearchProducts" });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Order.TranscribeAndSearch",
|
||||
pattern: "rendeles/hang",
|
||||
defaults: new { controller = "Order", action = "TranscribeAndSearch" });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Order.AddToCart",
|
||||
pattern: "rendeles/kosarba",
|
||||
defaults: new { controller = "Order", action = "AddToCart" });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Order.GetCartItems",
|
||||
pattern: "rendeles/kosar",
|
||||
defaults: new { controller = "Order", action = "GetCartItems" });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Order.PlacePreorder",
|
||||
pattern: "rendeles/elozetes-leadás",
|
||||
defaults: new { controller = "Order", action = "PlacePreorder" });
|
||||
|
||||
// ── Public: Help page ───────────────────────────────────────────────────
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Help.Index",
|
||||
pattern: "segitseg",
|
||||
defaults: new { controller = "Help", action = "Index" });
|
||||
|
||||
// ── Public: Customer preorder list ───────────────────────────────────────────
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.CustomerPreorder.List",
|
||||
pattern: "fiokom/elorerendeles-aim",
|
||||
defaults: new { controller = "CustomerPreorder", action = "List" });
|
||||
|
||||
// ── Public: Preorder (legacy, kept for backward compat) ───────────────
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Preorder.Index",
|
||||
pattern: "elozetes-rendeles",
|
||||
defaults: new { controller = "Preorder", action = "Index" });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Preorder.GetDeliveryDateTime",
|
||||
pattern: "elozetes-rendeles/szallitas-idopont",
|
||||
defaults: new { controller = "Preorder", action = "GetDeliveryDateTime" });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Preorder.SetDeliveryDateTime",
|
||||
pattern: "elozetes-rendeles/szallitas-idopont-beallitas",
|
||||
defaults: new { controller = "Preorder", action = "SetDeliveryDateTime" });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Preorder.GetAvailableProducts",
|
||||
pattern: "elozetes-rendeles/termekek",
|
||||
defaults: new { controller = "Preorder", action = "GetAvailableProducts" });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Preorder.PlacePreorder",
|
||||
pattern: "elozetes-rendeles/leadás",
|
||||
defaults: new { controller = "Preorder", action = "PlacePreorder" });
|
||||
|
||||
// ── Public: Quick Order ──────────────────────────────────────────────
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.QuickOrder.Index",
|
||||
|
|
@ -227,6 +368,16 @@ public class RouteProvider : IRouteProvider
|
|||
name: "Plugin.FruitBank.QuickOrder.GetCartItems",
|
||||
pattern: "gyors-rendeles/kosar",
|
||||
defaults: new { controller = "QuickOrder", action = "GetCartItems" });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.QuickOrder.SetDeliveryDateTime",
|
||||
pattern: "gyors-rendeles/szallitas-idopont",
|
||||
defaults: new { controller = "QuickOrder", action = "SetDeliveryDateTime" });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.QuickOrder.GetDeliveryDateTime",
|
||||
pattern: "gyors-rendeles/szallitas-idopont-lekerdezes",
|
||||
defaults: new { controller = "QuickOrder", action = "GetDeliveryDateTime" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Language Name="English" IsDefault="false" IsRightToLeft="false">
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
Plugin Configuration Page — Plugins.FruitBankPlugin.Fields.*
|
||||
Import: Admin > Configuration > Languages > [English] > Import resources
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
|
||||
<!-- General -->
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.IsEnabled">
|
||||
<Value><![CDATA[Plugin enabled]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.MaxTokens">
|
||||
<Value><![CDATA[Max tokens]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.Temperature">
|
||||
<Value><![CDATA[Temperature]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.RequestTimeoutSeconds">
|
||||
<Value><![CDATA[Request timeout (seconds)]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Cerebras -->
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.CerebrasApiKey">
|
||||
<Value><![CDATA[Cerebras API key]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.CerebrasModelName">
|
||||
<Value><![CDATA[Cerebras model]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.CerebrasApiBaseUrl">
|
||||
<Value><![CDATA[Cerebras API base URL]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- OpenAI -->
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.OpenAIApiKey">
|
||||
<Value><![CDATA[OpenAI API key]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.OpenAIModelName">
|
||||
<Value><![CDATA[OpenAI model]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.OpenAIApiBaseUrl">
|
||||
<Value><![CDATA[OpenAI API base URL]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Z.ai GLM-OCR -->
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.ZaiApiKey">
|
||||
<Value><![CDATA[Z.ai API key]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.ZaiModel">
|
||||
<Value><![CDATA[Z.ai model]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
</Language>
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Language Name="Hungarian" IsDefault="false" IsRightToLeft="false">
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
Plugin konfigurációs oldal — Plugins.FruitBankPlugin.Fields.*
|
||||
Import: Admin > Konfiguráció > Nyelvek > [Magyar] > Erőforrások importálása
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
|
||||
<!-- Általános -->
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.IsEnabled">
|
||||
<Value><![CDATA[Plugin engedélyezve]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.MaxTokens">
|
||||
<Value><![CDATA[Maximum token szám]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.Temperature">
|
||||
<Value><![CDATA[Kreativitás (temperature)]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.RequestTimeoutSeconds">
|
||||
<Value><![CDATA[Időtúllépés (másodperc)]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Cerebras -->
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.CerebrasApiKey">
|
||||
<Value><![CDATA[Cerebras API kulcs]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.CerebrasModelName">
|
||||
<Value><![CDATA[Cerebras modell]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.CerebrasApiBaseUrl">
|
||||
<Value><![CDATA[Cerebras API alapcím]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- OpenAI -->
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.OpenAIApiKey">
|
||||
<Value><![CDATA[OpenAI API kulcs]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.OpenAIModelName">
|
||||
<Value><![CDATA[OpenAI modell]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.OpenAIApiBaseUrl">
|
||||
<Value><![CDATA[OpenAI API alapcím]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Z.ai GLM-OCR -->
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.ZaiApiKey">
|
||||
<Value><![CDATA[Z.ai API kulcs]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.ZaiModel">
|
||||
<Value><![CDATA[Z.ai modell]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
</Language>
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Language Name="English" IsDefault="false" IsRightToLeft="false">
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
Preorder page — Plugins.Misc.FruitBankPlugin.Preorder.*
|
||||
Import: Admin > Configuration > Languages > [English] > Import resources
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
|
||||
<!-- General -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.PageTitle">
|
||||
<Value><![CDATA[Preorder]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.MenuLabel">
|
||||
<Value><![CDATA[Preorder]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Delivery step -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Title">
|
||||
<Value><![CDATA[When do you want to receive your preorder?]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Subtitle">
|
||||
<Value><![CDATA[Choose a delivery day and time (we'll confirm availability)]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.DayLabel">
|
||||
<Value><![CDATA[Delivery day]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.TimeLabel">
|
||||
<Value><![CDATA[Delivery time]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.TimeHint">
|
||||
<Value><![CDATA[Choose an exact time]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ConfirmButton">
|
||||
<Value><![CDATA[Show available products]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ChangeLabel">
|
||||
<Value><![CDATA[Delivery:]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ChangeButton">
|
||||
<Value><![CDATA[Change]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Today">
|
||||
<Value><![CDATA[Today]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Tomorrow">
|
||||
<Value><![CDATA[Tomorrow]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Saving">
|
||||
<Value><![CDATA[Saving...]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Product list -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.InfoBanner">
|
||||
<Value><![CDATA[Preorders are wishes — we will confirm availability when the shipment arrives and notify you of any changes.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.LoadingProducts">
|
||||
<Value><![CDATA[Loading available products...]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NoProductsAvailable">
|
||||
<Value><![CDATA[No products are currently available for preorder. Please check back later.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.ProductsLabel">
|
||||
<Value><![CDATA[Available for preorder — set quantities:]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.MeasurableBadge">
|
||||
<Value><![CDATA[Requires weighing]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.PricePerPiece">
|
||||
<Value><![CDATA[Ft/pcs]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.PieceUnit">
|
||||
<Value><![CDATA[pcs]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.StockLabel">
|
||||
<Value><![CDATA[Incoming stock:]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Note and submit -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NoteLabel">
|
||||
<Value><![CDATA[Additional note (optional)]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NotePlaceholder">
|
||||
<Value><![CDATA[Any special requests or notes for this preorder...]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SelectionNone">
|
||||
<Value><![CDATA[No products selected yet]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SelectionItems">
|
||||
<Value><![CDATA[product(s) selected]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SubmitButton">
|
||||
<Value><![CDATA[Place preorder]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.Submitting">
|
||||
<Value><![CDATA[Placing preorder...]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Summary panel -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SummaryTitle">
|
||||
<Value><![CDATA[Your preorder]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SummaryEmpty">
|
||||
<Value><![CDATA[Set quantities above to build your preorder.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SummaryNote">
|
||||
<Value><![CDATA[Prices for weighed items will be finalised after measurement. Preorder quantities may change depending on actual shipment.]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Success -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SuccessTitle">
|
||||
<Value><![CDATA[Preorder placed!]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SuccessMessage">
|
||||
<Value><![CDATA[Your preorder #{0} has been received. We will notify you when the shipment arrives.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.BackToHome">
|
||||
<Value><![CDATA[Back to home]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Error messages -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.ErrorPrefix">
|
||||
<Value><![CDATA[Error: ]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NotLoggedIn">
|
||||
<Value><![CDATA[Not logged in]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NoItemsSelected">
|
||||
<Value><![CDATA[No items selected]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NoValidItems">
|
||||
<Value><![CDATA[No valid items in preorder]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NoDeliveryDateTimeProvided">
|
||||
<Value><![CDATA[No delivery date/time provided]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.InvalidDeliveryDateTime">
|
||||
<Value><![CDATA[Invalid delivery date/time format]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.PlacedSuccessfully">
|
||||
<Value><![CDATA[Preorder placed successfully]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
</Language>
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Language Name="Hungarian" IsDefault="false" IsRightToLeft="false">
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
Előrendelés oldal — Plugins.Misc.FruitBankPlugin.Preorder.*
|
||||
Import: Admin > Konfiguráció > Nyelvek > [Magyar] > Erőforrások importálása
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
|
||||
<!-- Általános -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.PageTitle">
|
||||
<Value><![CDATA[Előrendelés]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.MenuLabel">
|
||||
<Value><![CDATA[Előrendelés]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Szállítási időpont lépés -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Title">
|
||||
<Value><![CDATA[Mikor kéred a rendelést?]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Subtitle">
|
||||
<Value><![CDATA[Válassz szállítási napot és időpontot (az elérhetőséget megerősítjük)]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.DayLabel">
|
||||
<Value><![CDATA[Kívánt szállítási nap]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.TimeLabel">
|
||||
<Value><![CDATA[Kívánt időpont]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.TimeHint">
|
||||
<Value><![CDATA[Válassz pontos időpontot]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ConfirmButton">
|
||||
<Value><![CDATA[Elérhető termékek mutatása]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ChangeLabel">
|
||||
<Value><![CDATA[Szállítás:]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ChangeButton">
|
||||
<Value><![CDATA[Módosítás]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Today">
|
||||
<Value><![CDATA[Ma]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Tomorrow">
|
||||
<Value><![CDATA[Holnap]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Saving">
|
||||
<Value><![CDATA[Mentés...]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Terméklista -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.InfoBanner">
|
||||
<Value><![CDATA[Az előrendelés egy kívánságlista — az áruk megerősítése a szállítmány beérkezésekor történik, és az esetleges változásokról értesítünk.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.LoadingProducts">
|
||||
<Value><![CDATA[Elérhető termékek betöltése...]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NoProductsAvailable">
|
||||
<Value><![CDATA[Jelenleg nincs előrendelhető termék. Kérjük, látogass vissza később.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.ProductsLabel">
|
||||
<Value><![CDATA[Előrendelhető termékek — add meg a mennyiségeket:]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.MeasurableBadge">
|
||||
<Value><![CDATA[Súlymérést igényel]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.PricePerPiece">
|
||||
<Value><![CDATA[Ft/db]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.PieceUnit">
|
||||
<Value><![CDATA[db]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.StockLabel">
|
||||
<Value><![CDATA[Várható készlet:]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Megjegyzés és leadás -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NoteLabel">
|
||||
<Value><![CDATA[Megjegyzés (nem kötelező)]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NotePlaceholder">
|
||||
<Value><![CDATA[Esetleges megjegyzések az előrendeléssel kapcsolatban...]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SelectionNone">
|
||||
<Value><![CDATA[Még nincs kiválasztott termék]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SelectionItems">
|
||||
<Value><![CDATA[termék kiválasztva]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SubmitButton">
|
||||
<Value><![CDATA[Előrendelés leadása]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.Submitting">
|
||||
<Value><![CDATA[Előrendelés mentése...]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Összefoglaló panel -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SummaryTitle">
|
||||
<Value><![CDATA[Előrendelésed]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SummaryEmpty">
|
||||
<Value><![CDATA[Add meg a mennyiségeket a termékeknél.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SummaryNote">
|
||||
<Value><![CDATA[A súlymérést igénylő tételeknél az ár a mérés után véglegesedik. A mennyiségek a tényleges szállítmánytól függően változhatnak.]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Sikeres leadás -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SuccessTitle">
|
||||
<Value><![CDATA[Előrendelés leadva!]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SuccessMessage">
|
||||
<Value><![CDATA[#{0} számú előrendelésed beérkezett. A szállítmány megerősítésekor értesítünk.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.BackToHome">
|
||||
<Value><![CDATA[Vissza a főoldalra]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Hibaüzenetek -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.ErrorPrefix">
|
||||
<Value><![CDATA[Hiba: ]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NotLoggedIn">
|
||||
<Value><![CDATA[Nincs bejelentkezve]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NoItemsSelected">
|
||||
<Value><![CDATA[Nincs kiválasztott termék]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NoValidItems">
|
||||
<Value><![CDATA[Nincs érvényes termék az előrendelésben]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NoDeliveryDateTimeProvided">
|
||||
<Value><![CDATA[Nincs szállítási időpont megadva]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.InvalidDeliveryDateTime">
|
||||
<Value><![CDATA[Érvénytelen szállítási dátum/idő formátum]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.PlacedSuccessfully">
|
||||
<Value><![CDATA[Előrendelés sikeresen leadva]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
</Language>
|
||||
|
|
@ -14,6 +14,41 @@
|
|||
<Value><![CDATA[Quick Order]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Delivery step -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Title">
|
||||
<Value><![CDATA[When do you want to receive your order?]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Subtitle">
|
||||
<Value><![CDATA[Choose a delivery day and time slot]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.DayLabel">
|
||||
<Value><![CDATA[Delivery day]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.TimeLabel">
|
||||
<Value><![CDATA[Delivery time]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.TimeHint">
|
||||
<Value><![CDATA[Choose an exact time]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Saving">
|
||||
<Value><![CDATA[Saving...]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ConfirmButton">
|
||||
<Value><![CDATA[Show products]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ChangeLabel">
|
||||
<Value><![CDATA[Delivery:]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ChangeButton">
|
||||
<Value><![CDATA[Change]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Today">
|
||||
<Value><![CDATA[Today]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Tomorrow">
|
||||
<Value><![CDATA[Tomorrow]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Search bar -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.MicButtonTitle">
|
||||
<Value><![CDATA[Start voice recording]]></Value>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,41 @@
|
|||
<Value><![CDATA[Gyors rendelés]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Szállítási időpont lépés -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Title">
|
||||
<Value><![CDATA[Mikor kéred a rendelést?]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Subtitle">
|
||||
<Value><![CDATA[Válassz szállítási napot és időablakot]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.DayLabel">
|
||||
<Value><![CDATA[Szállítási nap]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.TimeLabel">
|
||||
<Value><![CDATA[Szállítási időpont]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.TimeHint">
|
||||
<Value><![CDATA[Válassz pontos időpontot]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Saving">
|
||||
<Value><![CDATA[Mentés...]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ConfirmButton">
|
||||
<Value><![CDATA[Termékek mutatása]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ChangeLabel">
|
||||
<Value><![CDATA[Szállítás:]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ChangeButton">
|
||||
<Value><![CDATA[Módosítás]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Today">
|
||||
<Value><![CDATA[Ma]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Tomorrow">
|
||||
<Value><![CDATA[Holnap]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Keresősáv -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.MicButtonTitle">
|
||||
<Value><![CDATA[Hangfelvétel indítása]]></Value>
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ public partial class NameCompatibility : INameCompatibility
|
|||
{ typeof(StockTakingItem), FruitBankConstClient.StockTakingItemDbTableName},
|
||||
{ typeof(StockTakingItemPallet), FruitBankConstClient.StockTakingItemPalletDbTableName},
|
||||
{ typeof(CustomerCredit), FruitBankConstClient.CustomerCreditDbTableName},
|
||||
{ typeof(Preorder), FruitBankConstClient.PreOrderDbTableName},
|
||||
{ typeof(PreorderItem), FruitBankConstClient.PreOrderItemDbTableName},
|
||||
};
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -221,6 +221,15 @@
|
|||
<None Update="Areas\Admin\Views\Order\List.cshtml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Areas\Admin\Views\PreorderAvailability\Index.cshtml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Areas\Admin\Views\Preorder\Detail.cshtml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Areas\Admin\Views\Preorder\List.cshtml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Areas\Admin\Views\Product\List.cshtml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
|
|
@ -455,6 +464,9 @@
|
|||
<None Update="css\devextreme\icons\dxiconsmaterial.woff2">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="css\preorder.css">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="js\devextreme\aspnet\dx.aspnet.data.js">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
|
|
@ -662,6 +674,21 @@
|
|||
<None Update="Views\CustomerCreditWidget.cshtml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Views\CustomerPreorder\List.cshtml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Views\CustomerPreorder\NavItem.cshtml">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Views\Help\Index.cshtml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Views\Order\Index.cshtml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Views\Preorder\Index.cshtml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Views\ProductAIListWidget.cshtml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
|
|
|
|||
|
|
@ -305,6 +305,7 @@ AI-driven workflow for extracting partner + product data from uploaded PDFs/imag
|
|||
|
||||
| Issue | Correct approach |
|
||||
|---|---|
|
||||
| LinqToDB table name for custom entities | **Never** rely on `[Table]` attribute. Register the mapping in the plugin's `Mapping/NameCompatibility.cs` file: `{ typeof(MyEntity), "fbMyTable" }`. This is the only place LinqToDB reads the table name from in this codebase. |
|
||||
| `ICustomerAttributeService` does not exist | Use direct `XDocument.Parse` on the XML stored in `GenericAttribute.Value` |
|
||||
| `ParseAttributeValuesAsync` returns empty for free-text attributes | It's designed for predefined selection attributes (ID lookup). For free-text: parse XML directly: `<Attributes><CustomerAttribute ID="1"><CustomerAttributeValue><Value>...</Value>...` |
|
||||
| `TransactionSafeAsync` + async = deadlock | `TaskHelper.ToThreadPoolTask` inside it causes context switching deadlocks in ASP.NET; remove transaction wrapper for affected code |
|
||||
|
|
|
|||
|
|
@ -177,10 +177,11 @@ public class CustomPriceCalculationService : PriceCalculationService
|
|||
|
||||
if (productDto.IsMeasurable)
|
||||
{
|
||||
//finalPrice.priceWithoutDiscounts = 0;
|
||||
//return (0, finalPrice.finalPrice, finalPrice.appliedDiscountAmount, []);
|
||||
return finalPrice;
|
||||
//return (overriddenProductPrice.GetValueOrDefault(0), overriddenProductPrice.GetValueOrDefault(0), 0m, []);
|
||||
// For measurable products the real price is weight × unit price, determined only after
|
||||
// physical weighing. Until then we expose 0 so the cart and checkout total are honest.
|
||||
// The actual PriceInclTax / PriceExclTax on OrderItem is set by
|
||||
// CheckAndUpdateOrderItemFinalPricesAsync after the order is weighed.
|
||||
return (0m, 0m, 0m, new System.Collections.Generic.List<Nop.Core.Domain.Discounts.Discount>());
|
||||
}
|
||||
//var productAttributeMappings = await _specificationAttributeService.GetProductSpecificationAttributesAsync(product.Id);
|
||||
////Product Attributes
|
||||
|
|
|
|||
|
|
@ -98,6 +98,31 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
|
|||
|
||||
public async Task HandleEventAsync(OrderPlacedEvent eventMessage)
|
||||
{
|
||||
var order = eventMessage?.Order;
|
||||
if (order == null) return;
|
||||
|
||||
// Transfer the customer's chosen delivery datetime to the order as DateOfReceipt
|
||||
var customer = await _customerService.GetCustomerByIdAsync(order.CustomerId);
|
||||
if (customer == null) return;
|
||||
|
||||
var storeId = order.StoreId;
|
||||
const string pendingKey = "QuickOrderPendingDeliveryDateTime";
|
||||
|
||||
var pendingDateTime = await _fruitBankAttributeService
|
||||
.GetGenericAttributeValueAsync<Nop.Core.Domain.Customers.Customer, DateTime?>(order.CustomerId, pendingKey, storeId);
|
||||
|
||||
if (pendingDateTime.HasValue)
|
||||
{
|
||||
await _fruitBankAttributeService
|
||||
.InsertOrUpdateGenericAttributeAsync<Nop.Core.Domain.Orders.Order, DateTime>(
|
||||
order.Id, nameof(IOrderDto.DateOfReceipt), pendingDateTime.Value, storeId);
|
||||
|
||||
// Clean up — the value has been transferred to the order
|
||||
await _fruitBankAttributeService
|
||||
.DeleteGenericAttributeAsync<Nop.Core.Domain.Customers.Customer>(order.CustomerId, pendingKey, storeId);
|
||||
|
||||
Console.WriteLine($"[EventConsumer] OrderPlaced #{order.Id} – DateOfReceipt set to {pendingDateTime.Value:u}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task HandleEventAsync(EntityUpdatedEvent<Order> eventMessage)
|
||||
|
|
@ -225,16 +250,35 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
|
|||
{
|
||||
Visible = true,
|
||||
SystemName = "FruitBank",
|
||||
Title = await _localizationService.GetResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.VoiceOrder"), // You can localize this with await _localizationService.GetResourceAsync("...")
|
||||
Title = await _localizationService.GetResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.VoiceOrder"),
|
||||
IconClass = "fas fa-microphone",
|
||||
Url = _adminMenu.GetMenuItemUrl("VoiceOrder", "Create")
|
||||
//ChildNodes = [shippingsListMenuItem, createShippingMenuItem, editShippingMenuItem]
|
||||
|
||||
};
|
||||
|
||||
|
||||
shippingConfigurationItem.ChildNodes.Insert(3, voiceOrderMenuItem);
|
||||
|
||||
var preorderAvailabilityMenuItem = new AdminMenuItem
|
||||
{
|
||||
Visible = true,
|
||||
SystemName = "PreorderAvailability",
|
||||
Title = "Előrendelés — elérhetőség",
|
||||
IconClass = "fas fa-calendar-check",
|
||||
Url = _adminMenu.GetMenuItemUrl("PreorderAvailability", "Index")
|
||||
};
|
||||
|
||||
shippingConfigurationItem.ChildNodes.Insert(4, preorderAvailabilityMenuItem);
|
||||
|
||||
var preorderListMenuItem = new AdminMenuItem
|
||||
{
|
||||
Visible = true,
|
||||
SystemName = "Preorders.List",
|
||||
Title = "Előrendelések",
|
||||
IconClass = "fas fa-calendar-plus",
|
||||
Url = _adminMenu.GetMenuItemUrl("PreorderAdmin", "List")
|
||||
};
|
||||
|
||||
shippingConfigurationItem.ChildNodes.Insert(5, preorderListMenuItem);
|
||||
|
||||
|
||||
// Create a new top-level menu item
|
||||
var InvoiceSyncMenuItem = new AdminMenuItem
|
||||
|
|
|
|||
|
|
@ -1,28 +1,140 @@
|
|||
using Nop.Core;
|
||||
using Nop.Core.Domain.Common;
|
||||
using Nop.Core.Domain.Messages;
|
||||
using Nop.Core.Domain.Orders;
|
||||
using Nop.Core.Events;
|
||||
using Nop.Services.Affiliates;
|
||||
using Nop.Services.Catalog;
|
||||
using Nop.Services.Common;
|
||||
using Nop.Services.Customers;
|
||||
using Nop.Services.Localization;
|
||||
using Nop.Services.Messages;
|
||||
using Nop.Services.Orders;
|
||||
using Nop.Services.Stores;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||
|
||||
public class FruitBankNotificationService(
|
||||
CommonSettings commonSettings,
|
||||
IMessageTemplateService messageTemplateService,
|
||||
IEmailAccountService emailAccountService,
|
||||
EmailAccountSettings emailAccountSettings,
|
||||
IMessageTokenProvider messageTokenProvider,
|
||||
IWorkflowMessageService workflowMessageService,
|
||||
ICustomerService customerService,
|
||||
IStoreContext storeContext)
|
||||
IStoreContext storeContext,
|
||||
IAddressService addressService,
|
||||
|
||||
IAffiliateService affiliateService,
|
||||
IEventPublisher eventPublisher,
|
||||
ILanguageService languageService,
|
||||
ILocalizationService localizationService,
|
||||
IOrderService orderService,
|
||||
IProductService productService,
|
||||
IQueuedEmailService queuedEmailService,
|
||||
IStoreService storeService,
|
||||
ITokenizer tokenizer,
|
||||
MessagesSettings messagesSettings) : WorkflowMessageService(commonSettings,
|
||||
emailAccountSettings,
|
||||
addressService,
|
||||
affiliateService,
|
||||
customerService,
|
||||
emailAccountService,
|
||||
eventPublisher,
|
||||
languageService,
|
||||
localizationService,
|
||||
messageTemplateService,
|
||||
messageTokenProvider,
|
||||
orderService,
|
||||
productService,
|
||||
queuedEmailService,
|
||||
storeContext,
|
||||
storeService,
|
||||
tokenizer,
|
||||
messagesSettings)
|
||||
{
|
||||
public const string ORDER_AUDITED_TEMPLATE_NAME = "FruitBank.OrderAudited.CustomerNotification";
|
||||
public const string ORDER_STARTED_TEMPLATE_NAME = "FruitBank.OrderStarted.CustomerNotification";
|
||||
|
||||
|
||||
|
||||
|
||||
public override async Task<IList<int>> SendOrderPlacedCustomerNotificationAsync(Order order, int languageId,
|
||||
string attachmentFilePath = null, string attachmentFileName = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(order);
|
||||
|
||||
var store = await _storeService.GetStoreByIdAsync(order.StoreId) ?? await _storeContext.GetCurrentStoreAsync();
|
||||
languageId = await EnsureLanguageIsActiveAsync(languageId, store.Id);
|
||||
|
||||
var messageTemplates = await GetActiveMessageTemplatesAsync(MessageTemplateSystemNames.ORDER_PLACED_CUSTOMER_NOTIFICATION, store.Id);
|
||||
if (!messageTemplates.Any())
|
||||
return new List<int>();
|
||||
|
||||
//tokens
|
||||
var commonTokens = new List<Token>();
|
||||
await _messageTokenProvider.AddOrderTokensAsync(commonTokens, order, languageId);
|
||||
await _messageTokenProvider.AddCustomerTokensAsync(commonTokens, order.CustomerId);
|
||||
|
||||
var customer = await _customerService.GetCustomerByIdAsync(order.CustomerId);
|
||||
|
||||
return await messageTemplates.SelectAwait(async messageTemplate =>
|
||||
{
|
||||
//email account
|
||||
var emailAccount = await GetEmailAccountOfMessageTemplateAsync(messageTemplate, languageId);
|
||||
|
||||
var tokens = new List<Token>(commonTokens);
|
||||
await _messageTokenProvider.AddStoreTokensAsync(tokens, store, emailAccount, languageId);
|
||||
|
||||
//event notification
|
||||
await _eventPublisher.MessageTokensAddedAsync(messageTemplate, tokens);
|
||||
|
||||
var billingAddress = await _addressService.GetAddressByIdAsync(order.BillingAddressId);
|
||||
string toEmail;
|
||||
|
||||
//we surely have shipping address for orders with shipping method, but let's be safe
|
||||
if (billingAddress.Email != null)
|
||||
{
|
||||
|
||||
|
||||
if (!billingAddress.Email.EndsWith("inval.id"))
|
||||
{
|
||||
toEmail = billingAddress.Email;
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Customer {customer.Id} has BillinggAddressId but emailaddress is invalid: {billingAddress.Email}");
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Customer {customer.Id} has BillinggAddressId but emailaddress not found.");
|
||||
return -1;
|
||||
}
|
||||
|
||||
var toName = $"{billingAddress.FirstName} {billingAddress.LastName}";
|
||||
|
||||
return await SendNotificationAsync(messageTemplate, emailAccount, languageId, tokens, toEmail, toName,
|
||||
attachmentFilePath, attachmentFileName);
|
||||
}).ToListAsync();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Re-sends the order info email to the customer on demand from the admin.
|
||||
/// Reuses the OrderPlaced template which already contains the full order table.
|
||||
/// </summary>
|
||||
public Task<IList<int>> SendOrderInfoEmailAsync(Order order)
|
||||
=> SendOrderPlacedCustomerNotificationAsync(order, order.CustomerLanguageId);
|
||||
|
||||
/// <summary>
|
||||
/// Sends the "order started" (being prepared) customer notification.
|
||||
/// For measurable orders, informs the customer that final prices will be
|
||||
/// confirmed after weighing. Fires once when MeasuringStatus transitions to Started.
|
||||
/// </summary>
|
||||
///
|
||||
public async Task<int> SendOrderStartedCustomerNotificationAsync(Order order, bool isMeasurable)
|
||||
{
|
||||
var measurableNote = isMeasurable
|
||||
|
|
@ -71,7 +183,20 @@ public class FruitBankNotificationService(
|
|||
|
||||
tokens.Add(new Token("Order.MeasurableNote", measurableNote, true));
|
||||
|
||||
var toEmail = customer.Email;
|
||||
int addressId = 0;
|
||||
|
||||
string customerEmail = customer.Email;
|
||||
//bool customerHasShippingAddress = customer.ShippingAddressId.HasValue;
|
||||
|
||||
if (customer.ShippingAddressId.HasValue)
|
||||
{
|
||||
addressId = (int)customer.ShippingAddressId;
|
||||
|
||||
customerEmail = (await addressService.GetAddressByIdAsync(addressId)).Email ?? customer.Email;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Customer email determined as: {customerEmail} (addressId: {addressId})");
|
||||
|
||||
var toName = $"{customer.FirstName} {customer.LastName}".Trim();
|
||||
if (string.IsNullOrWhiteSpace(toName)) toName = customer.Email;
|
||||
|
||||
|
|
@ -79,6 +204,6 @@ public class FruitBankNotificationService(
|
|||
messageTemplate, emailAccount,
|
||||
order.CustomerLanguageId,
|
||||
tokens,
|
||||
toEmail, toName);
|
||||
customerEmail, toName);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -822,14 +822,16 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
|
|||
"'products' (array of objects with the following fields: " +
|
||||
"'name' (string), " +
|
||||
"'quantity' (int - the number of cartons, boxes or packages), " +
|
||||
"'netWeight' (double - the net kilograms), " +
|
||||
"'grossWeight' (double - the gross kilograms)," +
|
||||
"'netWeight' (double - the net kilograms in European format, example: 1.372 kgs should be 1372,00 kgs), " +
|
||||
"'grossWeight' (double - the gross kilogramsin European format, example: 1.372 kgs should be 1372,00 kgs)," +
|
||||
"'unitCost (double - the unit price of the product on the document)'.\r \n \n" +
|
||||
"";
|
||||
|
||||
string systemPrompt = "You are an AI assistant of FRUITBANK that extracts text and structured data from images. " +
|
||||
"Carefully analyze the image content to extract all relevant information accurately. " +
|
||||
"Provide the extracted data in a well-formatted JSON structure as specified.";
|
||||
"Keep in mind, that all the information are in EU standards, so if you find '.' in numbers that is thousand separator, not decimatal point" +
|
||||
"Provide the extracted data in a well-formatted JSON structure as specified. POINT IN NUMBERS IS THOUSAND SEPARATOR." +
|
||||
"IMPORTANT: if you find point in numbers, return them without the point, as '.' is thousand separator, not decimal point. Example: 1.731 kgs is 1731 kgs, NOT 1,731 kgs.";
|
||||
|
||||
var payload = new
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,507 @@
|
|||
using FruitBank.Common.Entities;
|
||||
using FruitBank.Common.Enums;
|
||||
//using LinqToDB;
|
||||
using Nop.Core.Domain.Orders;
|
||||
using Nop.Core.Domain.Payments;
|
||||
using Nop.Core.Domain.Shipping;
|
||||
using Nop.Core.Events;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||
using Nop.Services.Catalog;
|
||||
using Nop.Services.Customers;
|
||||
using Nop.Services.Orders;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Converts pending preorder items into real NopCommerce orders when
|
||||
/// incoming stock is confirmed via shipping document processing.
|
||||
///
|
||||
/// Called once per shipping document save, after all IncomingQuantity
|
||||
/// attributes have been written for that document's product set.
|
||||
///
|
||||
/// Allocation strategy: first-come-first-served by PreorderId (insertion order).
|
||||
///
|
||||
/// Multi-document design:
|
||||
/// - Preorder.OrderId tracks the linked real order once created.
|
||||
/// - First partial fulfillment → creates the order, saves OrderId on Preorder.
|
||||
/// - Subsequent documents → appends only newly-fulfilled items to that same order.
|
||||
/// - Dropped items are recorded in an order note but never become OrderItems.
|
||||
/// </summary>
|
||||
public class PreorderConversionService
|
||||
{
|
||||
private readonly PreorderDbContext _preorderDbContext;
|
||||
private readonly FruitBankDbContext _dbContext;
|
||||
private readonly ICustomerService _customerService;
|
||||
private readonly IProductService _productService;
|
||||
private readonly IEventPublisher _eventPublisher;
|
||||
private readonly CustomPriceCalculationService _customPriceCalculationService;
|
||||
private readonly IOrderService _orderService;
|
||||
|
||||
public PreorderConversionService(
|
||||
PreorderDbContext preorderDbContext,
|
||||
FruitBankDbContext dbContext,
|
||||
ICustomerService customerService,
|
||||
IProductService productService,
|
||||
IEventPublisher eventPublisher,
|
||||
IPriceCalculationService priceCalculationService,
|
||||
IOrderService orderService)
|
||||
{
|
||||
_preorderDbContext = preorderDbContext;
|
||||
_dbContext = dbContext;
|
||||
_customerService = customerService;
|
||||
_productService = productService;
|
||||
_eventPublisher = eventPublisher;
|
||||
_customPriceCalculationService = priceCalculationService as CustomPriceCalculationService;
|
||||
_orderService = orderService;
|
||||
}
|
||||
|
||||
// ── Entry point ───────────────────────────────────────────────────────────
|
||||
|
||||
public async Task ConvertPreordersForProductsAsync(IList<int> productIds, int shippingDocumentId)
|
||||
{
|
||||
Console.WriteLine($"[PreorderConversion] Starting for {productIds.Count} products, shippingDocumentId={shippingDocumentId}");
|
||||
|
||||
// Always sweep expired preorders first — any preorder whose DateOfReceipt
|
||||
// is in the past is closed regardless of stock, before we allocate anything
|
||||
await SweepExpiredPreordersAsync();
|
||||
|
||||
var pendingItems = await _preorderDbContext.GetPendingItemsForProductsAsync(productIds);
|
||||
if (!pendingItems.Any())
|
||||
{
|
||||
Console.WriteLine("[PreorderConversion] No pending preorder items — done.");
|
||||
return;
|
||||
}
|
||||
|
||||
var incomingPool = await BuildIncomingQuantityPoolAsync(productIds);
|
||||
|
||||
// Track which items were newly resolved in THIS run, grouped by preorder
|
||||
// Key: preorderId Value: list of items whose status changed in this run
|
||||
var newlyResolvedByPreorder = new Dictionary<int, List<PreorderItem>>();
|
||||
|
||||
foreach (var item in pendingItems)
|
||||
{
|
||||
var prevFulfilled = item.FulfilledQuantity;
|
||||
|
||||
if (!incomingPool.TryGetValue(item.ProductId, out var available) || available <= 0)
|
||||
{
|
||||
// No stock available in this document run — leave item Pending
|
||||
// so it can be picked up by a future document. The expiry sweep
|
||||
// above handles permanent closure once DateOfReceipt is past.
|
||||
continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
var fulfill = Math.Min(item.RequestedQuantity - item.FulfilledQuantity, available);
|
||||
item.FulfilledQuantity += fulfill;
|
||||
incomingPool[item.ProductId] -= fulfill;
|
||||
|
||||
item.Status = item.FulfilledQuantity >= item.RequestedQuantity
|
||||
? PreorderItemStatus.Fulfilled
|
||||
: item.FulfilledQuantity > 0
|
||||
? PreorderItemStatus.PartiallyFulfilled
|
||||
: PreorderItemStatus.Dropped;
|
||||
|
||||
await _preorderDbContext.PreorderItems.UpdateAsync(item);
|
||||
}
|
||||
|
||||
// Only track this item if something actually changed this run
|
||||
// (i.e. it gained fulfilled quantity or got dropped)
|
||||
var gainedQuantity = item.FulfilledQuantity - prevFulfilled;
|
||||
bool wasDropped = item.Status == PreorderItemStatus.Dropped && prevFulfilled == 0;
|
||||
|
||||
if (gainedQuantity > 0 || wasDropped)
|
||||
{
|
||||
if (!newlyResolvedByPreorder.ContainsKey(item.PreorderId))
|
||||
newlyResolvedByPreorder[item.PreorderId] = new List<PreorderItem>();
|
||||
newlyResolvedByPreorder[item.PreorderId].Add(item);
|
||||
}
|
||||
|
||||
Console.WriteLine($"[PreorderConversion] Item #{item.Id} (product {item.ProductId}): " +
|
||||
$"requested={item.RequestedQuantity}, fulfilled={item.FulfilledQuantity}, " +
|
||||
$"gained={item.FulfilledQuantity - prevFulfilled}, status={item.Status}");
|
||||
}
|
||||
|
||||
// Process each affected preorder
|
||||
foreach (var (preorderId, changedItems) in newlyResolvedByPreorder)
|
||||
{
|
||||
await _preorderDbContext.RefreshPreorderStatusAsync(preorderId);
|
||||
|
||||
var preorder = await _preorderDbContext.Preorders.GetByIdAsync(preorderId);
|
||||
if (preorder == null) continue;
|
||||
|
||||
// Items newly gaining fulfilled quantity in this run
|
||||
var newlyFulfilled = changedItems
|
||||
.Where(i => i.FulfilledQuantity - 0 > 0 &&
|
||||
(i.Status == PreorderItemStatus.Fulfilled ||
|
||||
i.Status == PreorderItemStatus.PartiallyFulfilled))
|
||||
.ToList();
|
||||
|
||||
// Items dropped in this run (no stock at all)
|
||||
var newlyDropped = changedItems
|
||||
.Where(i => i.Status == PreorderItemStatus.Dropped)
|
||||
.ToList();
|
||||
|
||||
if (preorder.OrderId == null)
|
||||
{
|
||||
// First time any items are resolved → create the order
|
||||
if (newlyFulfilled.Any() || newlyDropped.Any())
|
||||
{
|
||||
await CreateOrderAsync(preorder, newlyFulfilled, newlyDropped, shippingDocumentId);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Order already exists from a previous document → append new items only
|
||||
await AppendItemsToOrderAsync(preorder, newlyFulfilled, newlyDropped, shippingDocumentId);
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"[PreorderConversion] Done. {newlyResolvedByPreorder.Count} preorders affected.");
|
||||
}
|
||||
|
||||
// ── Expiry sweep ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Closes all preorders whose DateOfReceipt is in the past and still have
|
||||
/// Pending or PartiallyFulfilled items. Any still-Pending items become Dropped.
|
||||
/// Items that were already Fulfilled/PartiallyFulfilled stay as-is (those
|
||||
/// quantities already made it into a real order).
|
||||
/// Called at the start of every conversion run.
|
||||
/// </summary>
|
||||
private async Task SweepExpiredPreordersAsync()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var activePreorderStatuses = new[] { PreorderStatus.Pending, PreorderStatus.PartiallyFulfilled };
|
||||
|
||||
// Find preorders that are past their receipt date — fetch by date only,
|
||||
// then filter by status in memory (LinqToDB can't translate enum comparisons)
|
||||
var expiredPreorders = (await _preorderDbContext.Preorders
|
||||
.GetAll(false)
|
||||
.Where(p => p.DateOfReceipt < now)
|
||||
.ToListAsync())
|
||||
.Where(p => p.Status == PreorderStatus.Pending ||
|
||||
p.Status == PreorderStatus.PartiallyFulfilled)
|
||||
.ToList();
|
||||
|
||||
if (!expiredPreorders.Any()) return;
|
||||
|
||||
Console.WriteLine($"[PreorderConversion] Sweeping {expiredPreorders.Count} expired preorders");
|
||||
|
||||
foreach (var preorder in expiredPreorders)
|
||||
{
|
||||
var items = await _preorderDbContext.PreorderItems
|
||||
.GetAllByPreorderIdAsync(preorder.Id)
|
||||
.ToListAsync();
|
||||
|
||||
// Drop only the items that were never fulfilled — already-fulfilled
|
||||
// items stay as-is since they are already on a real order
|
||||
var stillPending = items.Where(i => i.Status == PreorderItemStatus.Pending).ToList();
|
||||
foreach (var item in stillPending)
|
||||
{
|
||||
item.Status = PreorderItemStatus.Dropped;
|
||||
await _preorderDbContext.PreorderItems.UpdateAsync(item);
|
||||
}
|
||||
|
||||
// Recalculate header status
|
||||
await _preorderDbContext.RefreshPreorderStatusAsync(preorder.Id);
|
||||
|
||||
var hadAnyFulfillment = items.Any(i =>
|
||||
i.Status == PreorderItemStatus.Fulfilled ||
|
||||
i.Status == PreorderItemStatus.PartiallyFulfilled);
|
||||
|
||||
Console.WriteLine($"[PreorderConversion] Expired preorder #{preorder.Id}: " +
|
||||
$"{stillPending.Count} items dropped, " +
|
||||
$"hadFulfillment={hadAnyFulfillment}, orderId={preorder.OrderId}");
|
||||
|
||||
// TODO: Send expiry notification if nothing was ever fulfilled
|
||||
// (fully unfulfilled preorders — customer should be notified)
|
||||
// if (!hadAnyFulfillment)
|
||||
// await _fruitBankNotificationService.SendPreorderExpiredNotificationAsync(preorder);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Create new order (first document that fulfills anything) ──────────────
|
||||
|
||||
private async Task CreateOrderAsync(
|
||||
Preorder preorder,
|
||||
List<PreorderItem> fulfilledItems,
|
||||
List<PreorderItem> droppedItems,
|
||||
int shippingDocumentId)
|
||||
{
|
||||
var customer = await _customerService.GetCustomerByIdAsync(preorder.CustomerId);
|
||||
if (customer == null)
|
||||
{
|
||||
Console.WriteLine($"[PreorderConversion] Customer {preorder.CustomerId} not found — skipping order creation for preorder #{preorder.Id}");
|
||||
return;
|
||||
}
|
||||
|
||||
var billingAddressId = customer.BillingAddressId ?? 0;
|
||||
if (billingAddressId == 0)
|
||||
{
|
||||
var addrMapping = await _dbContext.CustomerAddressMappings.Table
|
||||
.Where(m => m.CustomerId == customer.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
billingAddressId = addrMapping?.AddressId ?? 0;
|
||||
}
|
||||
|
||||
if (billingAddressId == 0)
|
||||
{
|
||||
Console.WriteLine($"[PreorderConversion] No billing address for customer {customer.Id} — skipping for preorder #{preorder.Id}");
|
||||
return;
|
||||
}
|
||||
|
||||
var orderTotal = await CalculateTotalAsync(fulfilledItems);
|
||||
|
||||
var order = new Order
|
||||
{
|
||||
OrderGuid = Guid.NewGuid(),
|
||||
StoreId = preorder.StoreId,
|
||||
CustomerId = preorder.CustomerId,
|
||||
BillingAddressId = billingAddressId,
|
||||
OrderStatusId = (int)OrderStatus.Pending,
|
||||
PaymentStatusId = (int)PaymentStatus.Pending,
|
||||
ShippingStatusId = (int)ShippingStatus.NotYetShipped,
|
||||
PaymentMethodSystemName = "Payments.CheckMoneyOrder",
|
||||
CustomerLanguageId = 1,
|
||||
CustomerTaxDisplayTypeId = 0,
|
||||
OrderSubtotalInclTax = orderTotal,
|
||||
OrderSubtotalExclTax = Math.Round(orderTotal / 1.27m, 2),
|
||||
OrderSubTotalDiscountInclTax = 0m,
|
||||
OrderSubTotalDiscountExclTax = 0m,
|
||||
OrderShippingInclTax = 0m,
|
||||
OrderShippingExclTax = 0m,
|
||||
PaymentMethodAdditionalFeeInclTax = 0m,
|
||||
PaymentMethodAdditionalFeeExclTax = 0m,
|
||||
TaxRates = "0:0;",
|
||||
OrderTax = 0m,
|
||||
OrderTotal = orderTotal,
|
||||
RefundedAmount = 0m,
|
||||
CustomerCurrencyCode = "HUF",
|
||||
CurrencyRate = 1m,
|
||||
OrderDiscount = 0m,
|
||||
CheckoutAttributeDescription = string.Empty,
|
||||
CheckoutAttributesXml = string.Empty,
|
||||
CustomerIp = string.Empty,
|
||||
AllowStoringCreditCardNumber = false,
|
||||
CardType = string.Empty,
|
||||
CardName = string.Empty,
|
||||
CardNumber = string.Empty,
|
||||
MaskedCreditCardNumber = string.Empty,
|
||||
CardCvv2 = string.Empty,
|
||||
CardExpirationMonth = string.Empty,
|
||||
CardExpirationYear = string.Empty,
|
||||
AuthorizationTransactionId = string.Empty,
|
||||
AuthorizationTransactionCode = string.Empty,
|
||||
AuthorizationTransactionResult = string.Empty,
|
||||
CaptureTransactionId = string.Empty,
|
||||
CaptureTransactionResult = string.Empty,
|
||||
SubscriptionTransactionId = string.Empty,
|
||||
PaidDateUtc = null,
|
||||
ShippingMethod = string.Empty,
|
||||
ShippingRateComputationMethodSystemName = string.Empty,
|
||||
Deleted = false,
|
||||
CreatedOnUtc = DateTime.UtcNow,
|
||||
CustomOrderNumber = string.Empty
|
||||
};
|
||||
|
||||
await _dbContext.Orders.InsertAsync(order);
|
||||
order.CustomOrderNumber = order.Id.ToString();
|
||||
await _dbContext.Orders.UpdateAsync(order);
|
||||
|
||||
// Save OrderId back on the Preorder so future documents can find it
|
||||
preorder.OrderId = order.Id;
|
||||
preorder.UpdatedOnUtc = DateTime.UtcNow;
|
||||
await _preorderDbContext.Preorders.UpdateAsync(preorder);
|
||||
|
||||
// DateOfReceipt generic attribute
|
||||
await _dbContext.GenericAttributes.InsertAsync(new Nop.Core.Domain.Common.GenericAttribute
|
||||
{
|
||||
EntityId = order.Id, KeyGroup = nameof(Order), Key = "DateOfReceipt",
|
||||
Value = preorder.DateOfReceipt.ToString("O"), StoreId = preorder.StoreId,
|
||||
CreatedOrUpdatedDateUTC = DateTime.UtcNow
|
||||
});
|
||||
|
||||
await InsertOrderItemsAsync(order, fulfilledItems);
|
||||
await InsertOrderNoteAsync(order.Id, preorder.Id, shippingDocumentId, fulfilledItems, droppedItems);
|
||||
|
||||
// Fire event so existing handlers (EventConsumer etc.) run
|
||||
await _eventPublisher.PublishAsync(new OrderPlacedEvent(order));
|
||||
|
||||
// TODO: Send "FruitBank.PreorderConverted.CustomerNotification" email
|
||||
// summarising fulfilled items, dropped items, order ID, DateOfReceipt
|
||||
// await _fruitBankNotificationService.SendPreorderConvertedNotificationAsync(order, preorder, fulfilledItems, droppedItems);
|
||||
|
||||
Console.WriteLine($"[PreorderConversion] Created Order #{order.Id} from Preorder #{preorder.Id} — " +
|
||||
$"{fulfilledItems.Count} fulfilled, {droppedItems.Count} dropped, total {orderTotal:N0} Ft");
|
||||
}
|
||||
|
||||
// ── Append to existing order (subsequent documents) ───────────────────────
|
||||
|
||||
private async Task AppendItemsToOrderAsync(
|
||||
Preorder preorder,
|
||||
List<PreorderItem> newlyFulfilled,
|
||||
List<PreorderItem> newlyDropped,
|
||||
int shippingDocumentId)
|
||||
{
|
||||
var order = await _dbContext.Orders.GetByIdAsync(preorder.OrderId!.Value);
|
||||
if (order == null)
|
||||
{
|
||||
Console.WriteLine($"[PreorderConversion] Preorder #{preorder.Id} references Order #{preorder.OrderId} which no longer exists — creating fresh");
|
||||
preorder.OrderId = null;
|
||||
await CreateOrderAsync(preorder, newlyFulfilled, newlyDropped, shippingDocumentId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newlyFulfilled.Any() && !newlyDropped.Any())
|
||||
{
|
||||
Console.WriteLine($"[PreorderConversion] Preorder #{preorder.Id}: no new items to append to Order #{order.Id}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Append new OrderItems for the newly fulfilled items only
|
||||
await InsertOrderItemsAsync(order, newlyFulfilled);
|
||||
|
||||
// Recalculate order total from all order items
|
||||
var allItems = await _dbContext.OrderItems.Table
|
||||
.Where(oi => oi.OrderId == order.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var newTotal = 0m;
|
||||
foreach (var oi in allItems)
|
||||
newTotal += oi.PriceInclTax;
|
||||
|
||||
order.OrderTotal = newTotal;
|
||||
order.OrderSubtotalInclTax = newTotal;
|
||||
order.OrderSubtotalExclTax = Math.Round(newTotal / 1.27m, 2);
|
||||
await _dbContext.Orders.UpdateAsync(order);
|
||||
|
||||
// Add a note for this document's contribution
|
||||
await InsertOrderNoteAsync(order.Id, preorder.Id, shippingDocumentId, newlyFulfilled, newlyDropped);
|
||||
|
||||
// TODO: Send update notification email (same template as initial, but framed as an update)
|
||||
// await _fruitBankNotificationService.SendPreorderConvertedNotificationAsync(order, preorder, newlyFulfilled, newlyDropped);
|
||||
|
||||
Console.WriteLine($"[PreorderConversion] Appended {newlyFulfilled.Count} items to Order #{order.Id} " +
|
||||
$"from Preorder #{preorder.Id} via document #{shippingDocumentId}. " +
|
||||
$"New total: {newTotal:N0} Ft");
|
||||
}
|
||||
|
||||
// ── Shared helpers ────────────────────────────────────────────────────────
|
||||
|
||||
private async Task InsertOrderItemsAsync(Order order, List<PreorderItem> items)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
var productDto = await _dbContext.ProductDtos.GetByIdAsync(item.ProductId, true);
|
||||
if (productDto == null) continue;
|
||||
|
||||
var unitPriceExclTax = Math.Round(item.UnitPriceInclTax / 1.27m, 4);
|
||||
var priceInclTax = productDto.IsMeasurable ? 0m : item.UnitPriceInclTax * item.FulfilledQuantity;
|
||||
var priceExclTax = productDto.IsMeasurable ? 0m : unitPriceExclTax * item.FulfilledQuantity;
|
||||
|
||||
await _dbContext.OrderItems.InsertAsync(new OrderItem
|
||||
{
|
||||
OrderItemGuid = Guid.NewGuid(),
|
||||
OrderId = order.Id,
|
||||
ProductId = item.ProductId,
|
||||
Quantity = item.FulfilledQuantity,
|
||||
UnitPriceInclTax = item.UnitPriceInclTax,
|
||||
UnitPriceExclTax = unitPriceExclTax,
|
||||
PriceInclTax = priceInclTax,
|
||||
PriceExclTax = priceExclTax,
|
||||
DiscountAmountInclTax = 0m,
|
||||
DiscountAmountExclTax = 0m,
|
||||
OriginalProductCost = 0m,
|
||||
AttributeDescription = string.Empty,
|
||||
AttributesXml = string.Empty,
|
||||
DownloadCount = 0,
|
||||
IsDownloadActivated = false,
|
||||
LicenseDownloadId = 0,
|
||||
RentalStartDateUtc = null,
|
||||
RentalEndDateUtc = null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InsertOrderNoteAsync(
|
||||
int orderId, int preorderId, int shippingDocumentId,
|
||||
List<PreorderItem> fulfilled, List<PreorderItem> dropped)
|
||||
{
|
||||
var fulfilledDesc = fulfilled.Any()
|
||||
? $"Teljesített: {string.Join(", ", fulfilled.Select(i => $"#{i.ProductId} ({i.FulfilledQuantity} db)"))}"
|
||||
: "Nincs teljesített tétel";
|
||||
var droppedDesc = dropped.Any()
|
||||
? $"Ejtett: {string.Join(", ", dropped.Select(i => $"#{i.ProductId}"))}"
|
||||
: string.Empty;
|
||||
|
||||
var docRef = shippingDocumentId > 0
|
||||
? $"szállítási dokumentum #{shippingDocumentId}"
|
||||
: "azonnali készletből (előrendelés leadásakor)";
|
||||
|
||||
var note = new OrderNote
|
||||
{
|
||||
OrderId = orderId,
|
||||
Note = $"Előrendelés #{preorderId} — {docRef}. " +
|
||||
$"{fulfilledDesc}. {droppedDesc}".TrimEnd('.', ' ') + ".",
|
||||
DisplayToCustomer = false,
|
||||
CreatedOnUtc = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _orderService.InsertOrderNoteAsync(note);
|
||||
}
|
||||
|
||||
private async Task<decimal> CalculateTotalAsync(List<PreorderItem> items)
|
||||
{
|
||||
var total = 0m;
|
||||
foreach (var item in items)
|
||||
{
|
||||
var productDto = await _dbContext.ProductDtos.GetByIdAsync(item.ProductId, true);
|
||||
if (productDto == null || productDto.IsMeasurable) continue;
|
||||
total += item.UnitPriceInclTax * item.FulfilledQuantity;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private async Task<Dictionary<int, int>> BuildIncomingQuantityPoolAsync(IList<int> productIds)
|
||||
{
|
||||
// 1. AvailableQuantity from ProductDto already accounts for
|
||||
// StockQuantity + IncomingQuantity (stock is allowed to go negative
|
||||
// to the limit of IncomingQuantity in the FruitBank stock model)
|
||||
var productDtos = await _dbContext.ProductDtos
|
||||
.GetAllByIds(productIds, loadRelations: false)
|
||||
.ToListAsync();
|
||||
|
||||
var availableByProduct = productDtos.ToDictionary(
|
||||
p => p.Id,
|
||||
p => p.AvailableQuantity);
|
||||
|
||||
var activeItemStatuses = new[] { PreorderItemStatus.Fulfilled, PreorderItemStatus.PartiallyFulfilled };
|
||||
|
||||
// 2. Subtract quantities already committed to preorders in previous runs
|
||||
// Fetch by productId only, filter by status in memory
|
||||
var allCommittedItems = await _preorderDbContext.PreorderItems.Table
|
||||
.Where(i => productIds.Contains(i.ProductId))
|
||||
.ToListAsync();
|
||||
|
||||
var alreadyAllocated = allCommittedItems
|
||||
.Where(i => i.Status == PreorderItemStatus.Fulfilled ||
|
||||
i.Status == PreorderItemStatus.PartiallyFulfilled)
|
||||
.GroupBy(i => i.ProductId)
|
||||
.Select(g => new { ProductId = g.Key, Allocated = g.Sum(i => i.FulfilledQuantity) })
|
||||
.ToList();
|
||||
|
||||
var allocatedByProduct = alreadyAllocated.ToDictionary(x => x.ProductId, x => x.Allocated);
|
||||
|
||||
// 3. Net pool = available − already committed to preorders
|
||||
var result = new Dictionary<int, int>();
|
||||
foreach (var productId in productIds)
|
||||
{
|
||||
var available = availableByProduct.TryGetValue(productId, out var avail) ? avail : 0;
|
||||
var committed = allocatedByProduct.TryGetValue(productId, out var alloc) ? alloc : 0;
|
||||
result[productId] = Math.Max(0, available - committed);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
using Mango.Nop.Core.Loggers;
|
||||
using Nop.Services.Configuration;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
#nullable enable
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Z.ai GLM-OCR service — szállítólevelek, rendelési dokumentumok strukturált szövegkinyerésére.
|
||||
/// Endpoint: POST https://api.z.ai/api/paas/v4/layout_parsing
|
||||
/// Konfiguráció: FruitBankSettings.ZaiApiKey (+ opcionális ZaiModel, default: "glm-ocr")
|
||||
///
|
||||
/// Output formátum: Markdown + HTML vegyes szöveg (md_results mező).
|
||||
/// A táblázatokat <table>/<thead>/<td> tagekben adja vissza — LLM-nek közvetlenül átadható.
|
||||
/// </summary>
|
||||
public class ZaiService
|
||||
{
|
||||
private const string LayoutParsingEndpoint = "https://api.z.ai/api/paas/v4/layout_parsing";
|
||||
private const string DefaultModel = "glm-ocr";
|
||||
|
||||
private readonly ISettingService _settingService;
|
||||
private readonly FruitBankSettings _settings;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<ZaiService> _logger;
|
||||
|
||||
public ZaiService(
|
||||
ISettingService settingService,
|
||||
HttpClient httpClient,
|
||||
ILogger<ZaiService> logger)
|
||||
{
|
||||
_settingService = settingService;
|
||||
_settings = _settingService.LoadSetting<FruitBankSettings>();
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private string ApiKey => _settings.ZaiApiKey
|
||||
?? throw new InvalidOperationException("ZAI API kulcs nincs konfigurálva (FruitBankSettings.ZaiApiKey).");
|
||||
|
||||
private string Model => string.IsNullOrWhiteSpace(_settings.ZaiModel)
|
||||
? DefaultModel
|
||||
: _settings.ZaiModel;
|
||||
|
||||
// ── Publikus API ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// OCR elemzés nyilvánosan elérhető URL alapján (kép vagy PDF).
|
||||
/// </summary>
|
||||
/// <param name="fileUrl">Nyilvánosan elérhető HTTP(S) URL. Kép: max 10 MB, PDF: max 50 MB / 100 oldal.</param>
|
||||
public async Task<ZaiOcrResult> AnalyzeUrlAsync(string fileUrl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileUrl))
|
||||
return ZaiOcrResult.Failure("A fileUrl paraméter üres.");
|
||||
|
||||
var body = JsonSerializer.Serialize(new { model = Model, file = fileUrl });
|
||||
return await CallApiAsync(body);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OCR elemzés memóriából (Stream).
|
||||
/// A stream tartalma base64-re konvertálódik, majd data URI-ként kerül az API-hoz.
|
||||
/// </summary>
|
||||
/// <param name="stream">Kép vagy PDF stream.</param>
|
||||
/// <param name="mimeType">MIME típus, pl. "image/jpeg", "application/pdf".</param>
|
||||
public async Task<ZaiOcrResult> AnalyzeStreamAsync(Stream stream, string mimeType)
|
||||
{
|
||||
if (stream == null || stream.Length == 0)
|
||||
return ZaiOcrResult.Failure("Az átadott stream üres.");
|
||||
|
||||
byte[] bytes;
|
||||
using (var ms = new MemoryStream())
|
||||
{
|
||||
await stream.CopyToAsync(ms);
|
||||
bytes = ms.ToArray();
|
||||
}
|
||||
|
||||
return await AnalyzeBase64Async(Convert.ToBase64String(bytes), mimeType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OCR elemzés base64 kódolt adat alapján.
|
||||
/// Ha az adat még nem tartalmazza a "data:" prefixet, automatikusan data URI-vá alakítja.
|
||||
/// </summary>
|
||||
/// <param name="base64Data">Nyers base64 vagy teljes data URI.</param>
|
||||
/// <param name="mimeType">MIME típus (csak nyers base64 esetén szükséges).</param>
|
||||
public async Task<ZaiOcrResult> AnalyzeBase64Async(string base64Data, string mimeType = "image/jpeg")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(base64Data))
|
||||
return ZaiOcrResult.Failure("A base64Data paraméter üres.");
|
||||
|
||||
var dataUri = base64Data.StartsWith("data:", StringComparison.OrdinalIgnoreCase)
|
||||
? base64Data
|
||||
: $"data:{mimeType};base64,{base64Data}";
|
||||
|
||||
var body = JsonSerializer.Serialize(new { model = Model, file = dataUri });
|
||||
return await CallApiAsync(body);
|
||||
}
|
||||
|
||||
// ── Segédmetódus: MIME típus meghatározása fájlnév alapján ──────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Fájlkiterjesztés alapján visszaadja a megfelelő MIME típust.
|
||||
/// </summary>
|
||||
public static string GetMimeType(string fileName)
|
||||
{
|
||||
var ext = Path.GetExtension(fileName).ToLowerInvariant();
|
||||
return ext switch
|
||||
{
|
||||
".pdf" => "application/pdf",
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
".tif" or ".tiff" => "image/tiff",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
|
||||
// ── Belső API hívás ──────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<ZaiOcrResult> CallApiAsync(string jsonBody)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, LayoutParsingEndpoint);
|
||||
request.Headers.Add("Authorization", $"Bearer {ApiKey}");
|
||||
request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.Error("ZAI API hiba {StatusCode}: {Body}", new Exception(responseBody));
|
||||
return ZaiOcrResult.Failure($"API hiba {(int)response.StatusCode}: {responseBody}");
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(responseBody);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var markdown = TryGetMarkdown(root);
|
||||
|
||||
// Token statisztika logolása (debug szinten, hogy ne spammelje a logot)
|
||||
if (root.TryGetProperty("usage", out var usage))
|
||||
{
|
||||
var prompt = usage.TryGetProperty("prompt_tokens", out var pt) ? pt.GetInt32() : 0;
|
||||
var completion = usage.TryGetProperty("completion_tokens", out var ct) ? ct.GetInt32() : 0;
|
||||
_logger.Debug("ZAI GLM-OCR token felhasználás: {Prompt} + {Completion} = {Total}" +$"{prompt}, {completion}, {prompt} + {completion}");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(markdown))
|
||||
{
|
||||
_logger.Warning("ZAI GLM-OCR: md_results mező üres. Raw válasz: {Body}", responseBody);
|
||||
return ZaiOcrResult.Failure("Az OCR eredmény üres (md_results mező hiányzik a válaszból).");
|
||||
}
|
||||
|
||||
_logger.Debug($"ZAI GLM-OCR sikeres, karakter { markdown.Length}");
|
||||
return ZaiOcrResult.Success(markdown, responseBody);
|
||||
}
|
||||
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
|
||||
{
|
||||
_logger.Error("ZAI GLM-OCR időtúllépés", ex);
|
||||
return ZaiOcrResult.Failure("Időtúllépés: a GLM-OCR API nem válaszolt időben. Nagy PDF-eknél növeld a HttpClient timeout-ját.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("ZAI GLM-OCR hívás kivétellel végződött", ex);
|
||||
return ZaiOcrResult.Failure($"Hálózati hiba: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Az md_results mezőt keresi elsőként (layout_parsing API), majd fallback-eket próbál
|
||||
/// a chat completion API formátumhoz — így a service toleráns az esetleges API verziókkal szemben.
|
||||
/// </summary>
|
||||
private static string TryGetMarkdown(JsonElement root)
|
||||
{
|
||||
// Elsődleges: layout_parsing endpoint saját mezője
|
||||
if (root.TryGetProperty("md_results", out var mdResults))
|
||||
{
|
||||
var val = mdResults.GetString();
|
||||
if (!string.IsNullOrEmpty(val)) return val;
|
||||
}
|
||||
|
||||
// Fallback 1: chat completion stílusú choices tömb
|
||||
if (root.TryGetProperty("choices", out var choices) &&
|
||||
choices.GetArrayLength() > 0 &&
|
||||
choices[0].TryGetProperty("message", out var msg) &&
|
||||
msg.TryGetProperty("content", out var content))
|
||||
{
|
||||
var val = content.GetString();
|
||||
if (!string.IsNullOrEmpty(val)) return val;
|
||||
}
|
||||
|
||||
// Fallback 2: egyszerű result mező
|
||||
if (root.TryGetProperty("result", out var result))
|
||||
{
|
||||
var val = result.GetString();
|
||||
if (!string.IsNullOrEmpty(val)) return val;
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Result record ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// A ZaiService által visszaadott OCR eredmény.
|
||||
/// </summary>
|
||||
public sealed class ZaiOcrResult
|
||||
{
|
||||
public bool IsSuccess { get; private init; }
|
||||
|
||||
/// <summary>
|
||||
/// A teljes dokumentum Markdown+HTML vegyes formátumban.
|
||||
/// Táblázatokat <table>/<th>/<td> tagek tartalmazzák — LLM promptba közvetlenül illeszthető.
|
||||
/// </summary>
|
||||
public string Markdown { get; private init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// A nyers JSON válasz (diagnosztikához / layout_details feldolgozáshoz).
|
||||
/// </summary>
|
||||
public string? RawResponse { get; private init; }
|
||||
|
||||
public string? ErrorMessage { get; private init; }
|
||||
|
||||
public static ZaiOcrResult Success(string markdown, string raw) =>
|
||||
new() { IsSuccess = true, Markdown = markdown, RawResponse = raw };
|
||||
|
||||
public static ZaiOcrResult Failure(string error) =>
|
||||
new() { IsSuccess = false, ErrorMessage = error };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,286 @@
|
|||
@using FruitBank.Common.Enums
|
||||
@using Nop.Plugin.Misc.FruitBankPlugin.Controllers
|
||||
@model List<CustomerPreorderController.CustomerPreorderRow>
|
||||
|
||||
@{
|
||||
Layout = "_ColumnsTwo";
|
||||
ViewBag.Title = "Előrendeléseim";
|
||||
}
|
||||
|
||||
<link rel="stylesheet" href="~/Plugins/Misc.FruitBankPlugin/css/quick-order.css" />
|
||||
|
||||
<div class="page account-page my-preorders-page">
|
||||
<div class="page-title">
|
||||
<h1>Előrendeléseim</h1>
|
||||
</div>
|
||||
<div class="page-body">
|
||||
|
||||
@if (!Model.Any())
|
||||
{
|
||||
<div class="no-data">
|
||||
<p>Még nem adtál le előrendelést.</p>
|
||||
<a href="@Url.Action("Index", "Order")" class="button-1">Rendelés indítása</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var preorder in Model)
|
||||
{
|
||||
var statusClass = preorder.Status switch
|
||||
{
|
||||
PreorderStatus.Confirmed => "po-status-confirmed",
|
||||
PreorderStatus.PartiallyFulfilled => "po-status-partial",
|
||||
PreorderStatus.Cancelled => "po-status-cancelled",
|
||||
_ => "po-status-pending"
|
||||
};
|
||||
var statusLabel = preorder.Status switch
|
||||
{
|
||||
PreorderStatus.Confirmed => "Megerősítve",
|
||||
PreorderStatus.PartiallyFulfilled => "Részben teljesítve",
|
||||
PreorderStatus.Cancelled => "Törölve / Lejárt",
|
||||
_ => "Függőben"
|
||||
};
|
||||
|
||||
<div class="po-customer-card">
|
||||
<div class="po-card-header">
|
||||
<div class="po-card-meta">
|
||||
<span class="po-card-id">#@preorder.PreorderId előrendelés</span>
|
||||
<span class="po-card-date">
|
||||
<i class="fa fa-calendar"></i>
|
||||
Kért szállítás: <strong>@preorder.DateOfReceipt.ToLocalTime().ToString("yyyy. MM. dd. HH:mm")</strong>
|
||||
</span>
|
||||
<span class="po-card-created">
|
||||
Leadva: @preorder.CreatedOnUtc.ToLocalTime().ToString("yyyy. MM. dd.")
|
||||
</span>
|
||||
</div>
|
||||
<div class="po-card-status-wrap">
|
||||
<span class="po-status-badge @statusClass">@statusLabel</span>
|
||||
@if (preorder.OrderId.HasValue)
|
||||
{
|
||||
<a href="@Url.RouteUrl("OrderDetails", new { orderId = preorder.OrderId })"
|
||||
class="po-order-link">
|
||||
<i class="fa fa-external-link"></i> Rendelés #@preorder.OrderId
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(preorder.CustomerNote))
|
||||
{
|
||||
<div class="po-card-note">
|
||||
<i class="fa fa-comment-o"></i>
|
||||
@preorder.CustomerNote
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="po-card-items">
|
||||
<table class="po-items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Termék</th>
|
||||
<th class="text-center">Kérve</th>
|
||||
<th class="text-center">Teljesítve</th>
|
||||
<th class="text-right">Egységár</th>
|
||||
<th class="text-center po-status-col">Állapot</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var item in preorder.Items)
|
||||
{
|
||||
var itemStatusLabel = item.Status switch
|
||||
{
|
||||
PreorderItemStatus.Fulfilled => "✓ Teljesítve",
|
||||
PreorderItemStatus.PartiallyFulfilled => "◑ Részben",
|
||||
PreorderItemStatus.Dropped => "✕ Ejtve",
|
||||
_ => "⏳ Vár"
|
||||
};
|
||||
var itemStatusClass = item.Status switch
|
||||
{
|
||||
PreorderItemStatus.Fulfilled => "item-fulfilled",
|
||||
PreorderItemStatus.PartiallyFulfilled => "item-partial",
|
||||
PreorderItemStatus.Dropped => "item-dropped",
|
||||
_ => "item-pending"
|
||||
};
|
||||
var unitPrice = item.IsMeasurable
|
||||
? "Súlymérés"
|
||||
: item.UnitPriceInclTax.ToString("N0") + " Ft/db";
|
||||
|
||||
<tr class="@itemStatusClass">
|
||||
<td>
|
||||
@item.ProductName
|
||||
@if (item.IsMeasurable)
|
||||
{
|
||||
<span class="measurable-tag" title="Súlymérést igényel">⚖️</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-center">@item.RequestedQuantity db</td>
|
||||
<td class="text-center">@item.FulfilledQuantity db</td>
|
||||
<td class="text-right">@unitPrice</td>
|
||||
<td class="text-center">
|
||||
<span class="item-status-label @itemStatusClass">@itemStatusLabel</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* ── Page ─────────────────────────────────────────────────────── */
|
||||
.my-preorders-page .page-title h1 {
|
||||
font-size: 24px;
|
||||
color: #1a3c22;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 48px 20px;
|
||||
color: #6b7c6e;
|
||||
}
|
||||
|
||||
.no-data p { margin-bottom: 16px; font-size: 15px; }
|
||||
|
||||
/* ── Preorder card ────────────────────────────────────────────── */
|
||||
.po-customer-card {
|
||||
background: #fff;
|
||||
border: 1px solid #dde8da;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.po-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
background: #f5f7f2;
|
||||
border-bottom: 1px solid #dde8da;
|
||||
}
|
||||
|
||||
.po-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
font-size: 13px;
|
||||
color: #6b7c6e;
|
||||
}
|
||||
|
||||
.po-card-id {
|
||||
font-weight: 700;
|
||||
color: #1a3c22;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.po-card-date strong { color: #1a3c22; }
|
||||
|
||||
.po-card-status-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.po-status-badge {
|
||||
display: inline-block;
|
||||
padding: 3px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.po-status-pending { background: #fff3cd; color: #856404; }
|
||||
.po-status-confirmed { background: #d4edda; color: #155724; }
|
||||
.po-status-partial { background: #fff8ee; color: #c87500; }
|
||||
.po-status-cancelled { background: #f8d7da; color: #721c24; }
|
||||
|
||||
.po-order-link {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #2d7a3a;
|
||||
text-decoration: none;
|
||||
border: 1px solid #2d7a3a;
|
||||
border-radius: 4px;
|
||||
padding: 2px 10px;
|
||||
}
|
||||
|
||||
.po-order-link:hover { background: #2d7a3a; color: #fff; }
|
||||
|
||||
/* Note */
|
||||
.po-card-note {
|
||||
padding: 10px 20px;
|
||||
font-size: 13px;
|
||||
color: #6b7c6e;
|
||||
background: #fffdf7;
|
||||
border-bottom: 1px solid #dde8da;
|
||||
}
|
||||
|
||||
.po-card-note .fa { margin-right: 6px; color: #f4a236; }
|
||||
|
||||
/* Items table */
|
||||
.po-card-items { padding: 0; }
|
||||
|
||||
.po-items-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.po-items-table th {
|
||||
padding: 8px 14px;
|
||||
background: #f0f4ee;
|
||||
color: #1a3c22;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
border-bottom: 1px solid #dde8da;
|
||||
}
|
||||
|
||||
.po-items-table td {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #f0f4ee;
|
||||
color: #2c3e2e;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.po-items-table tr:last-child td { border-bottom: none; }
|
||||
|
||||
.po-items-table tr.item-fulfilled { background: #f6fbf4; }
|
||||
.po-items-table tr.item-partial { background: #fffbf0; }
|
||||
.po-items-table tr.item-dropped { background: #fdf6f6; color: #999; }
|
||||
|
||||
.item-status-label {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.item-status-label.item-fulfilled { background: #d4edda; color: #155724; }
|
||||
.item-status-label.item-partial { background: #fff8ee; color: #c87500; }
|
||||
.item-status-label.item-dropped { background: #f8d7da; color: #721c24; }
|
||||
.item-status-label.item-pending { background: #fff3cd; color: #856404; }
|
||||
|
||||
.measurable-tag { margin-left: 4px; font-size: 13px; }
|
||||
|
||||
.text-center { text-align: center; }
|
||||
.text-right { text-align: right; }
|
||||
|
||||
@@media (max-width: 600px) {
|
||||
.po-status-col { display: none; }
|
||||
.po-items-table th:last-child,
|
||||
.po-items-table td:last-child { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<li class="customer-navigation-item @(Context.Request.Path.Value?.Contains("elorerendeles") == true ? "active" : "")">
|
||||
<a href="@Url.Action("List", "CustomerPreorder")">
|
||||
Előrendeléseim
|
||||
</a>
|
||||
</li>
|
||||
<li class="customer-navigation-item @(Context.Request.Path.Value?.Contains("segitseg") == true ? "active" : "")">
|
||||
<a href="@Url.Action("Index", "Help")">
|
||||
<i class="fa fa-question-circle" style="margin-right:5px;color:#2d7a3a;"></i> Segítség
|
||||
</a>
|
||||
</li>
|
||||
|
|
@ -0,0 +1,533 @@
|
|||
@{
|
||||
Layout = "_Root";
|
||||
ViewBag.Title = "Segítség";
|
||||
}
|
||||
|
||||
<style>
|
||||
.help-page {
|
||||
max-width: 780px;
|
||||
margin: 0 auto;
|
||||
padding: 0 0 60px;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
}
|
||||
|
||||
.help-hero {
|
||||
background: linear-gradient(135deg, #1a3c22 0%, #2d7a3a 100%);
|
||||
border-radius: 12px;
|
||||
padding: 36px 32px;
|
||||
margin-bottom: 36px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.help-hero-icon {
|
||||
font-size: 48px;
|
||||
opacity: 0.9;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.help-hero h1 {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.help-hero p {
|
||||
font-size: 15px;
|
||||
opacity: 0.85;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Section ─────────────────────────────────────────────── */
|
||||
.help-section {
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.help-section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1a3c22;
|
||||
border-left: 4px solid #2d7a3a;
|
||||
padding-left: 14px;
|
||||
margin-bottom: 18px;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
/* ── Step cards ──────────────────────────────────────────── */
|
||||
.help-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.help-step {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
background: #f5f7f2;
|
||||
border: 1px solid #dde8da;
|
||||
border-radius: 10px;
|
||||
padding: 16px 18px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.help-step-num {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: #2d7a3a;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.help-step-num.amber { background: #f4a236; color: #1a3c22; }
|
||||
|
||||
.help-step-body {}
|
||||
.help-step-title { font-weight: 700; color: #1a3c22; margin-bottom: 4px; font-size: 14px; }
|
||||
.help-step-desc { font-size: 13px; color: #4a5e4d; line-height: 1.6; }
|
||||
|
||||
/* ── Two-column flow cards ───────────────────────────────── */
|
||||
.help-flow-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.help-flow-card {
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
border: 2px solid;
|
||||
}
|
||||
|
||||
.help-flow-card.green {
|
||||
background: #eaf3de;
|
||||
border-color: #2d7a3a;
|
||||
}
|
||||
|
||||
.help-flow-card.amber {
|
||||
background: #fff8ee;
|
||||
border-color: #f4a236;
|
||||
}
|
||||
|
||||
.hfc-icon {
|
||||
font-size: 28px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.help-flow-card.green .hfc-icon { color: #2d7a3a; }
|
||||
.help-flow-card.amber .hfc-icon { color: #c87500; }
|
||||
|
||||
.hfc-title {
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.help-flow-card.green .hfc-title { color: #1a3c22; }
|
||||
.help-flow-card.amber .hfc-title { color: #7a4200; }
|
||||
|
||||
.hfc-when {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.help-flow-card.green .hfc-when { color: #2d7a3a; }
|
||||
.help-flow-card.amber .hfc-when { color: #c87500; }
|
||||
|
||||
.hfc-desc {
|
||||
font-size: 13px;
|
||||
color: #444;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.hfc-example {
|
||||
font-size: 12px;
|
||||
background: rgba(255,255,255,0.6);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
color: #555;
|
||||
line-height: 1.5;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Table ───────────────────────────────────────────────── */
|
||||
.help-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
.help-table th {
|
||||
background: #1a3c22;
|
||||
color: #fff;
|
||||
padding: 9px 13px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .4px;
|
||||
}
|
||||
|
||||
.help-table th:first-child { border-radius: 6px 0 0 0; }
|
||||
.help-table th:last-child { border-radius: 0 6px 0 0; }
|
||||
|
||||
.help-table td {
|
||||
padding: 10px 13px;
|
||||
border-bottom: 1px solid #dde8da;
|
||||
color: #2c3e2e;
|
||||
vertical-align: top;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.help-table tr:last-child td { border-bottom: none; }
|
||||
.help-table tr:nth-child(even) td { background: #f5f7f2; }
|
||||
|
||||
/* ── FAQ ─────────────────────────────────────────────────── */
|
||||
.help-faq { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.help-faq-item {
|
||||
border: 1px solid #dde8da;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.help-faq-q {
|
||||
width: 100%;
|
||||
background: #f5f7f2;
|
||||
border: none;
|
||||
padding: 13px 16px;
|
||||
text-align: left;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1a3c22;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.help-faq-q:hover { background: #eaf3de; }
|
||||
.help-faq-q .fa-chevron-down { margin-left: auto; font-size: 11px; color: #6b7c6e; transition: transform 0.2s; }
|
||||
.help-faq-q.open .fa-chevron-down { transform: rotate(180deg); }
|
||||
.help-faq-q .fa:first-child { color: #2d7a3a; }
|
||||
|
||||
.help-faq-a {
|
||||
display: none;
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
color: #444;
|
||||
line-height: 1.7;
|
||||
border-top: 1px solid #dde8da;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* ── Banner ──────────────────────────────────────────────── */
|
||||
.help-banner {
|
||||
background: #fff8ee;
|
||||
border: 1px solid #f4c87a;
|
||||
border-left: 4px solid #f4a236;
|
||||
border-radius: 8px;
|
||||
padding: 14px 18px;
|
||||
font-size: 13px;
|
||||
color: #7a4200;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
line-height: 1.6;
|
||||
margin: 14px 0;
|
||||
}
|
||||
.help-banner .fa { color: #f4a236; flex-shrink: 0; margin-top: 1px; font-size: 16px; }
|
||||
|
||||
.help-banner.green {
|
||||
background: #eaf3de;
|
||||
border-color: #a8d08d;
|
||||
border-left-color: #2d7a3a;
|
||||
color: #1a3c22;
|
||||
}
|
||||
.help-banner.green .fa { color: #2d7a3a; }
|
||||
|
||||
/* ── CTA ─────────────────────────────────────────────────── */
|
||||
.help-cta {
|
||||
background: linear-gradient(135deg, #1a3c22 0%, #2d7a3a 100%);
|
||||
border-radius: 12px;
|
||||
padding: 28px 32px;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.help-cta h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.help-cta p {
|
||||
font-size: 14px;
|
||||
opacity: 0.85;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.help-cta-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #f4a236;
|
||||
color: #1a3c22 !important;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
padding: 12px 28px;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
transition: background 0.18s;
|
||||
}
|
||||
.help-cta-btn:hover { background: #e8922a; }
|
||||
|
||||
@@media (max-width: 600px) {
|
||||
.help-hero { flex-direction: column; text-align: center; padding: 24px 20px; }
|
||||
.help-flow-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="help-page">
|
||||
|
||||
<!-- ── Hero ──────────────────────────────────────────────────────── -->
|
||||
<div class="help-hero">
|
||||
<div class="help-hero-icon"><i class="fa fa-question-circle"></i></div>
|
||||
<div>
|
||||
<h1>Hogyan rendeljek a FruitBankon?</h1>
|
||||
<p>Minden, amit a rendelési folyamatról tudni kell — egyszerűen elmagyarázva.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 1. A két rendelési mód ─────────────────────────────────────── -->
|
||||
<div class="help-section">
|
||||
<div class="help-section-title">A két rendelési mód</div>
|
||||
|
||||
<div class="help-flow-grid">
|
||||
<div class="help-flow-card green">
|
||||
<div class="hfc-icon"><i class="fa fa-shopping-basket"></i></div>
|
||||
<div class="hfc-title">Rendelés</div>
|
||||
<div class="hfc-when">Azonnali teljesítés</div>
|
||||
<div class="hfc-desc">A raktáron lévő árukból azonnal leadhatsz rendelést. A termékeket szabad szöveges keresővel vagy <strong>hangutasítással</strong> adhatod a kosárhoz.</div>
|
||||
<div class="hfc-example">Pl. „Narancs 100 doboz, alma 50 kg" — bemond vagy begépeled, a rendszer megtalálja a termékeket.</div>
|
||||
</div>
|
||||
<div class="help-flow-card amber">
|
||||
<div class="hfc-icon"><i class="fa fa-calendar-plus-o"></i></div>
|
||||
<div class="hfc-title">Előrendelés</div>
|
||||
<div class="hfc-when">Jövő heti áru</div>
|
||||
<div class="hfc-desc">Ha az áru még úton van (jövő héten érkezik), leadhatsz egy kívánságlistát. Amint megérkezik a szállítmány, <strong>automatikusan rendelés lesz belőle</strong> és e-mailben értesítünk.</div>
|
||||
<div class="hfc-example">Pl. Hétfőn rendeled a csütörtökön érkező narancsot — a rendszer feljegyzi és automatikusan intézi.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-banner">
|
||||
<i class="fa fa-info-circle"></i>
|
||||
<div>A rendszer <strong>automatikusan</strong> dönti el, melyik módot mutatja — nem kell manuálisan választani. A kiválasztott szállítási nap alapján azonnal jelzi, mire számíthatsz.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 2. Mikor melyik mód ─────────────────────────────────────────── -->
|
||||
<div class="help-section">
|
||||
<div class="help-section-title">Mikor melyik mód jelenik meg?</div>
|
||||
|
||||
<table class="help-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mai nap</th>
|
||||
<th>Kért szállítási nap</th>
|
||||
<th>Mód</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Hétfő / Kedd / Szerda</td>
|
||||
<td>Bármely nap</td>
|
||||
<td><strong style="color:#c87500;">Előrendelés</strong> — a heti áru még úton van</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Csütörtök / Péntek / Szombat / Vasárnap</td>
|
||||
<td>Következő héten (hétfő–szerda)</td>
|
||||
<td><strong style="color:#2d7a3a;">Rendelés</strong> — raktárkészletből azonnal</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Csütörtök / Péntek / Szombat / Vasárnap</td>
|
||||
<td>Ezen a héten (csütörtök–vasárnap)</td>
|
||||
<td><strong style="color:#2d7a3a;">Rendelés</strong> — az áru már megérkezett</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Bármely nap</td>
|
||||
<td>Jövő hét csütörtöktől</td>
|
||||
<td><strong style="color:#c87500;">Előrendelés</strong> — jövő heti szállítmányból</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- ── 3. Lépések ─────────────────────────────────────────────────── -->
|
||||
<div class="help-section">
|
||||
<div class="help-section-title">A rendelés menete lépésről lépésre</div>
|
||||
|
||||
<div class="help-steps">
|
||||
<div class="help-step">
|
||||
<div class="help-step-num">1</div>
|
||||
<div class="help-step-body">
|
||||
<div class="help-step-title">Válassz szállítási napot és időpontot</div>
|
||||
<div class="help-step-desc">Kattints a kívánt napra a naptárban, majd állítsd be a szállítási időpontot. A rendszer azonnal jelzi, hogy rendelés vagy előrendelés lesz-e belőle.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="help-step">
|
||||
<div class="help-step-num">2</div>
|
||||
<div class="help-step-body">
|
||||
<div class="help-step-title">Add meg a termékeket</div>
|
||||
<div class="help-step-desc"><strong>Rendelésnél:</strong> keress szöveggel (pl. „narancs 100") vagy nyomj a mikrofon gombra és mondd be hangosan. A rendszer megtalálja a termékeket és javasolja a mennyiséget.<br><strong>Előrendelésnél:</strong> a rendszer megmutatja az előrendelhető termékeket — csak add meg a kívánt mennyiségeket.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="help-step">
|
||||
<div class="help-step-num">3</div>
|
||||
<div class="help-step-body">
|
||||
<div class="help-step-title">Ellenőrizd a kosarat / összesítőt</div>
|
||||
<div class="help-step-desc">Jobb oldalon látod az összes hozzáadott terméket és a becsült összeget. A súlymérést igénylő tételeknél az ár a mérés után véglegesedik.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="help-step">
|
||||
<div class="help-step-num">4</div>
|
||||
<div class="help-step-body">
|
||||
<div class="help-step-title">Add le a rendelést</div>
|
||||
<div class="help-step-desc"><strong>Rendelésnél:</strong> kattints a „Tovább a pénztárhoz" gombra és erősítsd meg a rendelést.<br><strong>Előrendelésnél:</strong> kattints az „Előrendelés leadása" gombra. Visszaigazolást kapsz e-mailben, majd a szállítmány megérkezésekor értesítünk a végeredményről.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 4. Hangalapú rendelés ──────────────────────────────────────── -->
|
||||
<div class="help-section">
|
||||
<div class="help-section-title">Hangalapú rendelés — hogyan használd?</div>
|
||||
|
||||
<div class="help-banner green">
|
||||
<i class="fa fa-microphone"></i>
|
||||
<div>A hangalapú bevitel <strong>raktármunkások számára</strong> tervezett funkció — gyors és kézszabad rendelés mobilon, táblagépen egyaránt.</div>
|
||||
</div>
|
||||
|
||||
<div class="help-steps">
|
||||
<div class="help-step">
|
||||
<div class="help-step-num">1</div>
|
||||
<div class="help-step-body">
|
||||
<div class="help-step-title">Nyomj a mikrofon gombra</div>
|
||||
<div class="help-step-desc">A böngésző engedélyt kér a mikrofonhoz — engedélyezd. A rendszer automatikusan érzékeli, mikor kezdesz el és mikor fejezed be a beszédet.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="help-step">
|
||||
<div class="help-step-num">2</div>
|
||||
<div class="help-step-body">
|
||||
<div class="help-step-title">Mondd be a termékeket és a rekeszek számát</div>
|
||||
<div class="help-step-desc">Pl. „Narancs száz, alma ötven, banán harminc." Mondd határozottan, a termékek nevét és mennyiségét együtt. A rendszer automatikusan leáll, ha hallgatás érzékel.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="help-step">
|
||||
<div class="help-step-num">3</div>
|
||||
<div class="help-step-body">
|
||||
<div class="help-step-title">Ellenőrizd a találatokat</div>
|
||||
<div class="help-step-desc">A rendszer megjeleníti, mit értett. Ha valamit rosszul azonosított, állítsd be a mennyiséget kézzel, vagy keress rá szöveggel. Majd add a kosárhoz.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 5. Előrendelés részletei ──────────────────────────────────── -->
|
||||
<div class="help-section">
|
||||
<div class="help-section-title">Előrendelés — amit tudni kell</div>
|
||||
|
||||
<div class="help-faq">
|
||||
<div class="help-faq-item">
|
||||
<button class="help-faq-q" type="button">
|
||||
<i class="fa fa-circle" style="font-size:8px;"></i>
|
||||
Garantált az előrendelés teljesítése?
|
||||
<i class="fa fa-chevron-down"></i>
|
||||
</button>
|
||||
<div class="help-faq-a">
|
||||
Nem — az előrendelés egy <strong>kívánságlista</strong>, nem kötelező érvényű megrendelés. Ha a szállítmány nem hoz elegendő árut (pl. kevesebb érkezett a vártnál), a rendszer az érkezési sorrend alapján osztja el a készletet. Mindig értesítünk e-mailben, hogy miből mennyi teljesült.
|
||||
</div>
|
||||
</div>
|
||||
<div class="help-faq-item">
|
||||
<button class="help-faq-q" type="button">
|
||||
<i class="fa fa-circle" style="font-size:8px;"></i>
|
||||
Mi történik, ha csak részben teljesül?
|
||||
<i class="fa fa-chevron-down"></i>
|
||||
</button>
|
||||
<div class="help-faq-a">
|
||||
Automatikusan létrejön egy rendelés a teljesített tételekkel, és e-mailben értesítünk a részletekről — miből mennyi érkezett, és mi maradt ki. A kiesett tételek nem kerülnek automatikusan a következő szállítmányra.
|
||||
</div>
|
||||
</div>
|
||||
<div class="help-faq-item">
|
||||
<button class="help-faq-q" type="button">
|
||||
<i class="fa fa-circle" style="font-size:8px;"></i>
|
||||
Mikor jön létre a tényleges rendelés?
|
||||
<i class="fa fa-chevron-down"></i>
|
||||
</button>
|
||||
<div class="help-faq-a">
|
||||
Amint az adminisztrátor feldolgozza a szállítói dokumentumokat és rögzíti az érkező árut, a rendszer automatikusan létrehozza a rendelést. Ez általában a szállítást megelőző napon, szerdán vagy csütörtökön történik.
|
||||
</div>
|
||||
</div>
|
||||
<div class="help-faq-item">
|
||||
<button class="help-faq-q" type="button">
|
||||
<i class="fa fa-circle" style="font-size:8px;"></i>
|
||||
Módosíthatom az előrendelésemet?
|
||||
<i class="fa fa-chevron-down"></i>
|
||||
</button>
|
||||
<div class="help-faq-a">
|
||||
Az előrendelés módosítása jelenleg fejlesztés alatt van. Addig lépj kapcsolatba velünk telefonon vagy e-mailben, és segítünk a módosításban.
|
||||
</div>
|
||||
</div>
|
||||
<div class="help-faq-item">
|
||||
<button class="help-faq-q" type="button">
|
||||
<i class="fa fa-circle" style="font-size:8px;"></i>
|
||||
Hol látom az előrendeléseimet?
|
||||
<i class="fa fa-chevron-down"></i>
|
||||
</button>
|
||||
<div class="help-faq-a">
|
||||
A <a href="@Url.Action("List", "CustomerPreorder")" style="color:#2d7a3a;font-weight:600;">Saját fiók → Előrendeléseim</a> oldalon látod az összes leadott előrendelést, azok állapotát és a létrejött rendelésekre mutató hivatkozást.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── CTA ────────────────────────────────────────────────────────── -->
|
||||
<div class="help-cta">
|
||||
<h3>Készen állsz a rendelésre?</h3>
|
||||
<p>Válassz szállítási napot, és a rendszer vezet végig a folyamaton.</p>
|
||||
<a href="@Url.Action("Index", "Order")" class="help-cta-btn">
|
||||
<i class="fa fa-bolt"></i> Rendelés indítása
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script asp-location="Footer">
|
||||
$(function () {
|
||||
// FAQ accordion
|
||||
$('.help-faq-q').click(function () {
|
||||
var $a = $(this).next('.help-faq-a');
|
||||
var isOpen = $a.is(':visible');
|
||||
$('.help-faq-a').slideUp(180);
|
||||
$('.help-faq-q').removeClass('open');
|
||||
if (!isOpen) {
|
||||
$a.slideDown(180);
|
||||
$(this).addClass('open');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
<div class="form-group row">
|
||||
|
||||
<div class="col-12 col-md-7">
|
||||
<div class="col-12 col-md-9">
|
||||
<div class="card card-default mb-2">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-file-invoice"></i>
|
||||
|
|
@ -38,22 +38,27 @@
|
|||
</div>
|
||||
<hr />
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-2">
|
||||
<button type="button" class="btn btn-warning btn-block" data-toggle="modal" data-target="#allowRevisionModal">
|
||||
<i class="fa fa-redo"></i> Újramérés engedélyezése
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-2">
|
||||
<button type="button" class="btn btn-success btn-block" data-toggle="modal" data-target="#sendOrderEmailModal">
|
||||
<i class="fas fa-envelope"></i> Email küldése ügyfélnek
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button type="button" class="btn btn-primary btn-block" data-toggle="modal" data-target="#sendMessageModal">
|
||||
<i class="fas fa-paper-plane"></i> Üzenet küldése
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-2">
|
||||
<button type="button" class="btn btn-info btn-block" data-toggle="modal" data-target="#addOrderNoteModal">
|
||||
<i class="fas fa-sticky-note"></i> Jegyzet hozzáadása
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-2">
|
||||
<button type="button"
|
||||
class="btn btn-danger btn-block"
|
||||
data-toggle="modal"
|
||||
|
|
@ -70,7 +75,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-5">
|
||||
<div class="col-12 col-md-3">
|
||||
<div class="card card-default mb-3">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-file-invoice"></i>
|
||||
|
|
@ -107,6 +112,36 @@
|
|||
|
||||
</div>
|
||||
|
||||
<!-- Send Order Email Modal -->
|
||||
<div class="modal fade" id="sendOrderEmailModal" tabindex="-1" role="dialog" aria-labelledby="sendOrderEmailModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="sendOrderEmailModalLabel">
|
||||
<i class="fas fa-envelope"></i> Rendelési email küldése ügyfélnek
|
||||
</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Ez a gomb elküldi a jelenlegi rendelés összefoglalóját az ügyfél email címére a rendelésfeladási email sablon alapján.</p>
|
||||
<div id="sendOrderEmailStatus" class="alert" style="display: none; margin-top: 15px;">
|
||||
<i class="fas fa-info-circle"></i> <span id="sendOrderEmailStatusMessage"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">
|
||||
<i class="fa fa-times"></i> Mégse
|
||||
</button>
|
||||
<button type="button" id="sendOrderEmailBtn" class="btn btn-success">
|
||||
<i class="fas fa-envelope"></i> Email küldése
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Allow Revision Modal -->
|
||||
<div class="modal fade" id="allowRevisionModal" tabindex="-1" role="dialog" aria-labelledby="allowRevisionModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
|
|
@ -1244,6 +1279,54 @@
|
|||
statusDiv.show();
|
||||
}
|
||||
|
||||
// ========== SEND ORDER EMAIL TO CUSTOMER ==========
|
||||
|
||||
var sendOrderEmailUrl = '@Url.Action("SendOrderEmailToCustomer", "CustomOrder")';
|
||||
|
||||
$('#sendOrderEmailBtn').click(function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
var btn = $(this);
|
||||
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Küldés...');
|
||||
showSendOrderEmailStatus('Email küldése folyamatban...', 'info');
|
||||
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: sendOrderEmailUrl,
|
||||
data: {
|
||||
orderId: @Model.OrderId,
|
||||
__RequestVerificationToken: $('input[name="__RequestVerificationToken"]').val()
|
||||
},
|
||||
dataType: 'json',
|
||||
success: function(response) {
|
||||
btn.prop('disabled', false).html('<i class="fas fa-envelope"></i> Email küldése');
|
||||
if (response.success) {
|
||||
showSendOrderEmailStatus(response.message, 'success');
|
||||
setTimeout(function() { $('#sendOrderEmailModal').modal('hide'); }, 2000);
|
||||
} else {
|
||||
showSendOrderEmailStatus('Hiba: ' + (response.message || 'Ismeretlen hiba'), 'danger');
|
||||
}
|
||||
},
|
||||
error: function(xhr) {
|
||||
btn.prop('disabled', false).html('<i class="fas fa-envelope"></i> Email küldése');
|
||||
showSendOrderEmailStatus('Hiba: ' + (xhr.responseText || 'Szerver hiba'), 'danger');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function showSendOrderEmailStatus(message, type) {
|
||||
var statusDiv = $('#sendOrderEmailStatus');
|
||||
statusDiv.removeClass('alert-info alert-success alert-warning alert-danger').addClass('alert-' + type);
|
||||
$('#sendOrderEmailStatusMessage').text(message);
|
||||
statusDiv.show();
|
||||
}
|
||||
|
||||
$('#sendOrderEmailModal').on('hidden.bs.modal', function() {
|
||||
$('#sendOrderEmailStatus').hide();
|
||||
$('#sendOrderEmailBtn').prop('disabled', false).html('<i class="fas fa-envelope"></i> Email küldése');
|
||||
});
|
||||
|
||||
// Clear split order status when modal is closed
|
||||
$('#splitOrderModal').on('hidden.bs.modal', function () {
|
||||
$("#splitOrderStatus").hide();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,460 @@
|
|||
@using System.Text.Encodings.Web
|
||||
@{
|
||||
Layout = "_Root";
|
||||
ViewBag.Title = T("Plugins.Misc.FruitBankPlugin.Preorder.PageTitle").Text;
|
||||
}
|
||||
|
||||
<link rel="stylesheet" href="~/Plugins/Misc.FruitBankPlugin/css/quick-order.css" />
|
||||
<link rel="stylesheet" href="~/Plugins/Misc.FruitBankPlugin/css/preorder.css" />
|
||||
|
||||
<div class="quick-order-page">
|
||||
|
||||
<!-- ── STEP 1: Delivery date + time ─────────────────────────────────── -->
|
||||
<div id="deliveryStep" class="qo-delivery-step">
|
||||
<div class="ds-header">
|
||||
<i class="fa fa-calendar"></i>
|
||||
<div>
|
||||
<div class="ds-title">@T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Title")</div>
|
||||
<div class="ds-subtitle">@T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Subtitle")</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ds-body">
|
||||
<div class="ds-section-label">@T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.DayLabel")</div>
|
||||
<div class="ds-day-buttons" id="dayButtons"></div>
|
||||
|
||||
<div class="ds-section-label" style="margin-top:20px;">
|
||||
@T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.TimeLabel")
|
||||
</div>
|
||||
<div class="ds-time-wrapper">
|
||||
<input type="time" id="deliveryTimePicker" class="ds-time-input" value="08:00" min="05:00" max="22:00" />
|
||||
<span class="ds-time-hint">@T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.TimeHint")</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ds-footer">
|
||||
<button type="button" class="ds-confirm-btn" id="deliveryConfirmBtn" disabled>
|
||||
<i class="fa fa-arrow-right"></i> @T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ConfirmButton")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Delivery chip ────────────────────────────────────────────────── -->
|
||||
<div id="deliveryChip" class="qo-delivery-chip" style="display:none;">
|
||||
<i class="fa fa-calendar-check-o"></i>
|
||||
<span class="dc-label">@T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ChangeLabel")</span>
|
||||
<strong id="deliveryChipText"></strong>
|
||||
<button type="button" class="dc-change-btn" id="deliveryChangeBtn">
|
||||
<i class="fa fa-pencil"></i> @T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ChangeButton")
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── STEP 2: Product selection + submit ───────────────────────────── -->
|
||||
<div id="mainContent" style="display:none;">
|
||||
|
||||
<!-- Info banner -->
|
||||
<div class="po-info-banner">
|
||||
<i class="fa fa-info-circle"></i>
|
||||
@T("Plugins.Misc.FruitBankPlugin.Preorder.InfoBanner")
|
||||
</div>
|
||||
|
||||
<div class="qo-layout">
|
||||
|
||||
<!-- LEFT: product list + note + submit -->
|
||||
<div class="qo-products-panel">
|
||||
|
||||
<!-- Loading -->
|
||||
<div id="productsLoadingState" class="products-empty-state">
|
||||
<i class="fa fa-spinner fa-spin"></i>
|
||||
<p>@T("Plugins.Misc.FruitBankPlugin.Preorder.LoadingProducts")</p>
|
||||
</div>
|
||||
|
||||
<!-- No products -->
|
||||
<div id="noProductsCard" class="no-results-card" style="display:none;">
|
||||
<i class="fa fa-calendar-times-o"></i>
|
||||
<p>@T("Plugins.Misc.FruitBankPlugin.Preorder.NoProductsAvailable")</p>
|
||||
</div>
|
||||
|
||||
<!-- Product grid -->
|
||||
<div id="productSection" style="display:none;">
|
||||
<div class="matches-label">
|
||||
<i class="fa fa-cubes"></i>
|
||||
<span>@T("Plugins.Misc.FruitBankPlugin.Preorder.ProductsLabel")</span>
|
||||
</div>
|
||||
<div id="productGrid" class="product-grid"></div>
|
||||
|
||||
<!-- Customer note -->
|
||||
<div class="po-note-section">
|
||||
<label class="po-note-label" for="customerNote">
|
||||
<i class="fa fa-comment-o"></i>
|
||||
@T("Plugins.Misc.FruitBankPlugin.Preorder.NoteLabel")
|
||||
</label>
|
||||
<textarea id="customerNote" class="po-note-input"
|
||||
placeholder="@T("Plugins.Misc.FruitBankPlugin.Preorder.NotePlaceholder")"
|
||||
rows="3" maxlength="1000"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="po-submit-row">
|
||||
<div id="selectionSummary" class="po-selection-summary"></div>
|
||||
<button type="button" id="submitPreorderBtn" class="po-submit-btn" disabled>
|
||||
<i class="fa fa-paper-plane"></i>
|
||||
@T("Plugins.Misc.FruitBankPlugin.Preorder.SubmitButton")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: summary panel -->
|
||||
<div class="qo-cart-panel">
|
||||
<div class="qo-section-title">
|
||||
<i class="fa fa-list-ul"></i>
|
||||
@T("Plugins.Misc.FruitBankPlugin.Preorder.SummaryTitle")
|
||||
<span id="itemCountBadge" class="cart-count-badge">0</span>
|
||||
</div>
|
||||
|
||||
<div id="summaryEmpty" class="cart-empty">
|
||||
<i class="fa fa-list-ul"></i>
|
||||
<p>@T("Plugins.Misc.FruitBankPlugin.Preorder.SummaryEmpty")</p>
|
||||
</div>
|
||||
|
||||
<div id="summaryList" class="cart-items-list" style="display:none;"></div>
|
||||
|
||||
<div id="summaryNote" class="cart-total-row" style="display:none;">
|
||||
<div class="cart-total-note">
|
||||
<i class="fa fa-info-circle"></i>
|
||||
<small>@T("Plugins.Misc.FruitBankPlugin.Preorder.SummaryNote")</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── SUCCESS STATE ─────────────────────────────────────────────────── -->
|
||||
<div id="successState" style="display:none;" class="po-success-state">
|
||||
<div class="po-success-icon"><i class="fa fa-check-circle"></i></div>
|
||||
<h2>@T("Plugins.Misc.FruitBankPlugin.Preorder.SuccessTitle")</h2>
|
||||
<p id="successMessage"></p>
|
||||
<a href="@Url.RouteUrl("Homepage")" class="po-back-btn">
|
||||
<i class="fa fa-home"></i> @T("Plugins.Misc.FruitBankPlugin.Preorder.BackToHome")
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@Html.AntiForgeryToken()
|
||||
|
||||
<script asp-location="Footer">
|
||||
var poStr = {
|
||||
dsToday : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Today").Text))',
|
||||
dsTomorrow : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Tomorrow").Text))',
|
||||
dsSaving : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Saving").Text))',
|
||||
dsConfirm : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ConfirmButton").Text))',
|
||||
measurable : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.MeasurableBadge").Text))',
|
||||
pricePerPc : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.PricePerPiece").Text))',
|
||||
pieceUnit : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.PieceUnit").Text))',
|
||||
stockLabel : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.StockLabel").Text))',
|
||||
selNone : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.SelectionNone").Text))',
|
||||
selItems : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.SelectionItems").Text))',
|
||||
submitting : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.Submitting").Text))',
|
||||
successMsg : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.SuccessMessage").Text))',
|
||||
errorPfx : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.ErrorPrefix").Text))',
|
||||
huDayNames : ['vas\u00e1rnap','h\u00e9tf\u0151','kedd','szerda','cs\u00fct\u00f6rt\u00f6k','p\u00e9ntek','szombat']
|
||||
};
|
||||
</script>
|
||||
|
||||
<script asp-location="Footer">
|
||||
var selectedDeliveryDate = null;
|
||||
var selectedDeliveryTime = null;
|
||||
var selectedDayLabel = null;
|
||||
var products = []; // loaded from server
|
||||
var quantities = {}; // productId → quantity (0 = not selected)
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────
|
||||
$(document).ready(function () {
|
||||
renderDayButtons();
|
||||
|
||||
$(document).on('click', '.ds-day-btn', function () {
|
||||
$('.ds-day-btn').removeClass('selected');
|
||||
$(this).addClass('selected');
|
||||
selectedDeliveryDate = $(this).data('date');
|
||||
selectedDayLabel = $(this).data('label');
|
||||
checkDeliveryReady();
|
||||
});
|
||||
|
||||
$('#deliveryTimePicker').on('input change', function () {
|
||||
selectedDeliveryTime = $(this).val() || null;
|
||||
checkDeliveryReady();
|
||||
});
|
||||
selectedDeliveryTime = $('#deliveryTimePicker').val() || null;
|
||||
|
||||
$('#deliveryConfirmBtn').click(confirmDelivery);
|
||||
|
||||
$('#deliveryChangeBtn').click(function () {
|
||||
$('#deliveryChip').hide();
|
||||
$('#mainContent').hide();
|
||||
$('#deliveryStep').show();
|
||||
});
|
||||
|
||||
$('#submitPreorderBtn').click(submitPreorder);
|
||||
|
||||
// Restore saved delivery datetime if revisiting
|
||||
$.ajax({
|
||||
url: '@Url.Action("GetDeliveryDateTime", "Preorder")',
|
||||
type: 'GET',
|
||||
success: function (result) {
|
||||
if (!result.success || !result.hasValue) return;
|
||||
selectedDeliveryDate = result.date;
|
||||
selectedDeliveryTime = result.time;
|
||||
var $btn = $('.ds-day-btn[data-date="' + result.date + '"]');
|
||||
if ($btn.length) {
|
||||
$btn.addClass('selected');
|
||||
selectedDayLabel = $btn.data('label');
|
||||
} else {
|
||||
selectedDayLabel = result.date;
|
||||
}
|
||||
$('#deliveryTimePicker').val(result.time);
|
||||
showMainContent();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Delivery step ─────────────────────────────────────────────────────────
|
||||
|
||||
function renderDayButtons() {
|
||||
var container = $('#dayButtons').empty();
|
||||
var today = new Date();
|
||||
for (var i = 0; i < 14; i++) { // 2-week window for preorders
|
||||
var d = new Date(today);
|
||||
d.setDate(today.getDate() + i);
|
||||
var iso = d.toISOString().split('T')[0];
|
||||
var dayName;
|
||||
if (i === 0) dayName = poStr.dsToday;
|
||||
else if (i === 1) dayName = poStr.dsTomorrow;
|
||||
else dayName = poStr.huDayNames[d.getDay()];
|
||||
var dateStr = (d.getMonth() + 1) + '. ' + d.getDate() + '.';
|
||||
var btn = $('<button type="button" class="ds-day-btn">')
|
||||
.attr('data-date', iso)
|
||||
.attr('data-label', dayName + ' ' + dateStr)
|
||||
.html('<span class="ds-day-name">' + dayName + '</span><span class="ds-day-date">' + dateStr + '</span>');
|
||||
container.append(btn);
|
||||
}
|
||||
}
|
||||
|
||||
function checkDeliveryReady() {
|
||||
$('#deliveryConfirmBtn').prop('disabled', !(selectedDeliveryDate && selectedDeliveryTime));
|
||||
}
|
||||
|
||||
function confirmDelivery() {
|
||||
var btn = $('#deliveryConfirmBtn');
|
||||
btn.prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> ' + poStr.dsSaving);
|
||||
|
||||
var deliveryDateTime = selectedDeliveryDate + 'T' + selectedDeliveryTime;
|
||||
$.ajax({
|
||||
url : '@Url.Action("SetDeliveryDateTime", "Preorder")',
|
||||
type: 'POST',
|
||||
data: {
|
||||
deliveryDateTime: deliveryDateTime,
|
||||
__RequestVerificationToken: $('input[name="__RequestVerificationToken"]').val()
|
||||
},
|
||||
success: function (result) {
|
||||
if (!result.success) {
|
||||
alert(poStr.errorPfx + (result.message || ''));
|
||||
btn.prop('disabled', false).html('<i class="fa fa-arrow-right"></i> ' + poStr.dsConfirm);
|
||||
return;
|
||||
}
|
||||
showMainContent();
|
||||
},
|
||||
error: function () {
|
||||
btn.prop('disabled', false).html('<i class="fa fa-arrow-right"></i> ' + poStr.dsConfirm);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showMainContent() {
|
||||
var chipText = selectedDayLabel + ' \u2014 ' + selectedDeliveryTime;
|
||||
$('#deliveryChipText').text(chipText);
|
||||
$('#deliveryStep').hide();
|
||||
$('#deliveryChip').show();
|
||||
$('#mainContent').show();
|
||||
loadProducts();
|
||||
}
|
||||
|
||||
// ── Products ──────────────────────────────────────────────────────────────
|
||||
|
||||
function loadProducts() {
|
||||
$('#productsLoadingState').show();
|
||||
$('#noProductsCard').hide();
|
||||
$('#productSection').hide();
|
||||
|
||||
$.ajax({
|
||||
url : '@Url.Action("GetAvailableProducts", "Preorder")',
|
||||
type: 'GET',
|
||||
success: function (result) {
|
||||
$('#productsLoadingState').hide();
|
||||
if (!result.success || !result.products || result.products.length === 0) {
|
||||
$('#noProductsCard').show();
|
||||
return;
|
||||
}
|
||||
products = result.products;
|
||||
quantities = {};
|
||||
renderProducts();
|
||||
$('#productSection').show();
|
||||
},
|
||||
error: function () {
|
||||
$('#productsLoadingState').hide();
|
||||
$('#noProductsCard').show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderProducts() {
|
||||
var grid = $('#productGrid').empty();
|
||||
$.each(products, function (_, p) {
|
||||
quantities[p.id] = quantities[p.id] || 0;
|
||||
|
||||
var priceHtml = p.isMeasurable
|
||||
? '<span class="measurable-badge"><i class="fa fa-balance-scale"></i> ' + poStr.measurable + '</span>'
|
||||
: (p.unitPrice > 0 ? '<span class="pm-price">' + fmt(p.unitPrice) + ' ' + poStr.pricePerPc + '</span>' : '');
|
||||
|
||||
var card = $('<div>').addClass('product-card po-product-card').attr('data-id', p.id);
|
||||
card.html(
|
||||
'<div class="pc-body">' +
|
||||
'<div class="pc-name"><i class="fa fa-cube"></i> ' + p.name + '</div>' +
|
||||
'<div class="pc-meta">' +
|
||||
'<span class="pc-stock">' + poStr.stockLabel + ' ' + p.stockQuantity + ' ' + poStr.pieceUnit + '</span>' +
|
||||
priceHtml +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="pc-actions">' +
|
||||
'<div class="qty-stepper">' +
|
||||
'<button type="button" class="qty-btn qty-minus" tabindex="-1"><i class="fa fa-minus"></i></button>' +
|
||||
'<input type="number" class="qty-input po-qty" value="0" min="0" max="' + p.stockQuantity + '">' +
|
||||
'<button type="button" class="qty-btn qty-plus" tabindex="-1"><i class="fa fa-plus"></i></button>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
|
||||
card.find('.qty-minus').click(function () {
|
||||
var inp = $(this).siblings('.qty-input');
|
||||
var val = parseInt(inp.val()) || 0;
|
||||
if (val > 0) { inp.val(val - 1); onQtyChange(p.id, val - 1, card); }
|
||||
});
|
||||
card.find('.qty-plus').click(function () {
|
||||
var inp = $(this).siblings('.qty-input');
|
||||
var val = parseInt(inp.val()) || 0;
|
||||
if (val < p.stockQuantity) { inp.val(val + 1); onQtyChange(p.id, val + 1, card); }
|
||||
});
|
||||
card.find('.qty-input').on('input change blur', function () {
|
||||
var val = parseInt($(this).val());
|
||||
if (isNaN(val) || val < 0) val = 0;
|
||||
if (val > p.stockQuantity) val = p.stockQuantity;
|
||||
$(this).val(val);
|
||||
onQtyChange(p.id, val, card);
|
||||
});
|
||||
|
||||
grid.append(card);
|
||||
});
|
||||
}
|
||||
|
||||
function onQtyChange(productId, qty, $card) {
|
||||
quantities[productId] = qty;
|
||||
// Highlight selected cards
|
||||
$card.toggleClass('po-selected', qty > 0);
|
||||
updateSummary();
|
||||
}
|
||||
|
||||
function updateSummary() {
|
||||
var selectedItems = products.filter(function (p) { return (quantities[p.id] || 0) > 0; });
|
||||
var count = selectedItems.length;
|
||||
|
||||
$('#itemCountBadge').text(count);
|
||||
$('#submitPreorderBtn').prop('disabled', count === 0);
|
||||
|
||||
// Selection summary text
|
||||
if (count === 0) {
|
||||
$('#selectionSummary').text(poStr.selNone);
|
||||
} else {
|
||||
$('#selectionSummary').text(count + ' ' + poStr.selItems);
|
||||
}
|
||||
|
||||
// Right panel
|
||||
if (count === 0) {
|
||||
$('#summaryEmpty').show();
|
||||
$('#summaryList, #summaryNote').hide();
|
||||
return;
|
||||
}
|
||||
$('#summaryEmpty').hide();
|
||||
$('#summaryList, #summaryNote').show();
|
||||
|
||||
var list = $('#summaryList').empty();
|
||||
var hasMeasurable = false;
|
||||
$.each(selectedItems, function (_, p) {
|
||||
var qty = quantities[p.id];
|
||||
var priceHtml = p.isMeasurable
|
||||
? '<span class="measurable-badge-sm"><i class="fa fa-balance-scale"></i></span>'
|
||||
: '<strong class="line-total">' + fmt(p.unitPrice * qty) + ' Ft</strong>';
|
||||
if (p.isMeasurable) hasMeasurable = true;
|
||||
|
||||
list.append(
|
||||
'<div class="cart-item">' +
|
||||
'<div class="ci-name">' + p.name + '</div>' +
|
||||
'<div class="ci-details">' +
|
||||
'<span class="ci-qty">' + qty + ' ' + poStr.pieceUnit + '</span>' +
|
||||
priceHtml +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
});
|
||||
|
||||
if (hasMeasurable) $('#summaryNote').show();
|
||||
else $('#summaryNote').hide();
|
||||
}
|
||||
|
||||
// ── Submit ────────────────────────────────────────────────────────────────
|
||||
|
||||
function submitPreorder() {
|
||||
var selectedItems = products.filter(function (p) { return (quantities[p.id] || 0) > 0; });
|
||||
if (!selectedItems.length) return;
|
||||
|
||||
var btn = $('#submitPreorderBtn');
|
||||
btn.prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> ' + poStr.submitting);
|
||||
|
||||
var payload = {
|
||||
deliveryDateTime : selectedDeliveryDate + 'T' + selectedDeliveryTime,
|
||||
customerNote : $('#customerNote').val().trim(),
|
||||
items : selectedItems.map(function (p) {
|
||||
return { productId: p.id, quantity: quantities[p.id] };
|
||||
})
|
||||
};
|
||||
|
||||
$.ajax({
|
||||
url : '@Url.Action("PlacePreorder", "Preorder")',
|
||||
type : 'POST',
|
||||
contentType: 'application/json',
|
||||
data : JSON.stringify(payload),
|
||||
headers : { 'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').val() },
|
||||
success : function (result) {
|
||||
if (result.success) {
|
||||
$('#mainContent, #deliveryChip').hide();
|
||||
$('#successMessage').text(poStr.successMsg.replace('{0}', result.preorderId));
|
||||
$('#successState').show();
|
||||
} else {
|
||||
alert(poStr.errorPfx + (result.message || ''));
|
||||
btn.prop('disabled', false)
|
||||
.html('<i class="fa fa-paper-plane"></i> @Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.SubmitButton").Text))');
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
alert(poStr.errorPfx);
|
||||
btn.prop('disabled', false)
|
||||
.html('<i class="fa fa-paper-plane"></i> @Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.SubmitButton").Text))');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function fmt(val) {
|
||||
if (!val) return '—';
|
||||
return Math.round(val).toLocaleString('hu-HU');
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
@using System.Text.Encodings.Web
|
||||
@{
|
||||
Layout = "_Root";
|
||||
ViewBag.Title = T("Plugins.Misc.FruitBankPlugin.QuickOrder.PageTitle").Text;
|
||||
|
|
@ -7,144 +8,196 @@
|
|||
|
||||
<div class="quick-order-page">
|
||||
|
||||
<!-- Full-width Search Bar -->
|
||||
<div class="qo-search-bar-wrapper">
|
||||
<div class="qo-search-bar">
|
||||
<div class="search-input-group">
|
||||
<button id="recordBtn" class="mic-btn" title="@T("Plugins.Misc.FruitBankPlugin.QuickOrder.MicButtonTitle")">
|
||||
<i class="fa fa-microphone"></i>
|
||||
</button>
|
||||
<button id="stopBtn" class="mic-btn mic-btn-recording" style="display:none;" title="@T("Plugins.Misc.FruitBankPlugin.QuickOrder.StopButtonTitle")">
|
||||
<i class="fa fa-stop"></i>
|
||||
<span class="mic-pulse"></span>
|
||||
</button>
|
||||
<input type="text"
|
||||
id="searchInput"
|
||||
class="qo-input"
|
||||
placeholder="@T("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchPlaceholder")"
|
||||
onkeypress="if(event.key==='Enter') submitTextSearch()">
|
||||
<button class="qo-search-btn" onclick="submitTextSearch()">
|
||||
<i class="fa fa-search"></i> @T("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchButton")
|
||||
</button>
|
||||
<!-- ── STEP 1: Delivery date + time picker ───────────────────────────── -->
|
||||
<div id="deliveryStep" class="qo-delivery-step">
|
||||
<div class="ds-header">
|
||||
<i class="fa fa-calendar"></i>
|
||||
<div>
|
||||
<div class="ds-title">@T("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Title")</div>
|
||||
<div class="ds-subtitle">@T("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Subtitle")</div>
|
||||
</div>
|
||||
<div id="recordingStatus" class="recording-status-bar" style="display:none;">
|
||||
<span id="statusText">@T("Plugins.Misc.FruitBankPlugin.QuickOrder.ListeningStatus")</span>
|
||||
<div class="volume-bar-container">
|
||||
<div class="volume-bar volume-bar-silent"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ds-body">
|
||||
<div class="ds-section-label">@T("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.DayLabel")</div>
|
||||
<div class="ds-day-buttons" id="dayButtons"></div>
|
||||
|
||||
<div class="ds-section-label" style="margin-top:20px;">
|
||||
@T("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.TimeLabel")
|
||||
</div>
|
||||
<div class="ds-time-wrapper">
|
||||
<input type="time" id="deliveryTimePicker" class="ds-time-input" value="08:00" min="05:00" max="22:00" />
|
||||
<span class="ds-time-hint">@T("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.TimeHint")</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ds-footer">
|
||||
<button type="button" class="ds-confirm-btn" id="deliveryConfirmBtn" disabled>
|
||||
<i class="fa fa-arrow-right"></i> @T("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ConfirmButton")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Two-column layout -->
|
||||
<div class="qo-layout">
|
||||
|
||||
<!-- LEFT: Products -->
|
||||
<div class="qo-products-panel">
|
||||
|
||||
<div id="transcribedCard" class="result-card" style="display:none;">
|
||||
<div class="result-label"><i class="fa fa-microphone"></i> @T("Plugins.Misc.FruitBankPlugin.QuickOrder.TranscribedLabel")</div>
|
||||
<div id="transcribedText" class="result-text"></div>
|
||||
</div>
|
||||
|
||||
<div id="noResultsCard" class="no-results-card" style="display:none;">
|
||||
<i class="fa fa-search"></i>
|
||||
<p>@T("Plugins.Misc.FruitBankPlugin.QuickOrder.NoResultsText")</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div id="productsLoadingState" class="products-empty-state">
|
||||
<i class="fa fa-spinner fa-spin"></i>
|
||||
<p>@T("Plugins.Misc.FruitBankPlugin.QuickOrder.LoadingProducts")</p>
|
||||
</div>
|
||||
|
||||
<div id="productMatchesCard" style="display:none;">
|
||||
<div class="matches-label">
|
||||
<i class="fa fa-cubes"></i> <span id="matchesLabelText">@T("Plugins.Misc.FruitBankPlugin.QuickOrder.AllProductsLabel")</span>
|
||||
@T("Plugins.Misc.FruitBankPlugin.QuickOrder.QuantityHint")
|
||||
</div>
|
||||
<div id="productButtons" class="product-grid"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: Cart -->
|
||||
<div class="qo-cart-panel">
|
||||
|
||||
<div class="qo-section-title">
|
||||
<i class="fa fa-shopping-basket"></i> @T("Plugins.Misc.FruitBankPlugin.QuickOrder.CartTitle")
|
||||
<span id="cartItemCount" class="cart-count-badge">0</span>
|
||||
</div>
|
||||
|
||||
<div id="cartEmptyState" class="cart-empty">
|
||||
<i class="fa fa-shopping-basket"></i>
|
||||
<p>@T("Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine1")<br>@T("Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine2")</p>
|
||||
</div>
|
||||
|
||||
<div id="cartItemsList" class="cart-items-list" style="display:none;"></div>
|
||||
|
||||
<div id="cartTotalRow" class="cart-total-row" style="display:none;">
|
||||
<div class="cart-total-note">
|
||||
<i class="fa fa-info-circle"></i>
|
||||
<small>@T("Plugins.Misc.FruitBankPlugin.QuickOrder.CartTotalNote")</small>
|
||||
</div>
|
||||
<div class="cart-total">
|
||||
<span>@T("Plugins.Misc.FruitBankPlugin.QuickOrder.EstimatedTotal")</span>
|
||||
<strong id="cartTotalAmount">0 Ft</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cartActions" style="display:none;">
|
||||
<a href="@Url.Action("Cart", "ShoppingCart")" class="btn-checkout">
|
||||
<i class="fa fa-shopping-cart"></i> @T("Plugins.Misc.FruitBankPlugin.QuickOrder.CheckoutButton")
|
||||
</a>
|
||||
<a href="@Url.Action("Cart", "ShoppingCart")" class="btn-view-cart">
|
||||
<i class="fa fa-eye"></i> @T("Plugins.Misc.FruitBankPlugin.QuickOrder.ViewCartButton")
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<!-- ── Delivery chip (collapsed state) ──────────────────────────────── -->
|
||||
<div id="deliveryChip" class="qo-delivery-chip" style="display:none;">
|
||||
<i class="fa fa-calendar-check-o"></i>
|
||||
<span class="dc-label">@T("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ChangeLabel")</span>
|
||||
<strong id="deliveryChipText"></strong>
|
||||
<button type="button" class="dc-change-btn" id="deliveryChangeBtn">
|
||||
<i class="fa fa-pencil"></i> @T("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ChangeButton")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Search + layout (locked until delivery confirmed) ────────────── -->
|
||||
<div id="mainContent" style="display:none;">
|
||||
|
||||
<!-- Full-width Search Bar -->
|
||||
<div class="qo-search-bar-wrapper">
|
||||
<div class="qo-search-bar">
|
||||
<div class="search-input-group">
|
||||
<button id="recordBtn" class="mic-btn" title="@T("Plugins.Misc.FruitBankPlugin.QuickOrder.MicButtonTitle")">
|
||||
<i class="fa fa-microphone"></i>
|
||||
</button>
|
||||
<button id="stopBtn" class="mic-btn mic-btn-recording" style="display:none;" title="@T("Plugins.Misc.FruitBankPlugin.QuickOrder.StopButtonTitle")">
|
||||
<i class="fa fa-stop"></i>
|
||||
<span class="mic-pulse"></span>
|
||||
</button>
|
||||
<input type="text"
|
||||
id="searchInput"
|
||||
class="qo-input"
|
||||
placeholder="@T("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchPlaceholder")"
|
||||
onkeypress="if(event.key==='Enter') submitTextSearch()">
|
||||
<button class="qo-search-btn" onclick="submitTextSearch()">
|
||||
<i class="fa fa-search"></i> @T("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchButton")
|
||||
</button>
|
||||
</div>
|
||||
<div id="recordingStatus" class="recording-status-bar" style="display:none;">
|
||||
<span id="statusText">@T("Plugins.Misc.FruitBankPlugin.QuickOrder.ListeningStatus")</span>
|
||||
<div class="volume-bar-container">
|
||||
<div class="volume-bar volume-bar-silent"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Two-column layout -->
|
||||
<div class="qo-layout">
|
||||
|
||||
<!-- LEFT: Products -->
|
||||
<div class="qo-products-panel">
|
||||
|
||||
<div id="transcribedCard" class="result-card" style="display:none;">
|
||||
<div class="result-label"><i class="fa fa-microphone"></i> @T("Plugins.Misc.FruitBankPlugin.QuickOrder.TranscribedLabel")</div>
|
||||
<div id="transcribedText" class="result-text"></div>
|
||||
</div>
|
||||
|
||||
<div id="noResultsCard" class="no-results-card" style="display:none;">
|
||||
<i class="fa fa-search"></i>
|
||||
<p>@T("Plugins.Misc.FruitBankPlugin.QuickOrder.NoResultsText")</p>
|
||||
</div>
|
||||
|
||||
<div id="productsLoadingState" class="products-empty-state">
|
||||
<i class="fa fa-spinner fa-spin"></i>
|
||||
<p>@T("Plugins.Misc.FruitBankPlugin.QuickOrder.LoadingProducts")</p>
|
||||
</div>
|
||||
|
||||
<div id="productMatchesCard" style="display:none;">
|
||||
<div class="matches-label">
|
||||
<i class="fa fa-cubes"></i> <span id="matchesLabelText">@T("Plugins.Misc.FruitBankPlugin.QuickOrder.AllProductsLabel")</span>
|
||||
@T("Plugins.Misc.FruitBankPlugin.QuickOrder.QuantityHint")
|
||||
</div>
|
||||
<div id="productButtons" class="product-grid"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: Cart -->
|
||||
<div class="qo-cart-panel">
|
||||
|
||||
<div class="qo-section-title">
|
||||
<i class="fa fa-shopping-basket"></i> @T("Plugins.Misc.FruitBankPlugin.QuickOrder.CartTitle")
|
||||
<span id="cartItemCount" class="cart-count-badge">0</span>
|
||||
</div>
|
||||
|
||||
<div id="cartEmptyState" class="cart-empty">
|
||||
<i class="fa fa-shopping-basket"></i>
|
||||
<p>@T("Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine1")<br>@T("Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine2")</p>
|
||||
</div>
|
||||
|
||||
<div id="cartItemsList" class="cart-items-list" style="display:none;"></div>
|
||||
|
||||
<div id="cartTotalRow" class="cart-total-row" style="display:none;">
|
||||
<div class="cart-total-note">
|
||||
<i class="fa fa-info-circle"></i>
|
||||
<small>@T("Plugins.Misc.FruitBankPlugin.QuickOrder.CartTotalNote")</small>
|
||||
</div>
|
||||
<div class="cart-total">
|
||||
<span>@T("Plugins.Misc.FruitBankPlugin.QuickOrder.EstimatedTotal")</span>
|
||||
<strong id="cartTotalAmount">0 Ft</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cartActions" style="display:none;">
|
||||
<a href="@Url.Action("Cart", "ShoppingCart")" class="btn-checkout">
|
||||
<i class="fa fa-shopping-cart"></i> @T("Plugins.Misc.FruitBankPlugin.QuickOrder.CheckoutButton")
|
||||
</a>
|
||||
<a href="@Url.Action("Cart", "ShoppingCart")" class="btn-view-cart">
|
||||
<i class="fa fa-eye"></i> @T("Plugins.Misc.FruitBankPlugin.QuickOrder.ViewCartButton")
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /#mainContent -->
|
||||
|
||||
</div><!-- /.quick-order-page -->
|
||||
|
||||
@Html.AntiForgeryToken()
|
||||
|
||||
@* JS string bundle — Razor renders these once so JS never contains raw Hungarian *@
|
||||
@* JS string bundle *@
|
||||
<script asp-location="Footer">
|
||||
var qoStr = {
|
||||
allProducts: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.AllProductsLabel").Text))',
|
||||
searchResults: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchResultsLabel").Text))',
|
||||
searchPlaceholder: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchPlaceholder").Text))',
|
||||
activeRecordingPlaceholder: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.ActiveRecordingPlaceholder").Text))',
|
||||
listeningStatus: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.ListeningStatus").Text))',
|
||||
browserNotSupported: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.BrowserNotSupported").Text))',
|
||||
micAccessError: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.MicAccessError").Text))',
|
||||
micPermissionDenied: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.MicPermissionDenied").Text))',
|
||||
micNotFound: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.MicNotFound").Text))',
|
||||
calibrating: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.Calibrating").Text))',
|
||||
processing: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.Processing").Text))',
|
||||
recordingFailed: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.RecordingFailed").Text))',
|
||||
volumeHigh: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeHigh").Text))',
|
||||
volumeSpeaking: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeSpeaking").Text))',
|
||||
volumeLouder: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeLouder").Text))',
|
||||
searching: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.Searching").Text))',
|
||||
enterProducts: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.EnterProducts").Text))',
|
||||
searchError: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchError").Text))',
|
||||
audioError: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.AudioError").Text))',
|
||||
addToCartError: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.AddToCartError").Text))',
|
||||
errorPrefix: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.ErrorPrefix").Text))',
|
||||
measurableBadge: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.MeasurableBadge").Text))',
|
||||
stockLabel: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLabel").Text))',
|
||||
stockLimitedPrefix: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLimitedPrefix").Text))',
|
||||
stockLimitedSuffix: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLimitedSuffix").Text))',
|
||||
pieceUnit: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.PieceUnit").Text))',
|
||||
pricePerPiece: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.PricePerPiece").Text))',
|
||||
addToCartTitle: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.AddToCartTitle").Text))',
|
||||
addedToCart: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.AddedToCart").Text))'
|
||||
allProducts: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.AllProductsLabel").Text))',
|
||||
searchResults: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchResultsLabel").Text))',
|
||||
searchPlaceholder: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchPlaceholder").Text))',
|
||||
activeRecordingPlaceholder: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.ActiveRecordingPlaceholder").Text))',
|
||||
listeningStatus: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.ListeningStatus").Text))',
|
||||
browserNotSupported: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.BrowserNotSupported").Text))',
|
||||
micAccessError: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.MicAccessError").Text))',
|
||||
micPermissionDenied: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.MicPermissionDenied").Text))',
|
||||
micNotFound: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.MicNotFound").Text))',
|
||||
calibrating: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.Calibrating").Text))',
|
||||
processing: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.Processing").Text))',
|
||||
recordingFailed: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.RecordingFailed").Text))',
|
||||
volumeHigh: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeHigh").Text))',
|
||||
volumeSpeaking: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeSpeaking").Text))',
|
||||
volumeLouder: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeLouder").Text))',
|
||||
searching: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.Searching").Text))',
|
||||
enterProducts: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.EnterProducts").Text))',
|
||||
searchError: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchError").Text))',
|
||||
audioError: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.AudioError").Text))',
|
||||
addToCartError: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.AddToCartError").Text))',
|
||||
errorPrefix: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.ErrorPrefix").Text))',
|
||||
measurableBadge: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.MeasurableBadge").Text))',
|
||||
stockLabel: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLabel").Text))',
|
||||
stockLimitedPrefix: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLimitedPrefix").Text))',
|
||||
stockLimitedSuffix: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLimitedSuffix").Text))',
|
||||
pieceUnit: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.PieceUnit").Text))',
|
||||
pricePerPiece: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.PricePerPiece").Text))',
|
||||
addToCartTitle: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.AddToCartTitle").Text))',
|
||||
addedToCart: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.AddedToCart").Text))',
|
||||
dsToday: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Today").Text))',
|
||||
dsTomorrow: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Tomorrow").Text))',
|
||||
dsSaving: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Saving").Text))',
|
||||
huDayNames: ['vas\u00e1rnap','h\u00e9tf\u0151','kedd','szerda','cs\u00fct\u00f6rt\u00f6k','p\u00e9ntek','szombat']
|
||||
};
|
||||
</script>
|
||||
|
||||
<script asp-location="Footer">
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
var selectedDeliveryDate = null; // ISO date string e.g. "2026-04-16"
|
||||
var selectedDeliveryTime = null; // e.g. "08:00"
|
||||
var selectedDayLabel = null; // human-readable day label for chip
|
||||
|
||||
var mediaRecorder = null;
|
||||
var audioChunks = [];
|
||||
var isRecording = false;
|
||||
|
|
@ -164,13 +217,134 @@ var qoStr = {
|
|||
volumeHistorySize: 10
|
||||
};
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────
|
||||
$(document).ready(function () {
|
||||
renderDayButtons();
|
||||
|
||||
$(document).on('click', '.ds-day-btn', function () {
|
||||
$('.ds-day-btn').removeClass('selected');
|
||||
$(this).addClass('selected');
|
||||
selectedDeliveryDate = $(this).data('date');
|
||||
selectedDayLabel = $(this).data('label');
|
||||
checkDeliveryReady();
|
||||
});
|
||||
|
||||
// Time picker: any valid time enables the confirm button
|
||||
$('#deliveryTimePicker').on('input change', function () {
|
||||
selectedDeliveryTime = $(this).val() || null;
|
||||
checkDeliveryReady();
|
||||
});
|
||||
// Initialise with the default value already set in the input
|
||||
selectedDeliveryTime = $('#deliveryTimePicker').val() || null;
|
||||
|
||||
$('#deliveryConfirmBtn').click(confirmDelivery);
|
||||
$('#deliveryChangeBtn').click(function () {
|
||||
$('#deliveryChip').hide();
|
||||
$('#mainContent').hide();
|
||||
$('#deliveryStep').show();
|
||||
});
|
||||
|
||||
$('#recordBtn').click(startRecording);
|
||||
$('#stopBtn').click(function () { stopRecording(false); });
|
||||
|
||||
loadCart();
|
||||
loadAllProducts();
|
||||
|
||||
// ── Restore previously saved delivery datetime (e.g. new tab / page refresh) ──
|
||||
$.ajax({
|
||||
url: '@Url.Action("GetDeliveryDateTime", "QuickOrder")',
|
||||
type: 'GET',
|
||||
success: function (result) {
|
||||
if (!result.success || !result.hasValue) return;
|
||||
|
||||
// Restore state variables
|
||||
selectedDeliveryDate = result.date; // e.g. "2026-04-17"
|
||||
selectedDeliveryTime = result.time; // e.g. "08:00"
|
||||
|
||||
// Mark the correct day button as selected
|
||||
var $btn = $('.ds-day-btn[data-date="' + result.date + '"]');
|
||||
if ($btn.length) {
|
||||
$btn.addClass('selected');
|
||||
selectedDayLabel = $btn.data('label');
|
||||
} else {
|
||||
// The saved date is beyond the 7-day window shown — just use the date string
|
||||
selectedDayLabel = result.date;
|
||||
}
|
||||
|
||||
// Restore the time picker value
|
||||
$('#deliveryTimePicker').val(result.time);
|
||||
|
||||
// Skip the step and go straight to the product list
|
||||
var chipText = selectedDayLabel + ' \u2014 ' + result.time;
|
||||
$('#deliveryChipText').text(chipText);
|
||||
$('#deliveryStep').hide();
|
||||
$('#deliveryChip').show();
|
||||
$('#mainContent').show();
|
||||
loadAllProducts();
|
||||
}
|
||||
// On error: silently leave the step visible — user picks again
|
||||
});
|
||||
});
|
||||
|
||||
// ── Delivery step ─────────────────────────────────────────────────────────
|
||||
|
||||
function renderDayButtons() {
|
||||
var container = $('#dayButtons').empty();
|
||||
var today = new Date();
|
||||
for (var i = 0; i < 7; i++) {
|
||||
var d = new Date(today);
|
||||
d.setDate(today.getDate() + i);
|
||||
var iso = d.toISOString().split('T')[0];
|
||||
var dayName;
|
||||
if (i === 0) dayName = qoStr.dsToday;
|
||||
else if (i === 1) dayName = qoStr.dsTomorrow;
|
||||
else dayName = qoStr.huDayNames[d.getDay()];
|
||||
var dateStr = (d.getMonth() + 1) + '. ' + d.getDate() + '.';
|
||||
var btn = $('<button type="button" class="ds-day-btn">')
|
||||
.attr('data-date', iso)
|
||||
.attr('data-label', dayName + ' ' + dateStr)
|
||||
.html('<span class="ds-day-name">' + dayName + '</span><span class="ds-day-date">' + dateStr + '</span>');
|
||||
container.append(btn);
|
||||
}
|
||||
}
|
||||
|
||||
function checkDeliveryReady() {
|
||||
$('#deliveryConfirmBtn').prop('disabled', !(selectedDeliveryDate && selectedDeliveryTime));
|
||||
}
|
||||
|
||||
function confirmDelivery() {
|
||||
var btn = $('#deliveryConfirmBtn');
|
||||
btn.prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> ' + qoStr.dsSaving);
|
||||
|
||||
// Combine date + time into ISO datetime string e.g. "2026-04-16T08:00"
|
||||
var deliveryDateTime = selectedDeliveryDate + 'T' + selectedDeliveryTime;
|
||||
|
||||
$.ajax({
|
||||
url: '@Url.Action("SetDeliveryDateTime", "QuickOrder")',
|
||||
type: 'POST',
|
||||
data: {
|
||||
deliveryDateTime: deliveryDateTime,
|
||||
__RequestVerificationToken: $('input[name="__RequestVerificationToken"]').val()
|
||||
},
|
||||
success: function (result) {
|
||||
if (!result.success) {
|
||||
alert(qoStr.errorPrefix + (result.message || ''));
|
||||
btn.prop('disabled', false).html('<i class="fa fa-arrow-right"></i> @Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ConfirmButton").Text))');
|
||||
return;
|
||||
}
|
||||
var chipText = selectedDayLabel + ' \u2014 ' + selectedDeliveryTime;
|
||||
$('#deliveryChipText').text(chipText);
|
||||
$('#deliveryStep').hide();
|
||||
$('#deliveryChip').show();
|
||||
$('#mainContent').show();
|
||||
loadAllProducts();
|
||||
},
|
||||
error: function () {
|
||||
alert(qoStr.errorPrefix);
|
||||
btn.prop('disabled', false).html('<i class="fa fa-arrow-right"></i> @Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ConfirmButton").Text))');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Product list ──────────────────────────────────────────────────────────
|
||||
|
||||
function loadAllProducts() {
|
||||
|
|
@ -183,6 +357,7 @@ var qoStr = {
|
|||
$.ajax({
|
||||
url: '@Url.Action("GetAllProducts", "QuickOrder")',
|
||||
type: 'GET',
|
||||
data: { deliveryDate: selectedDeliveryDate, deliveryTime: selectedDeliveryTime },
|
||||
success: function (result) {
|
||||
$('#productsLoadingState').hide();
|
||||
if (result.success && result.products && result.products.length > 0) {
|
||||
|
|
@ -233,11 +408,7 @@ var qoStr = {
|
|||
if (audioContext) { audioContext.close(); audioContext = null; }
|
||||
analyser = null;
|
||||
isRecording = false;
|
||||
if (blob.size === 0) {
|
||||
alert(qoStr.recordingFailed);
|
||||
resetRecordingUI();
|
||||
return;
|
||||
}
|
||||
if (blob.size === 0) { alert(qoStr.recordingFailed); resetRecordingUI(); return; }
|
||||
processAudio(blob, mimeType);
|
||||
});
|
||||
|
||||
|
|
@ -337,6 +508,8 @@ var qoStr = {
|
|||
function processAudio(blob, mimeType) {
|
||||
var formData = new FormData();
|
||||
formData.append('audioFile', blob, 'recording.webm');
|
||||
formData.append('deliveryDate', selectedDeliveryDate || '');
|
||||
formData.append('deliveryTime', selectedDeliveryTime || '');
|
||||
formData.append('__RequestVerificationToken', $('input[name="__RequestVerificationToken"]').val());
|
||||
$.ajax({
|
||||
url: '@Url.Action("TranscribeAndSearch", "QuickOrder")',
|
||||
|
|
@ -371,7 +544,12 @@ var qoStr = {
|
|||
$.ajax({
|
||||
url: '@Url.Action("SearchProducts", "QuickOrder")',
|
||||
type: 'POST',
|
||||
data: { text: text, __RequestVerificationToken: $('input[name="__RequestVerificationToken"]').val() },
|
||||
data: {
|
||||
text: text,
|
||||
deliveryDate: selectedDeliveryDate,
|
||||
deliveryTime: selectedDeliveryTime,
|
||||
__RequestVerificationToken: $('input[name="__RequestVerificationToken"]').val()
|
||||
},
|
||||
success: function (result) { $('#recordingStatus').hide(); handleSearchResult(result); },
|
||||
error: function () { $('#recordingStatus').hide(); alert(qoStr.searchError); }
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,203 @@
|
|||
/*
|
||||
* Preorder page — supplemental styles
|
||||
* Inherits all base styles from quick-order.css
|
||||
*/
|
||||
|
||||
/* ── Day button window: 14-day grid ──────────────────────────────────── */
|
||||
.ds-day-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── Info banner ──────────────────────────────────────────────────────── */
|
||||
.po-info-banner {
|
||||
background: #fff8ee;
|
||||
border: 1px solid #f4a236;
|
||||
border-left: 4px solid #f4a236;
|
||||
border-radius: 8px;
|
||||
padding: 12px 18px;
|
||||
font-size: 14px;
|
||||
color: #1a3c22;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.po-info-banner .fa {
|
||||
color: #f4a236;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* ── Product card: qty starts at 0, selected state ────────────────────── */
|
||||
.po-product-card .qty-input {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.po-product-card.po-selected {
|
||||
border-color: #2d7a3a;
|
||||
box-shadow: 0 2px 10px rgba(45, 122, 58, 0.12);
|
||||
}
|
||||
|
||||
.po-product-card.po-selected .qty-input {
|
||||
color: #1a3c22;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ── Note section ─────────────────────────────────────────────────────── */
|
||||
.po-note-section {
|
||||
margin-top: 24px;
|
||||
background: #fff;
|
||||
border: 1px solid #dde8da;
|
||||
border-radius: 8px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.po-note-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #2d7a3a;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.po-note-label .fa {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.po-note-input {
|
||||
width: 100%;
|
||||
border: 1px solid #dde8da;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
color: #2c2c2c;
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.po-note-input:focus {
|
||||
outline: none;
|
||||
border-color: #2d7a3a;
|
||||
}
|
||||
|
||||
/* ── Submit row ───────────────────────────────────────────────────────── */
|
||||
.po-submit-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #dde8da;
|
||||
}
|
||||
|
||||
.po-selection-summary {
|
||||
font-size: 13px;
|
||||
color: #6b7c6e;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.po-submit-btn {
|
||||
padding: 12px 28px;
|
||||
background: #2d7a3a;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: background 0.18s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.po-submit-btn:hover:not(:disabled) {
|
||||
background: #1a3c22;
|
||||
}
|
||||
|
||||
.po-submit-btn:disabled {
|
||||
background: #dde8da;
|
||||
color: #6b7c6e;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.po-submit-btn .fa {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ── Success state ────────────────────────────────────────────────────── */
|
||||
.po-success-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
background: #fff;
|
||||
border: 1px solid #dde8da;
|
||||
border-radius: 8px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.po-success-icon {
|
||||
font-size: 64px;
|
||||
color: #2d7a3a;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.po-success-state h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
color: #1a3c22;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.po-success-state p {
|
||||
font-size: 15px;
|
||||
color: #6b7c6e;
|
||||
margin-bottom: 28px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.po-back-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 28px;
|
||||
background: #f5f7f2;
|
||||
color: #2d7a3a !important;
|
||||
border: 1px solid #dde8da;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: background 0.18s;
|
||||
}
|
||||
|
||||
.po-back-btn:hover {
|
||||
background: #dde8da;
|
||||
}
|
||||
|
||||
/* ── Responsive ───────────────────────────────────────────────────────── */
|
||||
@media (max-width: 600px) {
|
||||
.po-submit-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.po-submit-btn {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,230 @@
|
|||
color: #2c2c2c;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
DELIVERY STEP CARD
|
||||
───────────────────────────────────────────── */
|
||||
.qo-delivery-step {
|
||||
background: #fff;
|
||||
border: 1px solid #dde8da;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(45, 122, 58, 0.10);
|
||||
margin-bottom: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ds-header {
|
||||
background: #1a3c22;
|
||||
color: #fff;
|
||||
padding: 18px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.ds-header > .fa {
|
||||
font-size: 28px;
|
||||
color: #f4a236;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ds-title {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.ds-subtitle {
|
||||
font-size: 13px;
|
||||
color: rgba(255,255,255,0.7);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.ds-body {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.ds-section-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: #6b7c6e;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Day buttons */
|
||||
.ds-day-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ds-day-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 10px 18px;
|
||||
background: #f5f7f2;
|
||||
border: 2px solid #dde8da;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
min-width: 76px;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
}
|
||||
|
||||
.ds-day-btn:hover {
|
||||
border-color: #2d7a3a;
|
||||
background: #eef4eb;
|
||||
}
|
||||
|
||||
.ds-day-btn.selected {
|
||||
border-color: #2d7a3a;
|
||||
background: #2d7a3a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ds-day-name {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.ds-day-date {
|
||||
font-size: 11px;
|
||||
opacity: 0.75;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Time picker */
|
||||
.ds-time-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.ds-time-input {
|
||||
height: 48px;
|
||||
padding: 0 16px;
|
||||
font-size: 22px;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-weight: 700;
|
||||
color: #1a3c22;
|
||||
background: #f5f7f2;
|
||||
border: 2px solid #dde8da;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.ds-time-input:focus {
|
||||
outline: none;
|
||||
border-color: #2d7a3a;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.ds-time-hint {
|
||||
font-size: 13px;
|
||||
color: #6b7c6e;
|
||||
}
|
||||
|
||||
/* Confirm button */
|
||||
.ds-footer {
|
||||
padding: 16px 24px 20px;
|
||||
border-top: 1px solid #f5f7f2;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.ds-confirm-btn {
|
||||
padding: 12px 32px;
|
||||
background: #2d7a3a;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: background 0.18s;
|
||||
}
|
||||
|
||||
.ds-confirm-btn:hover:not(:disabled) {
|
||||
background: #1a3c22;
|
||||
}
|
||||
|
||||
.ds-confirm-btn:disabled {
|
||||
background: #dde8da;
|
||||
color: #6b7c6e;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ds-confirm-btn .fa {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
DELIVERY CHIP (collapsed state)
|
||||
───────────────────────────────────────────── */
|
||||
.qo-delivery-chip {
|
||||
background: #1a3c22;
|
||||
border-radius: 8px;
|
||||
padding: 10px 18px;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.qo-delivery-chip > .fa {
|
||||
color: #f4a236;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dc-label {
|
||||
color: rgba(255,255,255,0.65);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#deliveryChipText {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dc-change-btn {
|
||||
margin-left: auto;
|
||||
padding: 5px 14px;
|
||||
background: rgba(255,255,255,0.12);
|
||||
border: 1px solid rgba(255,255,255,0.25);
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dc-change-btn:hover {
|
||||
background: rgba(244,162,54,0.25);
|
||||
border-color: #f4a236;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
SEARCH BAR
|
||||
───────────────────────────────────────────── */
|
||||
|
|
@ -43,7 +267,6 @@
|
|||
gap: 0;
|
||||
}
|
||||
|
||||
/* Mic button */
|
||||
.mic-btn {
|
||||
flex-shrink: 0;
|
||||
width: 46px;
|
||||
|
|
@ -78,11 +301,8 @@
|
|||
50% { box-shadow: 0 0 0 8px rgba(244,162,54,0); }
|
||||
}
|
||||
|
||||
.mic-pulse {
|
||||
display: none;
|
||||
}
|
||||
.mic-pulse { display: none; }
|
||||
|
||||
/* Search text input */
|
||||
.qo-input {
|
||||
flex: 1;
|
||||
height: 46px;
|
||||
|
|
@ -103,11 +323,8 @@
|
|||
z-index: 1;
|
||||
}
|
||||
|
||||
.qo-input::placeholder {
|
||||
color: #6b7c6e;
|
||||
}
|
||||
.qo-input::placeholder { color: #6b7c6e; }
|
||||
|
||||
/* Search button */
|
||||
.qo-search-btn {
|
||||
flex-shrink: 0;
|
||||
height: 46px;
|
||||
|
|
@ -130,7 +347,6 @@
|
|||
border-color: #1a3c22;
|
||||
}
|
||||
|
||||
/* Recording status bar */
|
||||
.recording-status-bar {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
|
|
@ -183,8 +399,6 @@
|
|||
/* ─────────────────────────────────────────────
|
||||
PRODUCTS PANEL (LEFT)
|
||||
───────────────────────────────────────────── */
|
||||
|
||||
/* "I heard" transcription card */
|
||||
.result-card {
|
||||
background: #fff;
|
||||
border: 1px solid #dde8da;
|
||||
|
|
@ -208,7 +422,6 @@
|
|||
color: #2c2c2c;
|
||||
}
|
||||
|
||||
/* No results / empty */
|
||||
.no-results-card {
|
||||
background: #fff;
|
||||
border: 1px dashed #dde8da;
|
||||
|
|
@ -226,7 +439,6 @@
|
|||
display: block;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.products-empty-state {
|
||||
background: #fff;
|
||||
border: 1px solid #dde8da;
|
||||
|
|
@ -243,7 +455,6 @@
|
|||
display: block;
|
||||
}
|
||||
|
||||
/* Section header above product list */
|
||||
.matches-label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
|
|
@ -261,7 +472,6 @@
|
|||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Group label (search results grouped by keyword) */
|
||||
.group-label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
|
|
@ -306,7 +516,6 @@
|
|||
border-left: 3px solid #f4a236;
|
||||
}
|
||||
|
||||
/* Body — grows, holds name + meta inline */
|
||||
.pc-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
|
@ -316,7 +525,6 @@
|
|||
gap: 6px 14px;
|
||||
}
|
||||
|
||||
/* Product name */
|
||||
.pc-name {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
|
|
@ -336,7 +544,6 @@
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Meta row — stock + price inline */
|
||||
.pc-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -366,7 +573,6 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.stock-warning-badge {
|
||||
font-size: 11px;
|
||||
color: #e8734a;
|
||||
|
|
@ -388,7 +594,6 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Actions — fixed width, right-aligned */
|
||||
.pc-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -396,7 +601,6 @@
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Qty stepper */
|
||||
.qty-stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -420,9 +624,7 @@
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.qty-btn:hover {
|
||||
background: #dde8da;
|
||||
}
|
||||
.qty-btn:hover { background: #dde8da; }
|
||||
|
||||
.qty-input {
|
||||
width: 48px;
|
||||
|
|
@ -439,11 +641,8 @@
|
|||
}
|
||||
|
||||
.qty-input::-webkit-outer-spin-button,
|
||||
.qty-input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
.qty-input::-webkit-inner-spin-button { -webkit-appearance: none; }
|
||||
|
||||
/* Add to cart button */
|
||||
.pc-add-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
|
|
@ -460,20 +659,9 @@
|
|||
transition: background 0.18s, transform 0.12s;
|
||||
}
|
||||
|
||||
.pc-add-btn:hover {
|
||||
background: #1a3c22;
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.pc-add-btn:disabled {
|
||||
background: #dde8da;
|
||||
cursor: default;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.pc-add-btn.added {
|
||||
background: #8cb63c;
|
||||
}
|
||||
.pc-add-btn:hover { background: #1a3c22; transform: scale(1.06); }
|
||||
.pc-add-btn:disabled { background: #dde8da; cursor: default; transform: none; }
|
||||
.pc-add-btn.added { background: #8cb63c; }
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
CART PANEL (RIGHT)
|
||||
|
|
@ -500,10 +688,7 @@
|
|||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.qo-section-title .fa {
|
||||
color: #f4a236;
|
||||
font-size: 17px;
|
||||
}
|
||||
.qo-section-title .fa { color: #f4a236; font-size: 17px; }
|
||||
|
||||
.cart-count-badge {
|
||||
background: #f4a236;
|
||||
|
|
@ -523,17 +708,8 @@
|
|||
color: #6b7c6e;
|
||||
}
|
||||
|
||||
.cart-empty .fa {
|
||||
font-size: 30px;
|
||||
color: #dde8da;
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.cart-empty p {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.cart-empty .fa { font-size: 30px; color: #dde8da; display: block; margin-bottom: 10px; }
|
||||
.cart-empty p { font-size: 14px; line-height: 1.5; }
|
||||
|
||||
.cart-items-list {
|
||||
padding: 4px 0;
|
||||
|
|
@ -549,23 +725,11 @@
|
|||
gap: 4px;
|
||||
}
|
||||
|
||||
.cart-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.cart-item:last-child { border-bottom: none; }
|
||||
|
||||
.ci-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1a3c22;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.ci-name { font-size: 14px; font-weight: 600; color: #1a3c22; line-height: 1.3; }
|
||||
|
||||
.ci-details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ci-details { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
.ci-qty {
|
||||
font-size: 12px;
|
||||
|
|
@ -576,23 +740,11 @@
|
|||
padding: 1px 7px;
|
||||
}
|
||||
|
||||
.ci-price {
|
||||
font-size: 12px;
|
||||
color: #6b7c6e;
|
||||
}
|
||||
.ci-price { font-size: 12px; color: #6b7c6e; }
|
||||
|
||||
.line-total {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #2d7a3a;
|
||||
margin-left: auto;
|
||||
}
|
||||
.line-total { font-size: 13px; font-weight: 700; color: #2d7a3a; margin-left: auto; }
|
||||
|
||||
.measurable-badge-sm {
|
||||
font-size: 12px;
|
||||
color: #e8734a;
|
||||
margin-left: auto;
|
||||
}
|
||||
.measurable-badge-sm { font-size: 12px; color: #e8734a; margin-left: auto; }
|
||||
|
||||
.cart-total-row {
|
||||
border-top: 1px solid #dde8da;
|
||||
|
|
@ -610,25 +762,11 @@
|
|||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.cart-total-note .fa {
|
||||
color: #f4a236;
|
||||
margin-top: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cart-total-note .fa { color: #f4a236; margin-top: 1px; flex-shrink: 0; }
|
||||
|
||||
.cart-total {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
color: #2c2c2c;
|
||||
}
|
||||
.cart-total { display: flex; justify-content: space-between; align-items: center; font-size: 14px; color: #2c2c2c; }
|
||||
|
||||
.cart-total strong {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: #1a3c22;
|
||||
}
|
||||
.cart-total strong { font-size: 18px; font-weight: 800; color: #1a3c22; }
|
||||
|
||||
#cartActions {
|
||||
padding: 14px 18px;
|
||||
|
|
@ -655,14 +793,8 @@
|
|||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.btn-checkout:hover {
|
||||
background: #1a3c22;
|
||||
}
|
||||
|
||||
.btn-checkout .fa {
|
||||
font-size: 16px;
|
||||
color: #f4a236;
|
||||
}
|
||||
.btn-checkout:hover { background: #1a3c22; }
|
||||
.btn-checkout .fa { font-size: 16px; color: #f4a236; }
|
||||
|
||||
.btn-view-cart {
|
||||
display: flex;
|
||||
|
|
@ -681,9 +813,7 @@
|
|||
transition: background 0.18s;
|
||||
}
|
||||
|
||||
.btn-view-cart:hover {
|
||||
background: #dde8da;
|
||||
}
|
||||
.btn-view-cart:hover { background: #dde8da; }
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
TOAST NOTIFICATION
|
||||
|
|
@ -707,50 +837,36 @@
|
|||
border-left: 4px solid #f4a236;
|
||||
}
|
||||
|
||||
.qo-toast.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.qo-toast .fa {
|
||||
color: #8cb63c;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.qo-toast.show { opacity: 1; transform: translateY(0); }
|
||||
.qo-toast .fa { color: #8cb63c; margin-right: 6px; }
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
RESPONSIVE
|
||||
───────────────────────────────────────────── */
|
||||
@media (max-width: 960px) {
|
||||
.qo-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.qo-layout { grid-template-columns: 1fr; }
|
||||
.qo-cart-panel { position: static; }
|
||||
|
||||
.qo-cart-panel {
|
||||
position: static;
|
||||
}
|
||||
.ds-day-buttons { gap: 6px; }
|
||||
.ds-day-btn { min-width: 64px; padding: 8px 12px; }
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.quick-order-page {
|
||||
width: 100%;
|
||||
padding: 12px 12px 40px;
|
||||
}
|
||||
.quick-order-page { width: 100%; padding: 12px 12px 40px; }
|
||||
|
||||
.product-card {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ds-header { padding: 14px 16px; }
|
||||
.ds-body { padding: 16px; }
|
||||
.ds-footer { padding: 12px 16px 16px; }
|
||||
.ds-confirm-btn { width: 100%; justify-content: center; }
|
||||
.ds-time-wrapper { flex-direction: column; align-items: flex-start; }
|
||||
|
||||
.pc-body {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
.qo-delivery-chip { flex-wrap: wrap; gap: 6px; }
|
||||
#deliveryChipText { flex: 1 1 100%; order: 3; }
|
||||
.dc-change-btn { margin-left: 0; }
|
||||
|
||||
.pc-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.product-card { flex-wrap: wrap; }
|
||||
.pc-body { flex: 1 1 100%; }
|
||||
.pc-actions { width: 100%; justify-content: flex-end; }
|
||||
|
||||
.qo-search-btn {
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.qo-search-btn { padding: 0 14px; font-size: 13px; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue