gyorsrendelés, előrendelés, előrendelés management, hasonlók

This commit is contained in:
Adam 2026-05-12 16:18:19 +02:00
parent 51f546caec
commit c86ef0e416
55 changed files with 9143 additions and 1241 deletions

View File

@ -9,6 +9,7 @@ using FruitBank.Common.Dtos;
using FruitBank.Common.Entities; using FruitBank.Common.Entities;
using FruitBank.Common.Enums; using FruitBank.Common.Enums;
using FruitBank.Common.Interfaces; using FruitBank.Common.Interfaces;
using FruitBank.Common.Server;
using FruitBank.Common.Server.Interfaces; using FruitBank.Common.Server.Interfaces;
using FruitBank.Common.Server.Services.SignalRs; using FruitBank.Common.Server.Services.SignalRs;
using FruitBank.Common.SignalRs; using FruitBank.Common.SignalRs;
@ -72,7 +73,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
private readonly CustomOrderModelFactory _orderModelFactory; private readonly CustomOrderModelFactory _orderModelFactory;
private readonly ICustomOrderSignalREndpointServer _customOrderSignalREndpoint; private readonly ICustomOrderSignalREndpointServer _customOrderSignalREndpoint;
private readonly IPermissionService _permissionService; private readonly IPermissionService _permissionService;
private readonly IGenericAttributeService _genericAttributeService; //private readonly IGenericAttributeService _genericAttributeService;
private readonly FruitBankAttributeService _fruitBankAttributeService;
private readonly INotificationService _notificationService; private readonly INotificationService _notificationService;
private readonly ICustomerService _customerService; private readonly ICustomerService _customerService;
private readonly IProductService _productService; private readonly IProductService _productService;
@ -89,6 +91,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
protected readonly ITaxService _taxService; protected readonly ITaxService _taxService;
protected readonly MeasurementService _measurementService; protected readonly MeasurementService _measurementService;
protected readonly IWorkflowMessageService _workflowMessageService; protected readonly IWorkflowMessageService _workflowMessageService;
protected readonly FruitBankNotificationService _fruitBankNotificationService;
protected readonly IAddressService _addressService;
private static readonly char[] _separator = [',']; private static readonly char[] _separator = [','];
// ... other dependencies // ... other dependencies
@ -121,7 +125,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
IOrderModelFactory orderModelFactory, IOrderModelFactory orderModelFactory,
ICustomOrderSignalREndpointServer customOrderSignalREndpoint, ICustomOrderSignalREndpointServer customOrderSignalREndpoint,
IPermissionService permissionService, IPermissionService permissionService,
IGenericAttributeService genericAttributeService, //IGenericAttributeService genericAttributeService,
FruitBankAttributeService fruitBankAttributeService,
INotificationService notificationService, INotificationService notificationService,
ICustomerService customerService, ICustomerService customerService,
IProductService productService, IProductService productService,
@ -136,7 +141,10 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
IImportManager importManager, IImportManager importManager,
IDateTimeHelper dateTimeHelper, IDateTimeHelper dateTimeHelper,
ITaxService taxService, ITaxService taxService,
MeasurementService measurementService, IWorkflowMessageService workflowMessageService) MeasurementService measurementService,
IWorkflowMessageService workflowMessageService,
FruitBankNotificationService fruitBankNotificationService,
IAddressService addressService)
{ {
_logger = new Logger<CustomOrderController>(logWriters.ToArray()); _logger = new Logger<CustomOrderController>(logWriters.ToArray());
@ -147,7 +155,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
_orderModelFactory = orderModelFactory as CustomOrderModelFactory; _orderModelFactory = orderModelFactory as CustomOrderModelFactory;
_customOrderSignalREndpoint = customOrderSignalREndpoint; _customOrderSignalREndpoint = customOrderSignalREndpoint;
_permissionService = permissionService; _permissionService = permissionService;
_genericAttributeService = genericAttributeService; //_genericAttributeService = genericAttributeService;
_fruitBankAttributeService = fruitBankAttributeService;
_notificationService = notificationService; _notificationService = notificationService;
_customerService = customerService; _customerService = customerService;
_productService = productService; _productService = productService;
@ -165,7 +174,11 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
_taxService = taxService; _taxService = taxService;
_measurementService = measurementService; _measurementService = measurementService;
_workflowMessageService = workflowMessageService; _workflowMessageService = workflowMessageService;
_fruitBankNotificationService = fruitBankNotificationService;
_addressService = addressService;
// ... initialize other deps // ... initialize other deps
} }
#region CustomOrderSignalREndpoint #region CustomOrderSignalREndpoint
@ -434,7 +447,15 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
// store attributes in GenericAttribute table // store attributes in GenericAttribute table
//await _genericAttributeService.SaveAttributeAsync(order, nameof(IMeasurable.IsMeasurable), model.IsMeasurable, _storeContext.GetCurrentStore().Id); //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); var orderDto = await _dbContext.OrderDtos.GetByIdAsync(model.OrderId, true);
await _sendToClient.SendOrderChanged(orderDto); await _sendToClient.SendOrderChanged(orderDto);
@ -494,6 +515,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
{ {
//no address at all, cannot create order //no address at all, cannot create order
_logger.Error($"Cannot create order for customer {customer.Id}, no billing address found."); _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"); return RedirectToAction("List");
} }
} }
@ -547,7 +569,28 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
//var orderDto = await _dbContext.OrderDtos.GetByIdAsync(order.Id, true); //var orderDto = await _dbContext.OrderDtos.GetByIdAsync(order.Id, true);
//await _sendToClient.SendMeasuringNotification("Módosult a rendelés, mérjétek újra!", orderDto); //await _sendToClient.SendMeasuringNotification("Módosult a rendelés, mérjétek újra!", orderDto);
//var updatedOrder = await _orderService.GetOrderByIdAsync(order.Id); //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 }); return RedirectToAction("Edit", "Order", new { id = order.Id });
} }
@ -602,7 +645,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
{ {
unitPricesIncludeDiscounts = true; unitPricesIncludeDiscounts = true;
} }
//itt ha includeDiscounts van, akkor már a beírt ár megy be? //itt ha includeDiscounts van, akkor már a beírt ár megy be?
var orderItem = await CreateOrderItem(product, order, orderProductItem, isMeasurable, unitPricesIncludeDiscounts, customer, store); var orderItem = await CreateOrderItem(product, order, orderProductItem, isMeasurable, unitPricesIncludeDiscounts, customer, store);
@ -967,6 +1010,79 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
} }
[HttpGet] [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)] [CheckPermission(StandardPermission.Catalog.PRODUCTS_VIEW)]
public virtual async Task<IActionResult> ProductSearchUnfilteredAutoComplete(string term) public virtual async Task<IActionResult> ProductSearchUnfilteredAutoComplete(string term)
{ {
@ -975,7 +1091,6 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
const int maxResults = 30; const int maxResults = 30;
// Search products by name or SKU
var products = await _productService.SearchProductsAsync( var products = await _productService.SearchProductsAsync(
keywords: term, keywords: term,
pageIndex: 0, pageIndex: 0,
@ -989,24 +1104,21 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
var productDto = productDtosById[product.Id]; var productDto = productDtosById[product.Id];
if (productDto != null) if (productDto != null)
{ {
result.Add(new result.Add(new
{ {
label = $"{product.Name} [RENDELHETŐ: {(product.StockQuantity + productDto.IncomingQuantity)}] [ÁR: {product.Price}]", label = $"{product.Name} [RENDELHETŐ: {(product.StockQuantity + productDto.IncomingQuantity)}] [ÁR: {product.Price}]",
value = product.Id, value = product.Id,
sku = product.Sku, sku = product.Sku,
price = product.Price, price = product.Price,
stockQuantity = product.StockQuantity, stockQuantity = product.StockQuantity,
incomingQuantity = productDto.IncomingQuantity, incomingQuantity = productDto.IncomingQuantity,
}); });
} }
} }
return Json(result); return Json(result);
} }
//[HttpPost]
//public async Task<IActionResult> CreateInvoice(int orderId) //public async Task<IActionResult> CreateInvoice(int orderId)
//{ //{
// try // try
@ -1404,14 +1516,38 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
[HttpPost] [HttpPost]
//[IgnoreAntiforgeryToken] [ValidateAntiForgeryToken]
[ValidateAntiForgeryToken] [CheckPermission(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE)]
public async Task<IActionResult> FruitBankAddProductToOrder(int orderId, string productsJson) public async Task<IActionResult> SendOrderEmailToCustomer(int orderId)
{
try
{ {
try var order = await _orderService.GetOrderByIdAsync(orderId);
{ if (order == null)
_logger.Info($"AddProductToOrder - OrderId: {orderId}, ProductsJson: {productsJson}"); 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)) if (!await _permissionService.AuthorizeAsync(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE))
return Json(new { success = false, message = "Access denied" }); 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). // FruitBank Order Grid new server-side DataTables endpoint
/// </summary> // ═══════════════════════════════════════════════════════════════════
[CheckPermission(StandardPermission.Orders.ORDERS_VIEW)]
public async Task<IActionResult> NewList( /// <summary>
List<int> orderStatuses = null, /// Returns the new FruitBank order list view (replaces the default NopCommerce grid).
List<int> paymentStatuses = null, /// </summary>
List<int> shippingStatuses = null) [CheckPermission(StandardPermission.Orders.ORDERS_VIEW)]
{ public async Task<IActionResult> NewList(
var model = await _orderModelFactory.PrepareOrderSearchModelAsync(new OrderSearchModelExtended List<int> orderStatuses = null,
List<int> paymentStatuses = null,
List<int> shippingStatuses = null)
{ {
OrderStatusIds = orderStatuses, var model = await _orderModelFactory.PrepareOrderSearchModelAsync(new OrderSearchModelExtended
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
{ {
"customordernumber" => dtos.Where(o => o.CustomOrderNumber?.Contains(val, StringComparison.OrdinalIgnoreCase) == true).ToList(), OrderStatusIds = orderStatuses,
"customercompany" => dtos.Where(o => o.CustomerCompany?.Contains(val, StringComparison.OrdinalIgnoreCase) == true).ToList(), PaymentStatusIds = paymentStatuses,
"orderstatusid" => int.TryParse(val, out int osId) ? dtos.Where(o => o.OrderStatusId == osId).ToList() : dtos, ShippingStatusIds = shippingStatuses,
"measuringstatus" => int.TryParse(val, out int msId) ? dtos.Where(o => o.MeasuringStatus == msId).ToList() : dtos, Length = 50,
"ismeasurable" => bool.TryParse(val, out bool bm) ? dtos.Where(o => o.IsMeasurable == bm).ToList() : dtos, AvailablePageSizes = "20,50,100,500",
// InnVoice column sends 'has' or 'none' strings SortColumn = "Id",
"innvoicetechid" => val == "has" ? dtos.Where(o => !string.IsNullOrEmpty(o.InnvoiceTechId)).ToList() SortColumnDirection = "desc",
: val == "none" ? dtos.Where(o => string.IsNullOrEmpty(o.InnvoiceTechId)).ToList() });
: dtos, model.SetGridSort("Id", "desc");
_ => dtos 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 factorys ~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 its 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 doesnt 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 (its 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 ──────────────────────────────────────────────────── /// <summary>
bool asc = sortDir == "asc"; /// Inline-edit save endpoint. Currently supports DateOfReceipt.
dtos = sortColName.ToLowerInvariant() switch /// </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(), try
"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())
{ {
case "DATEOFRECEIPT": var order = await _orderService.GetOrderByIdAsync(orderId);
if (string.IsNullOrWhiteSpace(value)) if (order == null)
{ return Json(new { success = false, error = "Rendelés nem található" });
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" });
default: switch (field?.ToUpperInvariant())
return Json(new { success = false, error = $"Ismeretlen mező: {field}" }); {
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 });
}
} }
}
} }

View File

@ -41,6 +41,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
private readonly FileStorageService _fileStorageService; private readonly FileStorageService _fileStorageService;
private readonly FruitBankAttributeService _fruitBankAttributeService; private readonly FruitBankAttributeService _fruitBankAttributeService;
private readonly IStoreContext _storeContext; private readonly IStoreContext _storeContext;
private readonly PreorderConversionService _preorderConversionService;
public FileManagerController( public FileManagerController(
IPermissionService permissionService, IPermissionService permissionService,
@ -53,7 +54,8 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
IWorkContext workContext, IWorkContext workContext,
FileStorageService fileStorageService, FileStorageService fileStorageService,
FruitBankAttributeService fruitBankAttributeService, FruitBankAttributeService fruitBankAttributeService,
IStoreContext storeContext) IStoreContext storeContext,
PreorderConversionService preorderConversionService)
{ {
_permissionService = permissionService; _permissionService = permissionService;
_aiApiService = aiApiService; _aiApiService = aiApiService;
@ -66,6 +68,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
_fileStorageService = fileStorageService; _fileStorageService = fileStorageService;
_fruitBankAttributeService = fruitBankAttributeService; _fruitBankAttributeService = fruitBankAttributeService;
_storeContext = storeContext; _storeContext = storeContext;
_preorderConversionService = preorderConversionService;
} }
/// <summary> /// <summary>
@ -1120,6 +1123,32 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
newIncomingQuantity, _storeContext.GetCurrentStore().Id 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 return Json(new

View File

@ -34,7 +34,9 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
ApiBaseUrl = _settings.ApiBaseUrl, ApiBaseUrl = _settings.ApiBaseUrl,
MaxTokens = _settings.MaxTokens, MaxTokens = _settings.MaxTokens,
Temperature = _settings.Temperature, 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); 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.MaxTokens = model.MaxTokens;
_settings.Temperature = model.Temperature; _settings.Temperature = model.Temperature;
_settings.RequestTimeoutSeconds = model.RequestTimeoutSeconds; _settings.RequestTimeoutSeconds = model.RequestTimeoutSeconds;
_settings.ZaiApiKey = model.ZaiApiKey ?? string.Empty;
_settings.ZaiModel = model.ZaiModel ?? "glm-ocr";
// Save settings // Save settings
await _settingService.SaveSettingAsync(_settings); await _settingService.SaveSettingAsync(_settings);

View File

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

View File

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

View File

@ -9,25 +9,25 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models
{ {
public record ConfigureModel public record ConfigureModel
{ {
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ApiKey")] [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.CerebrasApiKey")]
public string ApiKey { get; set; } = string.Empty; public string ApiKey { get; set; } = string.Empty;
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ModelName")] [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.CerebrasModelName")]
public string ModelName { get; set; } = string.Empty; public string ModelName { get; set; } = string.Empty;
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ApiKey")] [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.OpenAIApiKey")]
public string OpenAIApiKey { get; set; } = string.Empty; public string OpenAIApiKey { get; set; } = string.Empty;
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ModelName")] [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.OpenAIModelName")]
public string OpenAIModelName { get; set; } = string.Empty; public string OpenAIModelName { get; set; } = string.Empty;
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.IsEnabled")] [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.IsEnabled")]
public bool IsEnabled { get; set; } public bool IsEnabled { get; set; }
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ApiBaseUrl")] [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.CerebrasApiBaseUrl")]
public string ApiBaseUrl { get; set; } = string.Empty; public string ApiBaseUrl { get; set; } = string.Empty;
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ApiBaseUrl")] [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.OpenAIApiBaseUrl")]
public string OpenAIApiBaseUrl { get; set; } = string.Empty; public string OpenAIApiBaseUrl { get; set; } = string.Empty;
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.MaxTokens")] [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.MaxTokens")]
@ -38,6 +38,14 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.RequestTimeoutSeconds")] [NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.RequestTimeoutSeconds")]
public int RequestTimeoutSeconds { get; set; } 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";
} }
} }

View File

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

View File

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

View File

@ -23,40 +23,42 @@
<label asp-for="ApiKey"></label> <label asp-for="ApiKey"></label>
<input asp-for="ApiKey" class="form-control" type="password" placeholder="Adja meg az AI API kulcsot" /> <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> <span asp-validation-for="ApiKey" class="text-danger"></span>
<small class="form-text text-muted">A Cerebras API kulcs</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label asp-for="ModelName"></label> <label asp-for="ModelName"></label>
<input asp-for="ModelName" class="form-control" placeholder="pl. gpt-3.5-turbo, gpt-4" /> <input asp-for="ModelName" class="form-control" placeholder="pl. gpt-3.5-turbo, gpt-4" />
<span asp-validation-for="ModelName" class="text-danger"></span> <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>
<div class="form-group"> <div class="form-group">
<label asp-for="OpenAIApiKey"></label> <label asp-for="OpenAIApiKey"></label>
<input asp-for="OpenAIApiKey" class="form-control" type="password" placeholder="Adja meg az OpenAI API kulcsot" /> <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> <span asp-validation-for="OpenAIApiKey" class="text-danger"></span>
<small class="form-text text-muted">Az OpenAI API kulcs</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label asp-for="OpenAIModelName"></label> <label asp-for="OpenAIModelName"></label>
<input asp-for="OpenAIModelName" class="form-control" placeholder="pl. gpt-3.5-turbo, gpt-4" /> <input asp-for="OpenAIModelName" class="form-control" placeholder="pl. gpt-3.5-turbo, gpt-4" />
<span asp-validation-for="OpenAIModelName" class="text-danger"></span> <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>
<div class="form-group"> <div class="form-group">
<label asp-for="ApiBaseUrl"></label> <label asp-for="ApiBaseUrl"></label>
<input asp-for="ApiBaseUrl" class="form-control" placeholder="https://api.openai.com/v1" /> <input asp-for="ApiBaseUrl" class="form-control" placeholder="https://api.openai.com/v1" />
<span asp-validation-for="ApiBaseUrl" class="text-danger"></span> <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>
<div class="form-group"> <div class="form-group">
<label asp-for="OpenAIApiBaseUrl"></label> <label asp-for="OpenAIApiBaseUrl"></label>
<input asp-for="OpenAIApiBaseUrl" class="form-control" placeholder="https://api.openai.com/v1" /> <input asp-for="OpenAIApiBaseUrl" class="form-control" placeholder="https://api.openai.com/v1" />
<span asp-validation-for="OpenAIApiBaseUrl" class="text-danger"></span> <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>
<div class="row"> <div class="row">
@ -88,6 +90,28 @@
</div> </div>
</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"> <div class="form-group">
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Mentés <i class="fas fa-save"></i> Mentés

View File

@ -29,7 +29,7 @@
@T("Admin.Orders.EditOrderDetails") - @Model.CustomOrderNumber @T("Admin.Orders.EditOrderDetails") - @Model.CustomOrderNumber
<small> <small>
<i class="fas fa-arrow-circle-left"></i> <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> </small>
</h1> </h1>
<div class="float-right"> <div class="float-right">

View File

@ -112,7 +112,7 @@
<div class="card-body p-0"> <div class="card-body p-0">
@* Anti-forgery token for AJAX POSTs *@ @* Anti-forgery token for AJAX POSTs *@
@Html.AntiForgeryToken() @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> <thead>
<tr> <tr>
<th><input type="checkbox" id="fb-check-all" title="Összes kijelölése"></th> <th><input type="checkbox" id="fb-check-all" title="Összes kijelölése"></th>
@ -120,7 +120,7 @@
<th>Partner</th> <th>Partner</th>
<th>InnVoice</th> <th>InnVoice</th>
<th>Súly</th> <th>Súly</th>
<th>Mérhető</th> <th>Mérendő</th>
<th>Mérés</th> <th>Mérés</th>
<th title="Kattintásra szerkeszthető">Átvétel <small class="text-muted">✏️</small></th> <th title="Kattintásra szerkeszthető">Átvétel <small class="text-muted">✏️</small></th>
<th>Státusz</th> <th>Státusz</th>

View File

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

View File

@ -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>&times;</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>

View File

@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -31,6 +31,9 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
private readonly OpenAIApiService _aiApiService; private readonly OpenAIApiService _aiApiService;
private readonly CerebrasAPIService _cerebrasApiService; private readonly CerebrasAPIService _cerebrasApiService;
private readonly FruitBankDbContext _dbContext; private readonly FruitBankDbContext _dbContext;
private readonly FruitBankAttributeService _fruitBankAttributeService;
private const string PendingDeliveryDateTimeKey = "QuickOrderPendingDeliveryDateTime";
// Resource key prefix // Resource key prefix
private const string Prefix = "Plugins.Misc.FruitBankPlugin.QuickOrder."; private const string Prefix = "Plugins.Misc.FruitBankPlugin.QuickOrder.";
@ -45,7 +48,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
IPriceCalculationService priceCalculationService, IPriceCalculationService priceCalculationService,
OpenAIApiService aiApiService, OpenAIApiService aiApiService,
CerebrasAPIService cerebrasApiService, CerebrasAPIService cerebrasApiService,
FruitBankDbContext dbContext) FruitBankDbContext dbContext,
FruitBankAttributeService fruitBankAttributeService)
{ {
_workContext = workContext; _workContext = workContext;
_storeContext = storeContext; _storeContext = storeContext;
@ -57,6 +61,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
_aiApiService = aiApiService; _aiApiService = aiApiService;
_cerebrasApiService = cerebrasApiService; _cerebrasApiService = cerebrasApiService;
_dbContext = dbContext; _dbContext = dbContext;
_fruitBankAttributeService = fruitBankAttributeService;
} }
[HttpGet] [HttpGet]
@ -70,10 +75,64 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
} }
/// <summary> /// <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> /// </summary>
[HttpGet] [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(); var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer)) if (await _customerService.IsGuestAsync(customer))
@ -81,13 +140,14 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
try try
{ {
var store = await _storeContext.GetCurrentStoreAsync(); Console.WriteLine($"[QuickOrder] GetAllProducts deliveryDate={deliveryDate}, time={deliveryTime}");
var allProductDtos = (await _dbContext.ProductDtos.GetAll(true).ToListAsync()).Where(pd => pd.AvailableQuantity > 0);
//var dbProducts = await _productService.SearchProductsAsync( var store = await _storeContext.GetCurrentStoreAsync();
// pageIndex: 0, var allProductDtos = (await _dbContext.ProductDtos.GetAll(true).ToListAsync())
// pageSize: 500, .Where(pd => pd.AvailableQuantity > 0);
// orderBy: );
// TODO: filter allProductDtos by deliveryDate + deliverySlot once
// availability data model is defined (e.g. scheduled stock, delivery windows).
var result = new List<object>(); var result = new List<object>();
@ -132,10 +192,11 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
} }
/// <summary> /// <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> /// </summary>
[HttpPost] [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(); var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer)) if (await _customerService.IsGuestAsync(customer))
@ -144,6 +205,10 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text))
return Json(new { success = false, message = await L("NoTextProvided") }); 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 try
{ {
var parsedProducts = await ParseProductsFromText(text); var parsedProducts = await ParseProductsFromText(text);
@ -163,10 +228,11 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
} }
/// <summary> /// <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> /// </summary>
[HttpPost] [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(); var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer)) if (await _customerService.IsGuestAsync(customer))
@ -175,6 +241,10 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
if (audioFile == null || audioFile.Length == 0) if (audioFile == null || audioFile.Length == 0)
return Json(new { success = false, message = await L("NoAudioReceived") }); 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 try
{ {
var transcribedText = await TranscribeAudioFile(audioFile, "hu"); var transcribedText = await TranscribeAudioFile(audioFile, "hu");
@ -200,7 +270,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
} }
/// <summary> /// <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> /// </summary>
[HttpPost] [HttpPost]
public async Task<IActionResult> AddToCart(int productId, int quantity) public async Task<IActionResult> AddToCart(int productId, int quantity)
@ -241,7 +311,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
} }
/// <summary> /// <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> /// </summary>
[HttpGet] [HttpGet]
public async Task<IActionResult> GetCartItems() public async Task<IActionResult> GetCartItems()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -37,13 +37,15 @@ public class FruitBankEventConsumer :
private readonly FruitBankDbContext _ctx; private readonly FruitBankDbContext _ctx;
private readonly MeasurementService _measurementService; private readonly MeasurementService _measurementService;
private readonly FruitBankAttributeService _fruitBankAttributeService; private readonly FruitBankAttributeService _fruitBankAttributeService;
private readonly PreorderConversionService _preorderConversionService;
public FruitBankEventConsumer(IHttpContextAccessor httpContextAcc, FruitBankDbContext ctx, MeasurementService measurementService, 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; _ctx = ctx;
_measurementService = measurementService; _measurementService = measurementService;
_fruitBankAttributeService = fruitBankAttributeService; _fruitBankAttributeService = fruitBankAttributeService;
_preorderConversionService = preorderConversionService;
} }
public override async Task HandleEventAsync(EntityUpdatedEvent<Product> eventMessage) public override async Task HandleEventAsync(EntityUpdatedEvent<Product> eventMessage)
@ -192,6 +194,24 @@ public class FruitBankEventConsumer :
Logger.Info($"HandleEventAsync->EntityInsertedEvent<ShippingItemPallet>; id: {eventMessage.Entity.Id}"); Logger.Info($"HandleEventAsync->EntityInsertedEvent<ShippingItemPallet>; id: {eventMessage.Entity.Id}");
await UpdateShippingDocumentIsAllMeasuredAsync(eventMessage.Entity); 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 #region Update
@ -201,15 +221,25 @@ public class FruitBankEventConsumer :
Logger.Info($"HandleEventAsync->EntityUpdatedEvent<ShippingItem>; id: {eventMessage.Entity.Id}"); Logger.Info($"HandleEventAsync->EntityUpdatedEvent<ShippingItem>; id: {eventMessage.Entity.Id}");
var shippingItem = eventMessage.Entity; var shippingItem = eventMessage.Entity;
//var isMeasured = shippingItem.IsValidMeasuringValues();
//if (shippingItem.IsMeasured != isMeasured)
//{
// shippingItem.IsMeasured = isMeasured;
// await ctx.ShippingItems.UpdateAsync(shippingItem, false);
//}
await UpdateShippingDocumentIsAllMeasuredAsync(shippingItem); 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) private async Task UpdateShippingDocumentIsAllMeasuredAsync(ShippingItem shippingItem)

View File

@ -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", "Quick Order", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MenuLabel", "Gyors rendel\u00e9s", hu); 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 // Search bar
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicButtonTitle", "Start voice recording", en); 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); 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", "Error: ", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ErrorPrefix", "Hiba: ", hu); 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 // Controller JSON error messages
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NotLoggedIn", "Not logged in", en); await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NotLoggedIn", "Not logged in", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NotLoggedIn", "Nincs bejelentkezve", hu); 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", "Invalid product or quantity", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.InvalidProductOrQuantity", "\u00c9rv\u00e9nytelen term\u00e9k vagy mennyis\u00e9g", hu); 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 ──────────────────────────────────────────────────── // ── Customer Credit ────────────────────────────────────────────────────
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle", "Customer Credit Management", en); 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); 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, PublicWidgetZones.ProductDetailsBottom,
AdminWidgetZones.ProductDetailsBlock, AdminWidgetZones.ProductDetailsBlock,
AdminWidgetZones.OrderDetailsBlock, AdminWidgetZones.OrderDetailsBlock,
AdminWidgetZones.CustomerDetailsBlock AdminWidgetZones.CustomerDetailsBlock,
PublicWidgetZones.AccountNavigationAfter
}); });
} }
@ -344,6 +462,10 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
{ {
return typeof(CustomerCreditWidgetViewComponent); return typeof(CustomerCreditWidgetViewComponent);
} }
else if (widgetZone == PublicWidgetZones.AccountNavigationAfter)
{
return typeof(CustomerPreorderNavViewComponent);
}
} }
return null; return null;

View File

@ -54,6 +54,19 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
/// Gets or sets the timeout for API requests in seconds /// Gets or sets the timeout for API requests in seconds
/// </summary> /// </summary>
public int RequestTimeoutSeconds { get; set; } = 30; 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";
} }
} }

View File

@ -82,6 +82,9 @@ public class PluginNopStartup : INopStartup
services.AddScoped<StockTakingItemDbTable>(); services.AddScoped<StockTakingItemDbTable>();
services.AddScoped<StockTakingItemPalletDbTable>(); services.AddScoped<StockTakingItemPalletDbTable>();
services.AddScoped<CustomerCreditDbTable>(); services.AddScoped<CustomerCreditDbTable>();
services.AddScoped<PreorderDbTable>();
services.AddScoped<PreorderItemDbTable>();
services.AddScoped<PreorderDbContext>();
services.AddScoped<StockTakingDbContext>(); services.AddScoped<StockTakingDbContext>();
services.AddScoped<FruitBankDbContext>(); services.AddScoped<FruitBankDbContext>();
@ -127,8 +130,15 @@ public class PluginNopStartup : INopStartup
services.AddScoped<OpenAIApiService>(); services.AddScoped<OpenAIApiService>();
//services.AddScoped<IAIAPIService, OpenAIApiService>(); //services.AddScoped<IAIAPIService, OpenAIApiService>();
services.AddScoped<AICalculationService>(); 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<PdfToImageService>();
services.AddScoped<FruitBankNotificationService>(); services.AddScoped<FruitBankNotificationService>();
services.AddScoped<PreorderConversionService>();
services.AddSingleton<IFileStorageProvider>(sp => services.AddSingleton<IFileStorageProvider>(sp =>
new LocalFileStorageProvider() // Uses default wwwroot/uploads new LocalFileStorageProvider() // Uses default wwwroot/uploads
// Or specify custom path: // Or specify custom path:

View File

@ -197,6 +197,147 @@ public class RouteProvider : IRouteProvider
pattern: "Admin/CustomerCredit/UpdateCreditLimit", pattern: "Admin/CustomerCredit/UpdateCreditLimit",
defaults: new { controller = "CustomerCredit", action = "UpdateCreditLimit", area = AreaNames.ADMIN }); 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 ────────────────────────────────────────────── // ── Public: Quick Order ──────────────────────────────────────────────
endpointRouteBuilder.MapControllerRoute( endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.QuickOrder.Index", name: "Plugin.FruitBank.QuickOrder.Index",
@ -227,6 +368,16 @@ public class RouteProvider : IRouteProvider
name: "Plugin.FruitBank.QuickOrder.GetCartItems", name: "Plugin.FruitBank.QuickOrder.GetCartItems",
pattern: "gyors-rendeles/kosar", pattern: "gyors-rendeles/kosar",
defaults: new { controller = "QuickOrder", action = "GetCartItems" }); 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> /// <summary>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -14,6 +14,41 @@
<Value><![CDATA[Quick Order]]></Value> <Value><![CDATA[Quick Order]]></Value>
</LocaleResource> </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 --> <!-- Search bar -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.MicButtonTitle"> <LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.MicButtonTitle">
<Value><![CDATA[Start voice recording]]></Value> <Value><![CDATA[Start voice recording]]></Value>

View File

@ -14,6 +14,41 @@
<Value><![CDATA[Gyors rendelés]]></Value> <Value><![CDATA[Gyors rendelés]]></Value>
</LocaleResource> </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 --> <!-- Keresősáv -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.MicButtonTitle"> <LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.MicButtonTitle">
<Value><![CDATA[Hangfelvétel indítása]]></Value> <Value><![CDATA[Hangfelvétel indítása]]></Value>

View File

@ -43,6 +43,8 @@ public partial class NameCompatibility : INameCompatibility
{ typeof(StockTakingItem), FruitBankConstClient.StockTakingItemDbTableName}, { typeof(StockTakingItem), FruitBankConstClient.StockTakingItemDbTableName},
{ typeof(StockTakingItemPallet), FruitBankConstClient.StockTakingItemPalletDbTableName}, { typeof(StockTakingItemPallet), FruitBankConstClient.StockTakingItemPalletDbTableName},
{ typeof(CustomerCredit), FruitBankConstClient.CustomerCreditDbTableName}, { typeof(CustomerCredit), FruitBankConstClient.CustomerCreditDbTableName},
{ typeof(Preorder), FruitBankConstClient.PreOrderDbTableName},
{ typeof(PreorderItem), FruitBankConstClient.PreOrderItemDbTableName},
}; };

View File

@ -221,6 +221,15 @@
<None Update="Areas\Admin\Views\Order\List.cshtml"> <None Update="Areas\Admin\Views\Order\List.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None> </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"> <None Update="Areas\Admin\Views\Product\List.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None> </None>
@ -455,6 +464,9 @@
<None Update="css\devextreme\icons\dxiconsmaterial.woff2"> <None Update="css\devextreme\icons\dxiconsmaterial.woff2">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None> </None>
<None Update="css\preorder.css">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="js\devextreme\aspnet\dx.aspnet.data.js"> <None Update="js\devextreme\aspnet\dx.aspnet.data.js">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None> </None>
@ -662,6 +674,21 @@
<None Update="Views\CustomerCreditWidget.cshtml"> <None Update="Views\CustomerCreditWidget.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None> </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"> <None Update="Views\ProductAIListWidget.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None> </None>

View File

@ -305,6 +305,7 @@ AI-driven workflow for extracting partner + product data from uploaded PDFs/imag
| Issue | Correct approach | | 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` | | `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>...` | | `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 | | `TransactionSafeAsync` + async = deadlock | `TaskHelper.ToThreadPoolTask` inside it causes context switching deadlocks in ASP.NET; remove transaction wrapper for affected code |

View File

@ -177,10 +177,11 @@ public class CustomPriceCalculationService : PriceCalculationService
if (productDto.IsMeasurable) if (productDto.IsMeasurable)
{ {
//finalPrice.priceWithoutDiscounts = 0; // For measurable products the real price is weight × unit price, determined only after
//return (0, finalPrice.finalPrice, finalPrice.appliedDiscountAmount, []); // physical weighing. Until then we expose 0 so the cart and checkout total are honest.
return finalPrice; // The actual PriceInclTax / PriceExclTax on OrderItem is set by
//return (overriddenProductPrice.GetValueOrDefault(0), overriddenProductPrice.GetValueOrDefault(0), 0m, []); // 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); //var productAttributeMappings = await _specificationAttributeService.GetProductSpecificationAttributesAsync(product.Id);
////Product Attributes ////Product Attributes

View File

@ -98,6 +98,31 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
public async Task HandleEventAsync(OrderPlacedEvent eventMessage) 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) public async Task HandleEventAsync(EntityUpdatedEvent<Order> eventMessage)
@ -225,16 +250,35 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
{ {
Visible = true, Visible = true,
SystemName = "FruitBank", 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", IconClass = "fas fa-microphone",
Url = _adminMenu.GetMenuItemUrl("VoiceOrder", "Create") Url = _adminMenu.GetMenuItemUrl("VoiceOrder", "Create")
//ChildNodes = [shippingsListMenuItem, createShippingMenuItem, editShippingMenuItem]
}; };
shippingConfigurationItem.ChildNodes.Insert(3, voiceOrderMenuItem); 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 // Create a new top-level menu item
var InvoiceSyncMenuItem = new AdminMenuItem var InvoiceSyncMenuItem = new AdminMenuItem

View File

@ -1,28 +1,140 @@
using Nop.Core; using Nop.Core;
using Nop.Core.Domain.Common;
using Nop.Core.Domain.Messages; using Nop.Core.Domain.Messages;
using Nop.Core.Domain.Orders; 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.Customers;
using Nop.Services.Localization;
using Nop.Services.Messages; using Nop.Services.Messages;
using Nop.Services.Orders;
using Nop.Services.Stores;
namespace Nop.Plugin.Misc.FruitBankPlugin.Services; namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
public class FruitBankNotificationService( public class FruitBankNotificationService(
CommonSettings commonSettings,
IMessageTemplateService messageTemplateService, IMessageTemplateService messageTemplateService,
IEmailAccountService emailAccountService, IEmailAccountService emailAccountService,
EmailAccountSettings emailAccountSettings, EmailAccountSettings emailAccountSettings,
IMessageTokenProvider messageTokenProvider, IMessageTokenProvider messageTokenProvider,
IWorkflowMessageService workflowMessageService, IWorkflowMessageService workflowMessageService,
ICustomerService customerService, 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_AUDITED_TEMPLATE_NAME = "FruitBank.OrderAudited.CustomerNotification";
public const string ORDER_STARTED_TEMPLATE_NAME = "FruitBank.OrderStarted.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> /// <summary>
/// Sends the "order started" (being prepared) customer notification. /// Sends the "order started" (being prepared) customer notification.
/// For measurable orders, informs the customer that final prices will be /// For measurable orders, informs the customer that final prices will be
/// confirmed after weighing. Fires once when MeasuringStatus transitions to Started. /// confirmed after weighing. Fires once when MeasuringStatus transitions to Started.
/// </summary> /// </summary>
///
public async Task<int> SendOrderStartedCustomerNotificationAsync(Order order, bool isMeasurable) public async Task<int> SendOrderStartedCustomerNotificationAsync(Order order, bool isMeasurable)
{ {
var measurableNote = isMeasurable var measurableNote = isMeasurable
@ -71,7 +183,20 @@ public class FruitBankNotificationService(
tokens.Add(new Token("Order.MeasurableNote", measurableNote, true)); 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(); var toName = $"{customer.FirstName} {customer.LastName}".Trim();
if (string.IsNullOrWhiteSpace(toName)) toName = customer.Email; if (string.IsNullOrWhiteSpace(toName)) toName = customer.Email;
@ -79,6 +204,6 @@ public class FruitBankNotificationService(
messageTemplate, emailAccount, messageTemplate, emailAccount,
order.CustomerLanguageId, order.CustomerLanguageId,
tokens, tokens,
toEmail, toName); customerEmail, toName);
} }
} }

View File

@ -822,14 +822,16 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
"'products' (array of objects with the following fields: " + "'products' (array of objects with the following fields: " +
"'name' (string), " + "'name' (string), " +
"'quantity' (int - the number of cartons, boxes or packages), " + "'quantity' (int - the number of cartons, boxes or packages), " +
"'netWeight' (double - the net kilograms), " + "'netWeight' (double - the net kilograms in European format, example: 1.372 kgs should be 1372,00 kgs), " +
"'grossWeight' (double - the gross kilograms)," + "'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" + "'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. " + 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. " + "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 var payload = new
{ {

View File

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

View File

@ -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 &lt;table&gt;/&lt;thead&gt;/&lt;td&gt; 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 &lt;table&gt;/&lt;th&gt;/&lt;td&gt; 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 };
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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ökvasá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

View File

@ -9,7 +9,7 @@
<div class="form-group row"> <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 card-default mb-2">
<div class="card-header"> <div class="card-header">
<i class="fas fa-file-invoice"></i> <i class="fas fa-file-invoice"></i>
@ -38,22 +38,27 @@
</div> </div>
<hr /> <hr />
<div class="form-group row"> <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"> <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 <i class="fa fa-redo"></i> Újramérés engedélyezése
</button> </button>
</div> </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"> <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 <i class="fas fa-paper-plane"></i> Üzenet küldése
</button> </button>
</div> </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"> <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 <i class="fas fa-sticky-note"></i> Jegyzet hozzáadása
</button> </button>
</div> </div>
<div class="col-md-3"> <div class="col-md-2">
<button type="button" <button type="button"
class="btn btn-danger btn-block" class="btn btn-danger btn-block"
data-toggle="modal" data-toggle="modal"
@ -70,7 +75,7 @@
</div> </div>
</div> </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 card-default mb-3">
<div class="card-header"> <div class="card-header">
<i class="fas fa-file-invoice"></i> <i class="fas fa-file-invoice"></i>
@ -107,6 +112,36 @@
</div> </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">&times;</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 --> <!-- Allow Revision Modal -->
<div class="modal fade" id="allowRevisionModal" tabindex="-1" role="dialog" aria-labelledby="allowRevisionModalLabel" aria-hidden="true"> <div class="modal fade" id="allowRevisionModal" tabindex="-1" role="dialog" aria-labelledby="allowRevisionModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document"> <div class="modal-dialog" role="document">
@ -1244,6 +1279,54 @@
statusDiv.show(); 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 // Clear split order status when modal is closed
$('#splitOrderModal').on('hidden.bs.modal', function () { $('#splitOrderModal').on('hidden.bs.modal', function () {
$("#splitOrderStatus").hide(); $("#splitOrderStatus").hide();

View File

@ -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>

View File

@ -1,3 +1,4 @@
@using System.Text.Encodings.Web
@{ @{
Layout = "_Root"; Layout = "_Root";
ViewBag.Title = T("Plugins.Misc.FruitBankPlugin.QuickOrder.PageTitle").Text; ViewBag.Title = T("Plugins.Misc.FruitBankPlugin.QuickOrder.PageTitle").Text;
@ -7,144 +8,196 @@
<div class="quick-order-page"> <div class="quick-order-page">
<!-- Full-width Search Bar --> <!-- ── STEP 1: Delivery date + time picker ───────────────────────────── -->
<div class="qo-search-bar-wrapper"> <div id="deliveryStep" class="qo-delivery-step">
<div class="qo-search-bar"> <div class="ds-header">
<div class="search-input-group"> <i class="fa fa-calendar"></i>
<button id="recordBtn" class="mic-btn" title="@T("Plugins.Misc.FruitBankPlugin.QuickOrder.MicButtonTitle")"> <div>
<i class="fa fa-microphone"></i> <div class="ds-title">@T("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Title")</div>
</button> <div class="ds-subtitle">@T("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Subtitle")</div>
<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>
<div id="recordingStatus" class="recording-status-bar" style="display:none;"> </div>
<span id="statusText">@T("Plugins.Misc.FruitBankPlugin.QuickOrder.ListeningStatus")</span> <div class="ds-body">
<div class="volume-bar-container"> <div class="ds-section-label">@T("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.DayLabel")</div>
<div class="volume-bar volume-bar-silent"></div> <div class="ds-day-buttons" id="dayButtons"></div>
</div>
<div class="ds-section-label" style="margin-top:20px;">
@T("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.TimeLabel")
</div> </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>
</div> </div>
<!-- Two-column layout --> <!-- ── Delivery chip (collapsed state) ──────────────────────────────── -->
<div class="qo-layout"> <div id="deliveryChip" class="qo-delivery-chip" style="display:none;">
<i class="fa fa-calendar-check-o"></i>
<!-- LEFT: Products --> <span class="dc-label">@T("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ChangeLabel")</span>
<div class="qo-products-panel"> <strong id="deliveryChipText"></strong>
<button type="button" class="dc-change-btn" id="deliveryChangeBtn">
<div id="transcribedCard" class="result-card" style="display:none;"> <i class="fa fa-pencil"></i> @T("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ChangeButton")
<div class="result-label"><i class="fa fa-microphone"></i> @T("Plugins.Misc.FruitBankPlugin.QuickOrder.TranscribedLabel")</div> </button>
<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>
</div> </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() @Html.AntiForgeryToken()
@* JS string bundle — Razor renders these once so JS never contains raw Hungarian *@ @* JS string bundle *@
<script asp-location="Footer"> <script asp-location="Footer">
var qoStr = { var qoStr = {
allProducts: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.AllProductsLabel").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))', 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))', 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))', 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))', 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))', 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))', 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))', 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))', 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))', 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))', 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))', 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))', 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))', 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))', 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))', 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))', 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))', 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))', 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))', 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))', 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))', 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))', 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))', 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))', 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))', 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))', 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))', 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))' 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>
<script asp-location="Footer"> <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 mediaRecorder = null;
var audioChunks = []; var audioChunks = [];
var isRecording = false; var isRecording = false;
@ -164,13 +217,134 @@ var qoStr = {
volumeHistorySize: 10 volumeHistorySize: 10
}; };
// ── Init ──────────────────────────────────────────────────────────────────
$(document).ready(function () { $(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); $('#recordBtn').click(startRecording);
$('#stopBtn').click(function () { stopRecording(false); }); $('#stopBtn').click(function () { stopRecording(false); });
loadCart(); 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 ────────────────────────────────────────────────────────── // ── Product list ──────────────────────────────────────────────────────────
function loadAllProducts() { function loadAllProducts() {
@ -183,6 +357,7 @@ var qoStr = {
$.ajax({ $.ajax({
url: '@Url.Action("GetAllProducts", "QuickOrder")', url: '@Url.Action("GetAllProducts", "QuickOrder")',
type: 'GET', type: 'GET',
data: { deliveryDate: selectedDeliveryDate, deliveryTime: selectedDeliveryTime },
success: function (result) { success: function (result) {
$('#productsLoadingState').hide(); $('#productsLoadingState').hide();
if (result.success && result.products && result.products.length > 0) { if (result.success && result.products && result.products.length > 0) {
@ -233,11 +408,7 @@ var qoStr = {
if (audioContext) { audioContext.close(); audioContext = null; } if (audioContext) { audioContext.close(); audioContext = null; }
analyser = null; analyser = null;
isRecording = false; isRecording = false;
if (blob.size === 0) { if (blob.size === 0) { alert(qoStr.recordingFailed); resetRecordingUI(); return; }
alert(qoStr.recordingFailed);
resetRecordingUI();
return;
}
processAudio(blob, mimeType); processAudio(blob, mimeType);
}); });
@ -337,6 +508,8 @@ var qoStr = {
function processAudio(blob, mimeType) { function processAudio(blob, mimeType) {
var formData = new FormData(); var formData = new FormData();
formData.append('audioFile', blob, 'recording.webm'); formData.append('audioFile', blob, 'recording.webm');
formData.append('deliveryDate', selectedDeliveryDate || '');
formData.append('deliveryTime', selectedDeliveryTime || '');
formData.append('__RequestVerificationToken', $('input[name="__RequestVerificationToken"]').val()); formData.append('__RequestVerificationToken', $('input[name="__RequestVerificationToken"]').val());
$.ajax({ $.ajax({
url: '@Url.Action("TranscribeAndSearch", "QuickOrder")', url: '@Url.Action("TranscribeAndSearch", "QuickOrder")',
@ -371,7 +544,12 @@ var qoStr = {
$.ajax({ $.ajax({
url: '@Url.Action("SearchProducts", "QuickOrder")', url: '@Url.Action("SearchProducts", "QuickOrder")',
type: 'POST', 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); }, success: function (result) { $('#recordingStatus').hide(); handleSearchResult(result); },
error: function () { $('#recordingStatus').hide(); alert(qoStr.searchError); } error: function () { $('#recordingStatus').hide(); alert(qoStr.searchError); }
}); });

View File

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

View File

@ -25,6 +25,230 @@
color: #2c2c2c; 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 SEARCH BAR
*/ */
@ -43,7 +267,6 @@
gap: 0; gap: 0;
} }
/* Mic button */
.mic-btn { .mic-btn {
flex-shrink: 0; flex-shrink: 0;
width: 46px; width: 46px;
@ -78,11 +301,8 @@
50% { box-shadow: 0 0 0 8px rgba(244,162,54,0); } 50% { box-shadow: 0 0 0 8px rgba(244,162,54,0); }
} }
.mic-pulse { .mic-pulse { display: none; }
display: none;
}
/* Search text input */
.qo-input { .qo-input {
flex: 1; flex: 1;
height: 46px; height: 46px;
@ -103,11 +323,8 @@
z-index: 1; z-index: 1;
} }
.qo-input::placeholder { .qo-input::placeholder { color: #6b7c6e; }
color: #6b7c6e;
}
/* Search button */
.qo-search-btn { .qo-search-btn {
flex-shrink: 0; flex-shrink: 0;
height: 46px; height: 46px;
@ -130,7 +347,6 @@
border-color: #1a3c22; border-color: #1a3c22;
} }
/* Recording status bar */
.recording-status-bar { .recording-status-bar {
margin-top: 12px; margin-top: 12px;
display: flex; display: flex;
@ -183,8 +399,6 @@
/* /*
PRODUCTS PANEL (LEFT) PRODUCTS PANEL (LEFT)
*/ */
/* "I heard" transcription card */
.result-card { .result-card {
background: #fff; background: #fff;
border: 1px solid #dde8da; border: 1px solid #dde8da;
@ -208,7 +422,6 @@
color: #2c2c2c; color: #2c2c2c;
} }
/* No results / empty */
.no-results-card { .no-results-card {
background: #fff; background: #fff;
border: 1px dashed #dde8da; border: 1px dashed #dde8da;
@ -226,7 +439,6 @@
display: block; display: block;
} }
/* Loading state */
.products-empty-state { .products-empty-state {
background: #fff; background: #fff;
border: 1px solid #dde8da; border: 1px solid #dde8da;
@ -243,7 +455,6 @@
display: block; display: block;
} }
/* Section header above product list */
.matches-label { .matches-label {
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
@ -261,7 +472,6 @@
font-size: 14px; font-size: 14px;
} }
/* Group label (search results grouped by keyword) */
.group-label { .group-label {
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
@ -306,7 +516,6 @@
border-left: 3px solid #f4a236; border-left: 3px solid #f4a236;
} }
/* Body — grows, holds name + meta inline */
.pc-body { .pc-body {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@ -316,7 +525,6 @@
gap: 6px 14px; gap: 6px 14px;
} }
/* Product name */
.pc-name { .pc-name {
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
@ -336,7 +544,6 @@
flex-shrink: 0; flex-shrink: 0;
} }
/* Meta row — stock + price inline */
.pc-meta { .pc-meta {
display: flex; display: flex;
align-items: center; align-items: center;
@ -366,7 +573,6 @@
white-space: nowrap; white-space: nowrap;
} }
/* Badges */
.stock-warning-badge { .stock-warning-badge {
font-size: 11px; font-size: 11px;
color: #e8734a; color: #e8734a;
@ -388,7 +594,6 @@
white-space: nowrap; white-space: nowrap;
} }
/* Actions — fixed width, right-aligned */
.pc-actions { .pc-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@ -396,7 +601,6 @@
flex-shrink: 0; flex-shrink: 0;
} }
/* Qty stepper */
.qty-stepper { .qty-stepper {
display: flex; display: flex;
align-items: center; align-items: center;
@ -420,9 +624,7 @@
flex-shrink: 0; flex-shrink: 0;
} }
.qty-btn:hover { .qty-btn:hover { background: #dde8da; }
background: #dde8da;
}
.qty-input { .qty-input {
width: 48px; width: 48px;
@ -439,11 +641,8 @@
} }
.qty-input::-webkit-outer-spin-button, .qty-input::-webkit-outer-spin-button,
.qty-input::-webkit-inner-spin-button { .qty-input::-webkit-inner-spin-button { -webkit-appearance: none; }
-webkit-appearance: none;
}
/* Add to cart button */
.pc-add-btn { .pc-add-btn {
width: 36px; width: 36px;
height: 36px; height: 36px;
@ -460,20 +659,9 @@
transition: background 0.18s, transform 0.12s; transition: background 0.18s, transform 0.12s;
} }
.pc-add-btn:hover { .pc-add-btn:hover { background: #1a3c22; transform: scale(1.06); }
background: #1a3c22; .pc-add-btn:disabled { background: #dde8da; cursor: default; transform: none; }
transform: scale(1.06); .pc-add-btn.added { background: #8cb63c; }
}
.pc-add-btn:disabled {
background: #dde8da;
cursor: default;
transform: none;
}
.pc-add-btn.added {
background: #8cb63c;
}
/* /*
CART PANEL (RIGHT) CART PANEL (RIGHT)
@ -500,10 +688,7 @@
letter-spacing: 0.3px; letter-spacing: 0.3px;
} }
.qo-section-title .fa { .qo-section-title .fa { color: #f4a236; font-size: 17px; }
color: #f4a236;
font-size: 17px;
}
.cart-count-badge { .cart-count-badge {
background: #f4a236; background: #f4a236;
@ -523,17 +708,8 @@
color: #6b7c6e; color: #6b7c6e;
} }
.cart-empty .fa { .cart-empty .fa { font-size: 30px; color: #dde8da; display: block; margin-bottom: 10px; }
font-size: 30px; .cart-empty p { font-size: 14px; line-height: 1.5; }
color: #dde8da;
display: block;
margin-bottom: 10px;
}
.cart-empty p {
font-size: 14px;
line-height: 1.5;
}
.cart-items-list { .cart-items-list {
padding: 4px 0; padding: 4px 0;
@ -549,23 +725,11 @@
gap: 4px; gap: 4px;
} }
.cart-item:last-child { .cart-item:last-child { border-bottom: none; }
border-bottom: none;
}
.ci-name { .ci-name { font-size: 14px; font-weight: 600; color: #1a3c22; line-height: 1.3; }
font-size: 14px;
font-weight: 600;
color: #1a3c22;
line-height: 1.3;
}
.ci-details { .ci-details { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.ci-qty { .ci-qty {
font-size: 12px; font-size: 12px;
@ -576,23 +740,11 @@
padding: 1px 7px; padding: 1px 7px;
} }
.ci-price { .ci-price { font-size: 12px; color: #6b7c6e; }
font-size: 12px;
color: #6b7c6e;
}
.line-total { .line-total { font-size: 13px; font-weight: 700; color: #2d7a3a; margin-left: auto; }
font-size: 13px;
font-weight: 700;
color: #2d7a3a;
margin-left: auto;
}
.measurable-badge-sm { .measurable-badge-sm { font-size: 12px; color: #e8734a; margin-left: auto; }
font-size: 12px;
color: #e8734a;
margin-left: auto;
}
.cart-total-row { .cart-total-row {
border-top: 1px solid #dde8da; border-top: 1px solid #dde8da;
@ -610,25 +762,11 @@
margin-bottom: 10px; margin-bottom: 10px;
} }
.cart-total-note .fa { .cart-total-note .fa { color: #f4a236; margin-top: 1px; flex-shrink: 0; }
color: #f4a236;
margin-top: 1px;
flex-shrink: 0;
}
.cart-total { .cart-total { display: flex; justify-content: space-between; align-items: center; font-size: 14px; color: #2c2c2c; }
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
color: #2c2c2c;
}
.cart-total strong { .cart-total strong { font-size: 18px; font-weight: 800; color: #1a3c22; }
font-size: 18px;
font-weight: 800;
color: #1a3c22;
}
#cartActions { #cartActions {
padding: 14px 18px; padding: 14px 18px;
@ -655,14 +793,8 @@
letter-spacing: 0.2px; letter-spacing: 0.2px;
} }
.btn-checkout:hover { .btn-checkout:hover { background: #1a3c22; }
background: #1a3c22; .btn-checkout .fa { font-size: 16px; color: #f4a236; }
}
.btn-checkout .fa {
font-size: 16px;
color: #f4a236;
}
.btn-view-cart { .btn-view-cart {
display: flex; display: flex;
@ -681,9 +813,7 @@
transition: background 0.18s; transition: background 0.18s;
} }
.btn-view-cart:hover { .btn-view-cart:hover { background: #dde8da; }
background: #dde8da;
}
/* /*
TOAST NOTIFICATION TOAST NOTIFICATION
@ -707,50 +837,36 @@
border-left: 4px solid #f4a236; border-left: 4px solid #f4a236;
} }
.qo-toast.show { .qo-toast.show { opacity: 1; transform: translateY(0); }
opacity: 1; .qo-toast .fa { color: #8cb63c; margin-right: 6px; }
transform: translateY(0);
}
.qo-toast .fa {
color: #8cb63c;
margin-right: 6px;
}
/* /*
RESPONSIVE RESPONSIVE
*/ */
@media (max-width: 960px) { @media (max-width: 960px) {
.qo-layout { .qo-layout { grid-template-columns: 1fr; }
grid-template-columns: 1fr; .qo-cart-panel { position: static; }
}
.qo-cart-panel { .ds-day-buttons { gap: 6px; }
position: static; .ds-day-btn { min-width: 64px; padding: 8px 12px; }
}
} }
@media (max-width: 600px) { @media (max-width: 600px) {
.quick-order-page { .quick-order-page { width: 100%; padding: 12px 12px 40px; }
width: 100%;
padding: 12px 12px 40px;
}
.product-card { .ds-header { padding: 14px 16px; }
flex-wrap: wrap; .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 { .qo-delivery-chip { flex-wrap: wrap; gap: 6px; }
flex: 1 1 100%; #deliveryChipText { flex: 1 1 100%; order: 3; }
} .dc-change-btn { margin-left: 0; }
.pc-actions { .product-card { flex-wrap: wrap; }
width: 100%; .pc-body { flex: 1 1 100%; }
justify-content: flex-end; .pc-actions { width: 100%; justify-content: flex-end; }
}
.qo-search-btn { .qo-search-btn { padding: 0 14px; font-size: 13px; }
padding: 0 14px;
font-size: 13px;
}
} }