Compare commits
2 Commits
000f1de2dd
...
51f546caec
| Author | SHA1 | Date |
|---|---|---|
|
|
51f546caec | |
|
|
8e1b3f2a5d |
|
|
@ -591,6 +591,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
|
|||
throw new Exception($"{errorText}");
|
||||
}
|
||||
|
||||
//itt vajon elég ez a vizsgálat, vagy a priceCalculationService.GetFinalPriceAsync-al kéne lekérni a végső árat és azt összehasonlítani? - A.
|
||||
//ha kedvezménye is van, de manuálisan is le van csökkentve az ár, akkor a kedvezményt látja a rendszer, és azt kellene összevetni a bejövő árral... - A.
|
||||
if (orderProductItem.Price != product.Price)
|
||||
{
|
||||
//manual price change
|
||||
|
|
@ -601,13 +603,15 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
|
|||
unitPricesIncludeDiscounts = true;
|
||||
}
|
||||
|
||||
|
||||
//itt ha includeDiscounts van, akkor már a beírt ár megy be?
|
||||
var orderItem = await CreateOrderItem(product, order, orderProductItem, isMeasurable, unitPricesIncludeDiscounts, customer, store);
|
||||
|
||||
_logger.Detail($"Adding order item: ProductId: {orderItem.ProductId}, Quantity: {orderItem.Quantity}, UnitPriceInclTax: {orderItem.UnitPriceInclTax}, UnitPriceExclTax: {orderItem.UnitPriceExclTax}, PriceInclTax: {orderItem.PriceInclTax}, PriceExclTax: {orderItem.PriceExclTax}");
|
||||
|
||||
await _orderService.InsertOrderItemAsync(orderItem);
|
||||
await _productService.AdjustInventoryAsync(product, -orderItem.Quantity, orderItem.AttributesXml, string.Format(await _localizationService.GetResourceAsync("Admin.StockQuantityHistory.Messages.PlaceOrder"), order.Id));
|
||||
|
||||
var priceCalculation = await _priceCalculationService.GetFinalPriceAsync(product, customer, store, includeDiscounts: false);
|
||||
var priceCalculation = await _priceCalculationService.GetFinalPriceAsync(product, customer, store, includeDiscounts: true);
|
||||
var unitPriceInclTaxValue = priceCalculation.finalPrice;
|
||||
|
||||
var (unitPriceExclTaxValue, _) = await _taxService.GetProductPriceAsync(product, unitPriceInclTaxValue, false, customer);
|
||||
|
|
@ -615,8 +619,12 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
|
|||
order.OrderSubtotalInclTax += unitPriceInclTaxValue * orderItem.Quantity;
|
||||
order.OrderSubtotalExclTax += unitPriceExclTaxValue * orderItem.Quantity;
|
||||
|
||||
order.OrderSubTotalDiscountInclTax += order.OrderSubtotalInclTax - orderItem.PriceInclTax;
|
||||
order.OrderSubTotalDiscountExclTax += order.OrderSubtotalExclTax - orderItem.PriceExclTax;
|
||||
var appliedDiscounts = priceCalculation.appliedDiscountAmount;
|
||||
var totalDiscountInclTax = appliedDiscounts * orderProductItem.Quantity;
|
||||
var totalDiscountExclTax = appliedDiscounts * orderProductItem.Quantity;
|
||||
|
||||
order.OrderSubTotalDiscountInclTax += totalDiscountInclTax;
|
||||
order.OrderSubTotalDiscountExclTax += totalDiscountExclTax;
|
||||
|
||||
//order.OrderTax
|
||||
//order.TaxRates
|
||||
|
|
@ -1751,7 +1759,235 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// FruitBank Order Grid – new server-side DataTables endpoint
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Returns the new FruitBank order list view (replaces the default NopCommerce grid).
|
||||
/// </summary>
|
||||
[CheckPermission(StandardPermission.Orders.ORDERS_VIEW)]
|
||||
public async Task<IActionResult> NewList(
|
||||
List<int> orderStatuses = null,
|
||||
List<int> paymentStatuses = null,
|
||||
List<int> shippingStatuses = null)
|
||||
{
|
||||
var model = await _orderModelFactory.PrepareOrderSearchModelAsync(new OrderSearchModelExtended
|
||||
{
|
||||
OrderStatusIds = orderStatuses,
|
||||
PaymentStatusIds = paymentStatuses,
|
||||
ShippingStatusIds = shippingStatuses,
|
||||
Length = 50,
|
||||
AvailablePageSizes = "20,50,100,500",
|
||||
SortColumn = "Id",
|
||||
SortColumnDirection = "desc",
|
||||
});
|
||||
model.SetGridSort("Id", "desc");
|
||||
model.SetGridPageSize(50, "20,50,100,500");
|
||||
|
||||
return View(
|
||||
"~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Order/FruitBankOrderList.cshtml",
|
||||
model);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DataTables server-side endpoint for the FruitBank order grid.
|
||||
/// Handles NopCommerce base filters + FruitBank-specific filters + per-column search + sort + pagination.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[CheckPermission(StandardPermission.Orders.ORDERS_VIEW)]
|
||||
public async Task<IActionResult> FruitBankOrderList()
|
||||
{
|
||||
// ── 1. Parse DataTables protocol params ────────────────────────
|
||||
_ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1);
|
||||
_ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0);
|
||||
_ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 50 : Math.Min(length, 500);
|
||||
|
||||
// Sort column
|
||||
_ = int.TryParse(Request.Form["order[0][column]"].FirstOrDefault(), out int sortColIdx);
|
||||
var sortDir = Request.Form["order[0][dir]"].FirstOrDefault() ?? "desc";
|
||||
var sortColName = Request.Form[$"columns[{sortColIdx}][data]"].FirstOrDefault() ?? "Id";
|
||||
|
||||
// Per-column search values keyed by column data-field name
|
||||
var colSearch = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (int ci = 0; Request.Form.ContainsKey($"columns[{ci}][data]"); ci++)
|
||||
{
|
||||
var cData = Request.Form[$"columns[{ci}][data]"].FirstOrDefault();
|
||||
var cVal = Request.Form[$"columns[{ci}][search][value]"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(cData) && !string.IsNullOrWhiteSpace(cVal))
|
||||
colSearch[cData] = cVal.Trim();
|
||||
}
|
||||
|
||||
// ── 2. Parse custom filter params ─────────────────────────────
|
||||
DateTime? startDate = null, endDate = null;
|
||||
if (DateTime.TryParse(Request.Form["StartDate"].FirstOrDefault(), out var sd)) startDate = sd;
|
||||
if (DateTime.TryParse(Request.Form["EndDate"].FirstOrDefault(), out var ed)) endDate = ed;
|
||||
|
||||
var orderStatusIds = Request.Form["OrderStatusIds"]
|
||||
.Where(v => int.TryParse(v, out _)).Select(int.Parse).Where(id => id > 0).ToList();
|
||||
var paymentStatusIds = Request.Form["PaymentStatusIds"]
|
||||
.Where(v => int.TryParse(v, out _)).Select(int.Parse).Where(id => id > 0).ToList();
|
||||
var shippingStatusIds = Request.Form["ShippingStatusIds"]
|
||||
.Where(v => int.TryParse(v, out _)).Select(int.Parse).Where(id => id > 0).ToList();
|
||||
|
||||
var billingCompany = Request.Form["BillingCompany"].FirstOrDefault(); // holds customer ID (string)
|
||||
|
||||
bool? isMeasurableFilter = null;
|
||||
var imStr = Request.Form["IsMeasurable"].FirstOrDefault();
|
||||
if (imStr == "true") isMeasurableFilter = true;
|
||||
if (imStr == "false") isMeasurableFilter = false;
|
||||
|
||||
bool? hasInnvoiceFilter = null;
|
||||
var hiStr = Request.Form["HasInnvoiceTechId"].FirstOrDefault();
|
||||
if (hiStr == "true") hasInnvoiceFilter = true;
|
||||
if (hiStr == "false") hasInnvoiceFilter = false;
|
||||
|
||||
// ── 3. Fetch data via factory (applies NopCommerce base filters)
|
||||
// We ask for a large page so all matching records come back in one shot;
|
||||
// FruitBank-specific filtering + pagination happen below in-process.
|
||||
var searchModel = new OrderSearchModelExtended
|
||||
{
|
||||
StartDate = startDate,
|
||||
EndDate = endDate,
|
||||
OrderStatusIds = orderStatusIds.Any() ? orderStatusIds : null,
|
||||
PaymentStatusIds = paymentStatusIds.Any() ? paymentStatusIds : null,
|
||||
ShippingStatusIds = shippingStatusIds.Any() ? shippingStatusIds : null,
|
||||
BillingCompany = billingCompany,
|
||||
SortColumn = "Id",
|
||||
SortColumnDirection = "desc"
|
||||
};
|
||||
// SetGridPageSize is the proper NopCommerce way to override Page/PageSize
|
||||
searchModel.SetGridPageSize(5000, "5000");
|
||||
|
||||
OrderListModelExtended orderListModel;
|
||||
try
|
||||
{
|
||||
orderListModel = await _orderModelFactory.PrepareOrderListModelExtendedAsync(searchModel);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"FruitBankOrderList – factory error: {ex.Message}", ex);
|
||||
return Json(new { draw, recordsTotal = 0, recordsFiltered = 0, data = Array.Empty<object>() });
|
||||
}
|
||||
|
||||
var rows = orderListModel.Data?.ToList() ?? new List<OrderModelExtended>();
|
||||
int total = orderListModel.RecordsTotal;
|
||||
|
||||
// ── 4. Map to lightweight DTO ──────────────────────────────────
|
||||
var dtos = rows.Select(o => new Nop.Plugin.Misc.FruitBankPlugin.Models.Orders.FruitBankOrderRowDto
|
||||
{
|
||||
Id = o.Id,
|
||||
CustomOrderNumber = o.CustomOrderNumber,
|
||||
CustomerCompany = o.CustomerCompany,
|
||||
CustomerId = o.CustomerId,
|
||||
InnvoiceTechId = o.InnvoiceTechId,
|
||||
IsAllOrderItemAvgWeightValid = o.IsAllOrderItemAvgWeightValid,
|
||||
IsMeasurable = o.IsMeasurable,
|
||||
MeasuringStatus = (int)o.MeasuringStatus,
|
||||
MeasuringStatusString = o.MeasuringStatusString,
|
||||
DateOfReceipt = o.DateOfReceipt,
|
||||
OrderStatusId = o.OrderStatusId,
|
||||
OrderStatus = o.OrderStatus,
|
||||
PaymentStatusId = o.PaymentStatusId,
|
||||
PaymentStatus = o.PaymentStatus,
|
||||
ShippingStatusId = o.ShippingStatusId,
|
||||
ShippingStatus = o.ShippingStatus,
|
||||
StoreName = o.StoreName,
|
||||
CreatedOn = o.CreatedOn,
|
||||
OrderTotal = o.OrderTotal
|
||||
}).ToList();
|
||||
|
||||
// ── 5. Apply FruitBank-specific top-level filters ──────────────
|
||||
if (isMeasurableFilter.HasValue)
|
||||
dtos = dtos.Where(o => o.IsMeasurable == isMeasurableFilter.Value).ToList();
|
||||
|
||||
if (hasInnvoiceFilter.HasValue)
|
||||
dtos = hasInnvoiceFilter.Value
|
||||
? dtos.Where(o => !string.IsNullOrEmpty(o.InnvoiceTechId)).ToList()
|
||||
: dtos.Where(o => string.IsNullOrEmpty(o.InnvoiceTechId)).ToList();
|
||||
|
||||
// ── 6. Apply per-column search ─────────────────────────────────
|
||||
foreach (var (col, val) in colSearch)
|
||||
{
|
||||
dtos = col.ToLowerInvariant() switch
|
||||
{
|
||||
"customordernumber" => dtos.Where(o => o.CustomOrderNumber?.Contains(val, StringComparison.OrdinalIgnoreCase) == true).ToList(),
|
||||
"customercompany" => dtos.Where(o => o.CustomerCompany?.Contains(val, StringComparison.OrdinalIgnoreCase) == true).ToList(),
|
||||
"orderstatusid" => int.TryParse(val, out int osId) ? dtos.Where(o => o.OrderStatusId == osId).ToList() : dtos,
|
||||
"measuringstatus" => int.TryParse(val, out int msId) ? dtos.Where(o => o.MeasuringStatus == msId).ToList() : dtos,
|
||||
"ismeasurable" => bool.TryParse(val, out bool bm) ? dtos.Where(o => o.IsMeasurable == bm).ToList() : dtos,
|
||||
// InnVoice column sends 'has' or 'none' strings
|
||||
"innvoicetechid" => val == "has" ? dtos.Where(o => !string.IsNullOrEmpty(o.InnvoiceTechId)).ToList()
|
||||
: val == "none" ? dtos.Where(o => string.IsNullOrEmpty(o.InnvoiceTechId)).ToList()
|
||||
: dtos,
|
||||
_ => dtos
|
||||
};
|
||||
}
|
||||
|
||||
int recordsFiltered = dtos.Count;
|
||||
|
||||
// ── 7. Sort ────────────────────────────────────────────────────
|
||||
bool asc = sortDir == "asc";
|
||||
dtos = sortColName.ToLowerInvariant() switch
|
||||
{
|
||||
"id" => asc ? dtos.OrderBy(o => o.Id).ToList() : dtos.OrderByDescending(o => o.Id).ToList(),
|
||||
"customordernumber" => asc ? dtos.OrderBy(o => o.CustomOrderNumber).ToList() : dtos.OrderByDescending(o => o.CustomOrderNumber).ToList(),
|
||||
"customercompany" => asc ? dtos.OrderBy(o => o.CustomerCompany).ToList() : dtos.OrderByDescending(o => o.CustomerCompany).ToList(),
|
||||
"dateofreceipt" => asc ? dtos.OrderBy(o => o.DateOfReceipt ?? DateTime.MinValue).ToList() : dtos.OrderByDescending(o => o.DateOfReceipt ?? DateTime.MinValue).ToList(),
|
||||
"createdon" => asc ? dtos.OrderBy(o => o.CreatedOn).ToList() : dtos.OrderByDescending(o => o.CreatedOn).ToList(),
|
||||
"orderstatusid" => asc ? dtos.OrderBy(o => o.OrderStatusId).ToList() : dtos.OrderByDescending(o => o.OrderStatusId).ToList(),
|
||||
"measuringstatus" => asc ? dtos.OrderBy(o => o.MeasuringStatus).ToList() : dtos.OrderByDescending(o => o.MeasuringStatus).ToList(),
|
||||
_ => dtos.OrderByDescending(o => o.Id).ToList()
|
||||
};
|
||||
|
||||
// ── 8. Paginate ────────────────────────────────────────────────
|
||||
var page = dtos.Skip(start).Take(length).ToList();
|
||||
|
||||
return Json(new { draw, recordsTotal = total, recordsFiltered, data = page });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inline-edit save endpoint. Currently supports DateOfReceipt.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[CheckPermission(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE)]
|
||||
public async Task<IActionResult> UpdateOrderField(int orderId, string field, string value)
|
||||
{
|
||||
try
|
||||
{
|
||||
var order = await _orderService.GetOrderByIdAsync(orderId);
|
||||
if (order == null)
|
||||
return Json(new { success = false, error = "Rendelés nem található" });
|
||||
|
||||
switch (field?.ToUpperInvariant())
|
||||
{
|
||||
case "DATEOFRECEIPT":
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
await _genericAttributeService.SaveAttributeAsync<DateTime?>(order, "DateOfReceipt", null);
|
||||
return Json(new { success = true, displayValue = (string)null });
|
||||
}
|
||||
if (DateTime.TryParse(value, out var newDate))
|
||||
{
|
||||
await _genericAttributeService.SaveAttributeAsync(order, "DateOfReceipt", newDate);
|
||||
return Json(new { success = true, displayValue = newDate.ToString("yyyy. MM. dd. HH:mm") });
|
||||
}
|
||||
return Json(new { success = false, error = "Érvénytelen dátum formátum" });
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,285 @@
|
|||
using FruitBank.Common.Entities;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nop.Core.Domain.Customers;
|
||||
using Nop.Core.Domain.Orders;
|
||||
using Nop.Core.Domain.Payments;
|
||||
using Nop.Data;
|
||||
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.Localization;
|
||||
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)]
|
||||
public class CustomerCreditController : BasePluginController
|
||||
{
|
||||
private readonly ICustomerCreditService _customerCreditService;
|
||||
private readonly ICustomerService _customerService;
|
||||
private readonly IRepository<Order> _orderRepository;
|
||||
private readonly IRepository<Customer> _customerRepository;
|
||||
private readonly CustomerCreditDbTable _customerCreditDbTable;
|
||||
private readonly IPermissionService _permissionService;
|
||||
private readonly ILocalizationService _localizationService;
|
||||
|
||||
public CustomerCreditController(
|
||||
ICustomerCreditService customerCreditService,
|
||||
ICustomerService customerService,
|
||||
IRepository<Order> orderRepository,
|
||||
IRepository<Customer> customerRepository,
|
||||
CustomerCreditDbTable customerCreditDbTable,
|
||||
IPermissionService permissionService,
|
||||
ILocalizationService localizationService)
|
||||
{
|
||||
_customerCreditService = customerCreditService;
|
||||
_customerService = customerService;
|
||||
_orderRepository = orderRepository;
|
||||
_customerRepository = customerRepository;
|
||||
_customerCreditDbTable = customerCreditDbTable;
|
||||
_permissionService = permissionService;
|
||||
_localizationService = localizationService;
|
||||
}
|
||||
|
||||
// ── LIST PAGE ─────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet]
|
||||
[Route("Admin/CustomerCredit/List")]
|
||||
public async Task<IActionResult> List()
|
||||
{
|
||||
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||
return AccessDeniedView();
|
||||
|
||||
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/CustomerCredit/List.cshtml");
|
||||
}
|
||||
|
||||
// ── DATATABLES SERVER-SIDE ENDPOINT ──────────────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
[Route("Admin/CustomerCredit/CustomerCreditList")]
|
||||
public async Task<IActionResult> CustomerCreditList()
|
||||
{
|
||||
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||
return Json(new { draw = 1, recordsTotal = 0, recordsFiltered = 0, data = Array.Empty<object>() });
|
||||
|
||||
_ = 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}][data]"].FirstOrDefault() ?? "OutstandingBalance";
|
||||
|
||||
var globalSearch = Request.Form["search[value]"].FirstOrDefault()?.Trim() ?? "";
|
||||
|
||||
// 1. Customers — single query
|
||||
var customers = await _customerRepository.Table
|
||||
.Where(c => !c.Deleted && c.Active && c.Email != null)
|
||||
.Select(c => new { c.Id, c.Email, c.FirstName, c.LastName })
|
||||
.ToListAsync();
|
||||
|
||||
// 2. Credit records — single query
|
||||
var credits = await _customerCreditDbTable.GetAll().ToListAsync();
|
||||
var creditByCustomer = credits.ToDictionary(x => x.CustomerId);
|
||||
|
||||
// 3. Outstanding balances — single grouped query, no N+1
|
||||
var outstandingByCustomer = await _orderRepository.Table
|
||||
.Where(o =>
|
||||
o.OrderStatusId != (int)OrderStatus.Cancelled &&
|
||||
(o.PaymentStatusId == (int)PaymentStatus.Pending ||
|
||||
o.PaymentStatusId == (int)PaymentStatus.PartiallyRefunded))
|
||||
.GroupBy(o => o.CustomerId)
|
||||
.Select(g => new { CustomerId = g.Key, Total = g.Sum(o => (decimal?)o.OrderTotal) ?? 0m })
|
||||
.ToListAsync();
|
||||
var outstandingDict = outstandingByCustomer.ToDictionary(x => x.CustomerId, x => x.Total);
|
||||
|
||||
// 4. Build rows
|
||||
var rows = customers.Select(c =>
|
||||
{
|
||||
creditByCustomer.TryGetValue(c.Id, out var credit);
|
||||
outstandingDict.TryGetValue(c.Id, out var outstanding);
|
||||
var hasLimit = credit != null;
|
||||
var remaining = hasLimit ? credit!.CreditLimit - outstanding : (decimal?)null;
|
||||
|
||||
return new CustomerCreditListRow
|
||||
{
|
||||
CustomerId = c.Id,
|
||||
CustomerEmail = c.Email ?? string.Empty,
|
||||
CustomerName = $"{c.FirstName} {c.LastName}".Trim(),
|
||||
HasCreditLimit = hasLimit,
|
||||
CreditLimit = credit?.CreditLimit ?? 0m,
|
||||
OutstandingBalance = outstanding,
|
||||
RemainingCredit = remaining,
|
||||
Comment = credit?.Comment
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
int recordsTotal = rows.Count;
|
||||
|
||||
// 5. Global search
|
||||
if (!string.IsNullOrWhiteSpace(globalSearch))
|
||||
{
|
||||
rows = rows.Where(r =>
|
||||
r.CustomerName.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) ||
|
||||
r.CustomerEmail.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) ||
|
||||
(r.Comment?.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) ?? false)
|
||||
).ToList();
|
||||
}
|
||||
|
||||
int recordsFiltered = rows.Count;
|
||||
|
||||
// 6. Sort
|
||||
bool asc = sortDir == "asc";
|
||||
rows = sortColName.ToLowerInvariant() switch
|
||||
{
|
||||
"customername" => asc ? rows.OrderBy(r => r.CustomerName).ToList() : rows.OrderByDescending(r => r.CustomerName).ToList(),
|
||||
"customeremail" => asc ? rows.OrderBy(r => r.CustomerEmail).ToList() : rows.OrderByDescending(r => r.CustomerEmail).ToList(),
|
||||
"creditlimit" => asc ? rows.OrderBy(r => r.CreditLimit).ToList() : rows.OrderByDescending(r => r.CreditLimit).ToList(),
|
||||
"outstandingbalance" => asc ? rows.OrderBy(r => r.OutstandingBalance).ToList() : rows.OrderByDescending(r => r.OutstandingBalance).ToList(),
|
||||
"remainingcredit" => asc ? rows.OrderBy(r => r.RemainingCredit ?? decimal.MaxValue).ToList() : rows.OrderByDescending(r => r.RemainingCredit ?? decimal.MinValue).ToList(),
|
||||
_ => rows.OrderByDescending(r => r.OutstandingBalance).ToList()
|
||||
};
|
||||
|
||||
// 7. Paginate
|
||||
var page = rows.Skip(start).Take(length).ToList();
|
||||
|
||||
return Json(new { draw, recordsTotal, recordsFiltered, data = page });
|
||||
}
|
||||
|
||||
// ── INLINE EDIT: CREDIT LIMIT ─────────────────────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
[Route("Admin/CustomerCredit/UpdateCreditLimit")]
|
||||
public async Task<IActionResult> UpdateCreditLimit(int customerId, string? creditLimit, bool removeLimit, string? comment)
|
||||
{
|
||||
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||
return Json(new { success = false, error = "Access denied" });
|
||||
|
||||
try
|
||||
{
|
||||
var existing = await _customerCreditService.GetByCustomerIdAsync(customerId);
|
||||
var outstanding = await _customerCreditService.GetOutstandingBalanceAsync(customerId);
|
||||
|
||||
// Empty input or explicit removeLimit flag → delete record = unlimited
|
||||
if (removeLimit || string.IsNullOrWhiteSpace(creditLimit))
|
||||
{
|
||||
if (existing != null)
|
||||
await _customerCreditService.DeleteAsync(existing);
|
||||
|
||||
return Json(new
|
||||
{
|
||||
success = true,
|
||||
hasLimit = false,
|
||||
creditLimit = (decimal?)null,
|
||||
outstanding,
|
||||
remaining = (decimal?)null
|
||||
});
|
||||
}
|
||||
|
||||
// Parse the value (JS sends invariant decimal)
|
||||
if (!decimal.TryParse(creditLimit,
|
||||
System.Globalization.NumberStyles.Any,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out var limit) || limit < 0)
|
||||
return Json(new { success = false, error = "Érvénytelen összeg" });
|
||||
|
||||
var entity = existing ?? new CustomerCredit { CustomerId = customerId };
|
||||
entity.CreditLimit = limit;
|
||||
if (comment != null) entity.Comment = comment;
|
||||
|
||||
await _customerCreditService.SaveAsync(entity);
|
||||
|
||||
return Json(new
|
||||
{
|
||||
success = true,
|
||||
hasLimit = true,
|
||||
creditLimit = limit,
|
||||
outstanding,
|
||||
remaining = limit - outstanding
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Json(new { success = false, error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
// ── DETAILS ───────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet]
|
||||
[Route("Admin/CustomerCredit/Details/{customerId:int}")]
|
||||
public async Task<IActionResult> Details(int customerId)
|
||||
{
|
||||
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||
return AccessDeniedView();
|
||||
|
||||
var customer = await _customerService.GetCustomerByIdAsync(customerId);
|
||||
if (customer == null)
|
||||
return NotFound();
|
||||
|
||||
var credit = await _customerCreditService.GetByCustomerIdAsync(customerId);
|
||||
var outstanding = await _customerCreditService.GetOutstandingBalanceAsync(customerId);
|
||||
|
||||
var unpaidOrders = await _orderRepository.Table
|
||||
.Where(o =>
|
||||
o.CustomerId == customerId &&
|
||||
o.OrderStatusId != (int)OrderStatus.Cancelled &&
|
||||
(o.PaymentStatusId == (int)PaymentStatus.Pending ||
|
||||
o.PaymentStatusId == (int)PaymentStatus.PartiallyRefunded))
|
||||
.OrderByDescending(o => o.CreatedOnUtc)
|
||||
.ToListAsync();
|
||||
|
||||
var model = new CustomerCreditModel
|
||||
{
|
||||
CustomerId = customerId,
|
||||
CustomerEmail = customer.Email,
|
||||
CustomerName = $"{customer.FirstName} {customer.LastName}".Trim(),
|
||||
CreditId = credit?.Id ?? 0,
|
||||
CreditLimit = credit?.CreditLimit ?? 0m,
|
||||
Comment = credit?.Comment,
|
||||
OutstandingBalance = outstanding,
|
||||
RemainingCredit = credit != null ? credit.CreditLimit - outstanding : (decimal?)null,
|
||||
HasCreditLimit = credit != null,
|
||||
UnpaidOrders = unpaidOrders.Select(o => new CustomerCreditOrderRow
|
||||
{
|
||||
OrderId = o.Id,
|
||||
OrderTotal = o.OrderTotal,
|
||||
CreatedOnUtc = o.CreatedOnUtc,
|
||||
OrderStatus = o.OrderStatus.ToString(),
|
||||
PaymentStatus = o.PaymentStatus.ToString()
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/CustomerCredit/Details.cshtml", model);
|
||||
}
|
||||
|
||||
// ── SAVE (from Details page) ──────────────────────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
[Route("Admin/CustomerCredit/Save")]
|
||||
public async Task<IActionResult> Save(CustomerCreditModel model)
|
||||
{
|
||||
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||
return AccessDeniedView();
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
return RedirectToAction("Details", new { customerId = model.CustomerId });
|
||||
|
||||
var entity = model.CreditId > 0
|
||||
? await _customerCreditService.GetByCustomerIdAsync(model.CustomerId) ?? new CustomerCredit()
|
||||
: new CustomerCredit();
|
||||
|
||||
entity.CustomerId = model.CustomerId;
|
||||
entity.CreditLimit = model.CreditLimit;
|
||||
entity.Comment = model.Comment;
|
||||
|
||||
await _customerCreditService.SaveAsync(entity);
|
||||
|
||||
return RedirectToAction("Details", new { customerId = model.CustomerId });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models;
|
||||
|
||||
public class CustomerCreditListRow
|
||||
{
|
||||
public int CustomerId { get; set; }
|
||||
public string CustomerEmail { get; set; } = string.Empty;
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public bool HasCreditLimit { get; set; }
|
||||
public decimal CreditLimit { get; set; }
|
||||
public decimal OutstandingBalance { get; set; }
|
||||
public decimal? RemainingCredit { get; set; }
|
||||
public string? Comment { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models;
|
||||
|
||||
public class CustomerCreditModel
|
||||
{
|
||||
public int CustomerId { get; set; }
|
||||
public string CustomerEmail { get; set; } = string.Empty;
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
|
||||
// Credit record
|
||||
public int CreditId { get; set; }
|
||||
public decimal CreditLimit { get; set; }
|
||||
public string? Comment { get; set; }
|
||||
public bool HasCreditLimit { get; set; }
|
||||
|
||||
// Calculated
|
||||
public decimal OutstandingBalance { get; set; }
|
||||
public decimal? RemainingCredit { get; set; }
|
||||
|
||||
// Unpaid orders table
|
||||
public List<CustomerCreditOrderRow> UnpaidOrders { get; set; } = new();
|
||||
}
|
||||
|
||||
public class CustomerCreditOrderRow
|
||||
{
|
||||
public int OrderId { get; set; }
|
||||
public decimal OrderTotal { get; set; }
|
||||
public DateTime CreatedOnUtc { get; set; }
|
||||
public string OrderStatus { get; set; } = string.Empty;
|
||||
public string PaymentStatus { get; set; } = string.Empty;
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
@model Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models.CustomerCreditModel
|
||||
@using Nop.Web.Framework.UI
|
||||
|
||||
@{
|
||||
// Layout = "_FruitBankAdminLayout";
|
||||
var remaining = Model.RemainingCredit;
|
||||
var statusClass = !Model.HasCreditLimit ? "status-unlimited"
|
||||
: remaining <= 0 ? "status-blocked"
|
||||
: remaining < Model.CreditLimit * 0.2m ? "status-warning"
|
||||
: "status-ok";
|
||||
}
|
||||
|
||||
<style>
|
||||
.credit-summary { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
|
||||
.credit-card { flex: 1; min-width: 160px; background: #fff; border: 1px solid #e0e0e0; border-radius: 8px; padding: 1rem 1.25rem; }
|
||||
.credit-card .label { font-size: 0.78rem; color: #666; margin-bottom: .3rem; }
|
||||
.credit-card .value { font-size: 1.4rem; font-weight: 700; }
|
||||
.status-ok .value { color: #2d7a3a; }
|
||||
.status-warning .value { color: #f4a236; }
|
||||
.status-blocked .value { color: #c0392b; }
|
||||
.status-unlimited .value { color: #555; }
|
||||
.back-link { margin-bottom: 1rem; display: inline-block; }
|
||||
</style>
|
||||
|
||||
<a class="back-link" href="/Admin/Customer/Edit/@Model.CustomerId">
|
||||
<i class="fa fa-arrow-left"></i> @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.BackToCustomer")
|
||||
</a>
|
||||
|
||||
<div class="content-header clearfix">
|
||||
<h1 class="pull-left">
|
||||
@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle") — @Model.CustomerName (@Model.CustomerEmail)
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@* ── Summary cards ── *@
|
||||
<div class="credit-summary">
|
||||
<div class="credit-card">
|
||||
<div class="label">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit")</div>
|
||||
<div class="value">@(Model.HasCreditLimit ? Model.CreditLimit.ToString("N0") + " Ft" : "—")</div>
|
||||
</div>
|
||||
<div class="credit-card">
|
||||
<div class="label">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance")</div>
|
||||
<div class="value">@Model.OutstandingBalance.ToString("N0") Ft</div>
|
||||
</div>
|
||||
<div class="credit-card @statusClass">
|
||||
<div class="label">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit")</div>
|
||||
<div class="value">
|
||||
@if (!Model.HasCreditLimit)
|
||||
{
|
||||
@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited")
|
||||
}
|
||||
else
|
||||
{
|
||||
@remaining!.Value.ToString("N0")
|
||||
<span style="font-size:.9rem">Ft</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Edit form ── *@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.EditTitle")</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form asp-action="Save" asp-controller="CustomerCredit" asp-area="Admin" method="post">
|
||||
<input type="hidden" name="CustomerId" value="@Model.CustomerId" />
|
||||
<input type="hidden" name="CreditId" value="@Model.CreditId" />
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3 col-form-label">
|
||||
<label>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit")</label>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<input type="number" name="CreditLimit" value="@Model.CreditLimit" min="0" step="1000" class="form-control" style="max-width:240px" />
|
||||
<small class="form-text text-muted">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimitHint")</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3 col-form-label">
|
||||
<label>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment")</label>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<textarea name="Comment" class="form-control" rows="3" style="max-width:480px">@Model.Comment</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-md-9 offset-md-3">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fa fa-save"></i> @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Save")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Unpaid orders table ── *@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.UnpaidOrdersTitle") (@Model.UnpaidOrders.Count)</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
@if (!Model.UnpaidOrders.Any())
|
||||
{
|
||||
<p class="text-muted">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.NoUnpaidOrders")</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-bordered table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderId")</th>
|
||||
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderDate")</th>
|
||||
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderTotal")</th>
|
||||
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderStatus")</th>
|
||||
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.PaymentStatus")</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var o in Model.UnpaidOrders)
|
||||
{
|
||||
<tr>
|
||||
<td>#@o.OrderId</td>
|
||||
<td>@o.CreatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd HH:mm")</td>
|
||||
<td><strong>@o.OrderTotal.ToString("N0") Ft</strong></td>
|
||||
<td>@o.OrderStatus</td>
|
||||
<td>@o.PaymentStatus</td>
|
||||
<td>
|
||||
<a href="/Admin/Order/Edit/@o.OrderId" class="btn btn-xs btn-default" target="_blank">
|
||||
<i class="fa fa-external-link"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="2"><strong>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Total")</strong></td>
|
||||
<td><strong>@Model.OutstandingBalance.ToString("N0") Ft</strong></td>
|
||||
<td colspan="3"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
@{
|
||||
ViewBag.PageTitle = "Hitelkeretek";
|
||||
NopHtml.SetActiveMenuItemSystemName("CustomerCredit.List");
|
||||
}
|
||||
|
||||
<div class="content-header clearfix">
|
||||
<h1 class="float-left">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle")</h1>
|
||||
</div>
|
||||
|
||||
<section class="content">
|
||||
<div class="container-fluid">
|
||||
<div class="card card-default">
|
||||
<div class="card-body p-0">
|
||||
@Html.AntiForgeryToken()
|
||||
<table id="cc-grid" class="table table-bordered table-hover m-0" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CustomerName")</th>
|
||||
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CustomerEmail")</th>
|
||||
<th title="Kattintásra szerkeszthető — törléshez hagyd üresen">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit") <small class="text-muted">✏️</small></th>
|
||||
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance")</th>
|
||||
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit")</th>
|
||||
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment")</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* ── Editable credit limit cell ─────────────────────────────── */
|
||||
#cc-grid tbody td.cc-editable {
|
||||
cursor: pointer;
|
||||
}
|
||||
#cc-grid tbody td.cc-editable:hover {
|
||||
background-color: #fff8e1;
|
||||
}
|
||||
#cc-grid tbody td.cc-editable input[type="number"] {
|
||||
width: 130px;
|
||||
font-size: 13px;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid #80bdff;
|
||||
border-radius: 3px;
|
||||
}
|
||||
/* ── Status colours ─────────────────────────────────────────── */
|
||||
.cc-remaining-ok { color: #2d7a3a; font-weight: 600; }
|
||||
.cc-remaining-warning { color: #e67e22; font-weight: 600; }
|
||||
.cc-remaining-blocked { color: #c0392b; font-weight: 600; }
|
||||
.cc-remaining-none { color: #888; }
|
||||
/* ── Stripe + hover ─────────────────────────────────────────── */
|
||||
#cc-grid tbody tr:nth-child(even) { background-color: #f9f9f9; }
|
||||
#cc-grid tbody tr:hover { background-color: #eaf2ff; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
$(function () {
|
||||
|
||||
var _token = $('input[name="__RequestVerificationToken"]').val();
|
||||
|
||||
function fmt(val) {
|
||||
if (val == null) return '—';
|
||||
return Number(val).toLocaleString('hu-HU') + ' Ft';
|
||||
}
|
||||
|
||||
function renderRemaining(row) {
|
||||
if (!row.HasCreditLimit) return '<span class="cc-remaining-none">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited").Text</span>';
|
||||
var r = row.RemainingCredit;
|
||||
var cls = r <= 0 ? 'cc-remaining-blocked'
|
||||
: r < row.CreditLimit * 0.2 ? 'cc-remaining-warning'
|
||||
: 'cc-remaining-ok';
|
||||
return '<span class="' + cls + '">' + fmt(r) + '</span>';
|
||||
}
|
||||
|
||||
function renderCreditLimit(row) {
|
||||
if (!row.HasCreditLimit) return '<span class="text-muted">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited").Text</span>';
|
||||
return fmt(row.CreditLimit);
|
||||
}
|
||||
|
||||
var table = $('#cc-grid').DataTable({
|
||||
serverSide : true,
|
||||
processing : true,
|
||||
orderCellsTop: true,
|
||||
pageLength : 25,
|
||||
lengthMenu : [[10, 25, 50, 100], [10, 25, 50, 100]],
|
||||
order : [[3, 'desc']],
|
||||
language : {
|
||||
processing : 'Betöltés...',
|
||||
search : 'Keresés:',
|
||||
lengthMenu : '_MENU_ sor/oldal',
|
||||
info : '_START_ – _END_ / _TOTAL_ ügyfél',
|
||||
infoEmpty : '0 ügyfél',
|
||||
infoFiltered : '(szűrve _MAX_-ból)',
|
||||
paginate : { first: '««', previous: '«', next: '»', last: '»»' },
|
||||
emptyTable : 'Nincs találat',
|
||||
zeroRecords : 'Nincs találat'
|
||||
},
|
||||
ajax: {
|
||||
url : '/Admin/CustomerCredit/CustomerCreditList',
|
||||
type: 'POST',
|
||||
data: function (d) {
|
||||
d.__RequestVerificationToken = _token;
|
||||
},
|
||||
error: function (xhr) {
|
||||
console.error('CustomerCreditList error:', xhr.status, xhr.responseText);
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
/* 0 */ { data: 'CustomerName', name: 'CustomerName' },
|
||||
/* 1 */ { data: 'CustomerEmail', name: 'CustomerEmail' },
|
||||
/* 2 */ { data: 'CreditLimit', name: 'CreditLimit', className: 'cc-editable text-right',
|
||||
render: function (d, t, row) { return renderCreditLimit(row); } },
|
||||
/* 3 */ { data: 'OutstandingBalance', name: 'OutstandingBalance', className: 'text-right',
|
||||
render: function (d) {
|
||||
var color = d > 0 ? 'color:#c0392b;font-weight:600' : '';
|
||||
return '<span style="' + color + '">' + fmt(d) + '</span>';
|
||||
}},
|
||||
/* 4 */ { data: 'RemainingCredit', name: 'RemainingCredit',
|
||||
render: function (d, t, row) { return renderRemaining(row); } },
|
||||
/* 5 */ { data: 'Comment', name: 'Comment', orderable: false,
|
||||
render: function (d) { return d ? '<span class="text-muted">' + d + '</span>' : ''; } },
|
||||
/* 6 */ { data: 'CustomerId', name: null, orderable: false, searchable: false, width: '80px', className: 'text-center',
|
||||
render: function (d) {
|
||||
return '<a href="/Admin/CustomerCredit/Details/' + d + '" class="btn btn-default btn-xs" title="Részletek"><i class="fas fa-edit"></i></a>' +
|
||||
' <a href="/Admin/Customer/Edit/' + d + '" class="btn btn-default btn-xs" title="Ügyfél szerkesztése"><i class="fas fa-user"></i></a>';
|
||||
}}
|
||||
]
|
||||
});
|
||||
|
||||
/* ── Inline editing: CreditLimit ─────────────────────────────── */
|
||||
$(document).on('click', '#cc-grid tbody td.cc-editable', function () {
|
||||
var $td = $(this);
|
||||
if ($td.find('input').length) return;
|
||||
|
||||
var $row = $td.closest('tr');
|
||||
var rowData = table.row($row).data();
|
||||
if (!rowData) return;
|
||||
|
||||
var savedHtml = $td.html();
|
||||
var current = rowData.HasCreditLimit ? rowData.CreditLimit : '';
|
||||
|
||||
var $inp = $('<input type="number" min="0" step="1000" placeholder="Korlátlan (törléshez hagyd üresen)">')
|
||||
.val(current)
|
||||
.css({ width: '180px', fontSize: '13px' });
|
||||
|
||||
$td.html('').append($inp);
|
||||
$inp.focus().select();
|
||||
|
||||
function restore() { $td.html(savedHtml); }
|
||||
|
||||
function persist() {
|
||||
var raw = $inp.val().trim();
|
||||
var removeLimit = raw === ''; // empty = remove limit → unlimited
|
||||
var newVal = removeLimit ? null : parseFloat(raw);
|
||||
|
||||
// If a number was typed but is invalid or negative, cancel
|
||||
if (!removeLimit && (isNaN(newVal) || newVal < 0)) { restore(); return; }
|
||||
|
||||
// No change: still has limit and same value
|
||||
if (!removeLimit && rowData.HasCreditLimit && newVal === rowData.CreditLimit) { restore(); return; }
|
||||
|
||||
// No change: was already unlimited and still wants unlimited
|
||||
if (removeLimit && !rowData.HasCreditLimit) { restore(); return; }
|
||||
|
||||
$.ajax({
|
||||
url : '/Admin/CustomerCredit/UpdateCreditLimit',
|
||||
type : 'POST',
|
||||
data : {
|
||||
__RequestVerificationToken : _token,
|
||||
customerId : rowData.CustomerId,
|
||||
creditLimit : removeLimit ? '' : newVal, // empty string signals "remove"
|
||||
removeLimit : removeLimit,
|
||||
comment : rowData.Comment || ''
|
||||
},
|
||||
success: function (res) {
|
||||
if (res.success) {
|
||||
rowData.CreditLimit = res.creditLimit;
|
||||
rowData.OutstandingBalance = res.outstanding;
|
||||
rowData.RemainingCredit = res.remaining;
|
||||
rowData.HasCreditLimit = res.hasLimit;
|
||||
table.row($row).data(rowData).invalidate().draw(false);
|
||||
} else {
|
||||
restore();
|
||||
alert('Mentési hiba: ' + (res.error || 'Ismeretlen hiba'));
|
||||
}
|
||||
},
|
||||
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(); }
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
@ -0,0 +1,634 @@
|
|||
@model Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models.Order.OrderSearchModelExtended
|
||||
|
||||
@using FruitBank.Common.Interfaces
|
||||
@using Nop.Services.Stores
|
||||
@using Nop.Web.Areas.Admin.Components
|
||||
@using Nop.Web.Areas.Admin.Models.Orders
|
||||
@using Nop.Web.Framework.Infrastructure
|
||||
@inject IStoreService storeService
|
||||
|
||||
@{
|
||||
// Layout = "~/Areas/Admin/Views/Shared/_LayoutAdmin.cshtml";
|
||||
ViewBag.PageTitle = "FruitBank Rendelések";
|
||||
NopHtml.SetActiveMenuItemSystemName("Orders");
|
||||
}
|
||||
|
||||
@* ── Action buttons ─────────────────────────────────────────────── *@
|
||||
<form id="fb-header-form" asp-controller="Order" asp-action="List" method="post">
|
||||
<div class="content-header clearfix">
|
||||
<h1 class="float-left">Rendelések</h1>
|
||||
<div class="float-right">
|
||||
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#create-order-window">
|
||||
<i class="fas fa-plus"></i> @T("Admin.Common.AddNew")
|
||||
</button>
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-success"><i class="fas fa-download"></i> @T("Admin.Common.Export")</button>
|
||||
<button type="button" class="btn btn-success dropdown-toggle dropdown-icon" data-toggle="dropdown"><span class="sr-only"> </span></button>
|
||||
<ul class="dropdown-menu" role="menu">
|
||||
<li class="dropdown-item"><button asp-action="ExportXml" type="submit" name="exportxml-all"><i class="far fa-file-code"></i> @T("Admin.Common.ExportToXml.All")</button></li>
|
||||
<li class="dropdown-item"><button type="button" id="exportxml-selected"><i class="far fa-file-code"></i> @T("Admin.Common.ExportToXml.Selected")</button></li>
|
||||
<li class="dropdown-divider"></li>
|
||||
<li class="dropdown-item"><button asp-action="ExportExcel" type="submit" name="exportexcel-all"><i class="far fa-file-excel"></i> @T("Admin.Common.ExportToExcel.All")</button></li>
|
||||
<li class="dropdown-item"><button type="button" id="exportexcel-selected"><i class="far fa-file-excel"></i> @T("Admin.Common.ExportToExcel.Selected")</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-info"><i class="far fa-file-pdf"></i> @T("Admin.Orders.PdfInvoices")</button>
|
||||
<button type="button" class="btn btn-info dropdown-toggle dropdown-icon" data-toggle="dropdown"><span class="sr-only"> </span></button>
|
||||
<ul class="dropdown-menu" role="menu">
|
||||
<li class="dropdown-item"><button asp-action="PdfInvoice" type="submit" name="pdf-invoice-all">@T("Admin.Orders.PdfInvoices.All")</button></li>
|
||||
<li class="dropdown-item"><button type="button" id="pdf-invoice-selected">@T("Admin.Orders.PdfInvoices.Selected")</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section class="content">
|
||||
<div class="container-fluid">
|
||||
|
||||
@* ── Filter Panel ─────────────────────────────────────────────── *@
|
||||
<div class="card card-default card-search mb-2">
|
||||
<div class="card-body py-2">
|
||||
<div class="row align-items-end">
|
||||
@* Date from *@
|
||||
<div class="col-md-2">
|
||||
<div class="form-group mb-1">
|
||||
<nop-label asp-for="StartDate" />
|
||||
<nop-editor asp-for="StartDate" />
|
||||
</div>
|
||||
</div>
|
||||
@* Date to *@
|
||||
<div class="col-md-2">
|
||||
<div class="form-group mb-1">
|
||||
<nop-label asp-for="EndDate" />
|
||||
<nop-editor asp-for="EndDate" />
|
||||
</div>
|
||||
</div>
|
||||
@* Partner autocomplete → stores customer ID in hidden *@
|
||||
<div class="col-md-4">
|
||||
<div class="form-group mb-1">
|
||||
<label class="col-form-label">Partner</label>
|
||||
<div class="input-group">
|
||||
<input type="text" id="fb-company-display" autocomplete="off" class="form-control" placeholder="Cég neve..." />
|
||||
<div class="input-group-append">
|
||||
<button type="button" id="fb-company-clear" class="btn btn-outline-secondary" style="display:none">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input asp-for="BillingCompany" type="hidden" id="BillingCompany" />
|
||||
</div>
|
||||
</div>
|
||||
@* Go to order by number *@
|
||||
<div class="col-md-3">
|
||||
<div class="form-group mb-1">
|
||||
<nop-label asp-for="GoDirectlyToCustomOrderNumber" />
|
||||
<div class="input-group">
|
||||
<nop-editor asp-for="GoDirectlyToCustomOrderNumber" />
|
||||
<div class="input-group-append">
|
||||
<button type="button" id="go-to-order-by-number" class="btn btn-info">
|
||||
@T("Admin.Common.Go")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@* Search button *@
|
||||
<div class="col-md-1">
|
||||
<div class="form-group mb-1">
|
||||
<label class="col-form-label"> </label>
|
||||
<button type="button" id="fb-search-btn" class="btn btn-primary btn-block">
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Grid ─────────────────────────────────────────────────────── *@
|
||||
<div class="card card-default">
|
||||
<div class="card-body p-0">
|
||||
@* Anti-forgery token for AJAX POSTs *@
|
||||
@Html.AntiForgeryToken()
|
||||
<table id="fb-orders-grid" class="table table-bordered table-hover m-0" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" id="fb-check-all" title="Összes kijelölése"></th>
|
||||
<th>Rendelés #</th>
|
||||
<th>Partner</th>
|
||||
<th>InnVoice</th>
|
||||
<th>Súly</th>
|
||||
<th>Mérhető</th>
|
||||
<th>Mérés</th>
|
||||
<th title="Kattintásra szerkeszthető">Átvétel <small class="text-muted">✏️</small></th>
|
||||
<th>Státusz</th>
|
||||
<th>Fizetés</th>
|
||||
<th>Szállítás</th>
|
||||
<th>Létrehozva</th>
|
||||
<th>Összeg</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
<div id="fb-totals-row" class="card-footer py-2" style="display:none">
|
||||
<div id="fb-totals-content" class="small text-muted"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@* ── Export selected – XML ──────────────────────────────────────── *@
|
||||
<form asp-controller="Order" asp-action="ExportXmlSelected" method="post" id="export-xml-selected-form">
|
||||
<input type="hidden" id="export-xml-ids" name="selectedIds" value="" />
|
||||
</form>
|
||||
@* ── Export selected – Excel ────────────────────────────────────── *@
|
||||
<form asp-controller="Order" asp-action="ExportExcelSelected" method="post" id="export-excel-selected-form">
|
||||
<input type="hidden" id="export-excel-ids" name="selectedIds" value="" />
|
||||
</form>
|
||||
@* ── PDF selected ───────────────────────────────────────────────── *@
|
||||
<form asp-controller="Order" asp-action="PdfInvoiceSelected" method="post" id="pdf-invoice-selected-form">
|
||||
<input type="hidden" id="pdf-invoice-ids" name="selectedIds" value="" />
|
||||
</form>
|
||||
|
||||
@* ── Create Order Modal ─────────────────────────────────────────── *@
|
||||
<div id="create-order-window" class="modal fade" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">@T("Admin.Orders.AddNew")</h4>
|
||||
<button type="button" class="close" data-dismiss="modal"><span>×</span></button>
|
||||
</div>
|
||||
<form asp-controller="CustomOrder" asp-action="Create" method="post" id="create-order-form">
|
||||
<div class="form-horizontal">
|
||||
<div class="modal-body">
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3"><label class="col-form-label">@T("Admin.Orders.Fields.Customer")</label></div>
|
||||
<div class="col-md-9">
|
||||
<input type="text" id="create-order-customer-search" autocomplete="off" class="form-control" placeholder="Ügyfél neve vagy email..." />
|
||||
<span id="create-order-customer-name" class="mt-1 d-block"></span>
|
||||
<input type="hidden" id="create-order-customer-id" name="customerId" />
|
||||
<span class="field-validation-error" id="create-order-customer-error" style="display:none">Kérjük válasszon ügyfelet</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row" id="create-product-search-section" style="display:none">
|
||||
<div class="col-md-3"><label class="col-form-label">@T("Admin.Orders.Fields.Product")</label></div>
|
||||
<div class="col-md-9">
|
||||
<input type="text" id="create-order-product-search" autocomplete="off" class="form-control" placeholder="Termék neve vagy SKU..." />
|
||||
</div>
|
||||
</div>
|
||||
<div id="create-selected-products-section" style="display:none">
|
||||
<table class="table table-sm table-bordered" id="create-products-table">
|
||||
<thead><tr><th>Termék</th><th style="width:100px">Menny.</th><th style="width:120px">Egységár</th><th style="width:40px"></th></tr></thead>
|
||||
<tbody id="create-products-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<input type="hidden" id="create-order-products-json" name="orderProductsJson" />
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">@T("Admin.Common.Cancel")</button>
|
||||
<button type="submit" class="btn btn-primary">@T("Admin.Common.Create")</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<style>
|
||||
/* ── Column filter row ──────────────────────────────────────── */
|
||||
#fb-orders-grid thead tr.fb-filter-row th {
|
||||
padding: 3px 4px;
|
||||
background-color: #f4f6f9;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
}
|
||||
#fb-orders-grid thead tr.fb-filter-row input,
|
||||
#fb-orders-grid thead tr.fb-filter-row select {
|
||||
width: 100%;
|
||||
height: 26px;
|
||||
padding: 1px 4px;
|
||||
font-size: 11px;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
}
|
||||
#fb-orders-grid thead tr.fb-filter-row input:focus,
|
||||
#fb-orders-grid thead tr.fb-filter-row select:focus {
|
||||
outline: none;
|
||||
border-color: #80bdff;
|
||||
box-shadow: 0 0 0 2px rgba(0,123,255,.15);
|
||||
}
|
||||
/* ── Editable date cell ────────────────────────────────────── */
|
||||
#fb-orders-grid tbody td.fb-editable-date {
|
||||
cursor: pointer;
|
||||
}
|
||||
#fb-orders-grid tbody td.fb-editable-date:hover {
|
||||
background-color: #fff8e1;
|
||||
}
|
||||
#fb-orders-grid tbody td.fb-editable-date input[type="date"] {
|
||||
width: 120px;
|
||||
font-size: 12px;
|
||||
padding: 1px 4px;
|
||||
border: 1px solid #80bdff;
|
||||
border-radius: 3px;
|
||||
}
|
||||
/* ── Stripe rows ───────────────────────────────────────────── */
|
||||
#fb-orders-grid tbody tr:nth-child(even) {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
#fb-orders-grid tbody tr:hover {
|
||||
background-color: #eaf2ff;
|
||||
}
|
||||
/* ── Processing overlay ────────────────────────────────────── */
|
||||
#fb-orders-grid_processing {
|
||||
background: rgba(255,255,255,0.85);
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
/* autocomplete z-index fix in modals */
|
||||
.ui-autocomplete { z-index: 1060 !important; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
$(function () {
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────── */
|
||||
var _token = $('input[name="__RequestVerificationToken"]').val();
|
||||
|
||||
function antiForgery(obj) {
|
||||
obj['__RequestVerificationToken'] = _token;
|
||||
return obj;
|
||||
}
|
||||
|
||||
var selectedIds = [];
|
||||
|
||||
function getSelectedIds() {
|
||||
selectedIds = [];
|
||||
$('#fb-orders-grid tbody .fb-row-check:checked').each(function () {
|
||||
selectedIds.push($(this).val());
|
||||
});
|
||||
return selectedIds;
|
||||
}
|
||||
|
||||
/* ── Column renderers ────────────────────────────────────────── */
|
||||
function renderInnvoice(data) {
|
||||
return data
|
||||
? '<span class="badge badge-success">Igen</span>'
|
||||
: '<span class="badge badge-secondary">Nem</span>';
|
||||
}
|
||||
|
||||
function renderWeightValid(data) {
|
||||
return data
|
||||
? '<span class="badge badge-success">OK</span>'
|
||||
: '<span class="badge badge-danger font-weight-bold">!</span>';
|
||||
}
|
||||
|
||||
function renderMeasurable(data) {
|
||||
return data
|
||||
? '<span class="badge badge-info">Igen</span>'
|
||||
: '<span class="badge badge-light text-secondary">Nem</span>';
|
||||
}
|
||||
|
||||
function renderMeasuringStatus(val, row) {
|
||||
var map = { 10: 'warning', 20: 'primary', 30: 'success', 40: 'danger' };
|
||||
var cls = map[val] || 'secondary';
|
||||
var label = row.MeasuringStatusString || String(val);
|
||||
return '<span class="badge badge-' + cls + '">' + label + '</span>';
|
||||
}
|
||||
|
||||
function renderDateOfReceipt(data) {
|
||||
if (!data) return '<span class="text-muted">—</span>';
|
||||
var d = new Date(data);
|
||||
var dateStr = d.toLocaleDateString('hu-HU');
|
||||
var timeStr = d.toLocaleTimeString('hu-HU', { hour: '2-digit', minute: '2-digit' });
|
||||
return '<span>' + dateStr + ' ' + timeStr + '</span>';
|
||||
}
|
||||
|
||||
function renderOrderStatus(statusId, row) {
|
||||
var map = { 10: 'warning', 20: 'primary', 30: 'success', 40: 'danger' };
|
||||
var cls = map[statusId] || 'secondary';
|
||||
var label = row.OrderStatus || String(statusId);
|
||||
return '<span class="badge badge-' + cls + '">' + label + '</span>';
|
||||
}
|
||||
|
||||
/* ── DataTables ──────────────────────────────────────────────── */
|
||||
var table = $('#fb-orders-grid').DataTable({
|
||||
serverSide : true,
|
||||
processing : true,
|
||||
orderCellsTop: true,
|
||||
stateSave : false,
|
||||
pageLength : 50,
|
||||
lengthMenu : [[20, 50, 100, 200, 500], [20, 50, 100, 200, 500]],
|
||||
order : [[0, 'desc']],
|
||||
language : {
|
||||
processing : 'Betöltés...',
|
||||
search : 'Keresés:',
|
||||
lengthMenu : '_MENU_ sor/oldal',
|
||||
info : '_START_ – _END_ / _TOTAL_ rendelés',
|
||||
infoEmpty : '0 rendelés',
|
||||
infoFiltered : '(szűrve _MAX_-ból)',
|
||||
paginate : { first: '««', previous: '«', next: '»', last: '»»' },
|
||||
emptyTable : 'Nincs találat',
|
||||
zeroRecords : 'Nincs találat a szűrési feltételekre'
|
||||
},
|
||||
ajax: {
|
||||
url : '@Url.Action("FruitBankOrderList", "CustomOrder")',
|
||||
type: 'POST',
|
||||
data: function (d) {
|
||||
d.StartDate = $('#@Html.IdFor(m => m.StartDate)').val();
|
||||
d.EndDate = $('#@Html.IdFor(m => m.EndDate)').val();
|
||||
d.BillingCompany = $('#BillingCompany').val();
|
||||
addAntiForgeryToken(d);
|
||||
},
|
||||
error: function (xhr) {
|
||||
console.error('FruitBankOrderList AJAX error:', xhr.status, xhr.responseText);
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
/* 0 */ { data: 'Id', name: 'Id', orderable: false, searchable: false, width: '32px',
|
||||
className: 'text-center',
|
||||
render: function (d) { return '<input type="checkbox" class="fb-row-check" value="' + d + '">'; } },
|
||||
/* 1 */ { data: 'CustomOrderNumber', name: 'CustomOrderNumber', width: '95px' },
|
||||
/* 2 */ { data: 'CustomerCompany', name: 'CustomerCompany' },
|
||||
/* 3 */ { data: 'InnvoiceTechId', name: 'InnvoiceTechId', orderable: false, width: '75px',
|
||||
className: 'text-center',
|
||||
render: function (d) { return renderInnvoice(d); } },
|
||||
/* 4 */ { data: 'IsAllOrderItemAvgWeightValid', name: 'IsAllOrderItemAvgWeightValid', orderable: false, width: '55px',
|
||||
className: 'text-center',
|
||||
render: function (d) { return renderWeightValid(d); } },
|
||||
/* 5 */ { data: 'IsMeasurable', name: 'IsMeasurable', orderable: false, width: '65px',
|
||||
className: 'text-center',
|
||||
render: function (d) { return renderMeasurable(d); } },
|
||||
/* 6 */ { data: 'MeasuringStatus', name: 'MeasuringStatus', width: '95px',
|
||||
className: 'text-center',
|
||||
render: function (d, t, row) { return renderMeasuringStatus(d, row); } },
|
||||
/* 7 */ { data: 'DateOfReceipt', name: 'DateOfReceipt', width: '110px',
|
||||
className: 'text-center fb-editable-date',
|
||||
render: function (d) { return renderDateOfReceipt(d); } },
|
||||
/* 8 */ { data: 'OrderStatusId', name: 'OrderStatusId', width: '105px',
|
||||
className: 'text-center',
|
||||
render: function (d, t, row) { return renderOrderStatus(d, row); } },
|
||||
/* 9 */ { data: 'PaymentStatus', name: 'PaymentStatus', orderable: false, width: '110px',
|
||||
render: function (d) { return d || '—'; } },
|
||||
/* 10 */ { data: 'ShippingStatus', name: 'ShippingStatus', orderable: false, width: '110px',
|
||||
render: function (d) { return d || '—'; } },
|
||||
/* 11 */ { data: 'CreatedOn', name: 'CreatedOn', width: '92px',
|
||||
className: 'text-center',
|
||||
render: function (d) { return d ? new Date(d).toLocaleDateString('hu-HU') : '—'; } },
|
||||
/* 12 */ { data: 'OrderTotal', name: 'OrderTotal', orderable: false, width: '105px',
|
||||
className: 'text-right' },
|
||||
/* 13 */ { data: 'Id', name: null, orderable: false, searchable: false, width: '42px',
|
||||
className: 'text-center',
|
||||
render: function (d) { return '<a href="/Admin/Order/Edit/' + d + '" class="btn btn-default btn-xs" title="Szerkesztés"><i class="fas fa-pencil-alt"></i></a>'; } }
|
||||
],
|
||||
|
||||
/* ── Per-column filter row ─────────────────────────────────── */
|
||||
initComplete: function () {
|
||||
var api = this.api();
|
||||
var $thead = $(this).find('thead');
|
||||
var $filterRow = $('<tr class="fb-filter-row"></tr>').appendTo($thead);
|
||||
|
||||
// Filter definition per column index:
|
||||
// null = no filter, 'text' = text input, {type:'select', opts:[...]} = dropdown
|
||||
var defs = [
|
||||
null, /* 0 checkbox */
|
||||
'text', /* 1 order # */
|
||||
'text', /* 2 company */
|
||||
{ type: 'select', opts: [['', 'Mind'], ['has', '✓ Igen'], ['none', '✗ Nem']] }, /* 3 innvoice */
|
||||
null, /* 4 weight (no per-column filter) */
|
||||
{ type: 'select', opts: [['', 'Mind'], ['true', 'Igen'], ['false', 'Nem']] }, /* 5 measurable */
|
||||
{ type: 'select', opts: [['', 'Mind'], ['0', 'Nincs'], ['10', '…folyamat'], ['20', 'Mérésre'], ['30', 'Mérve'], ['40', 'Lezárva']] }, /* 6 measuring */
|
||||
null, /* 7 date (top-level filter handles this) */
|
||||
{ type: 'select', opts: [['', 'Mind'], ['10', 'Függőben'], ['20', 'Feldolgozás'], ['30', 'Teljesítve'], ['40', 'Törölve']] }, /* 8 order status */
|
||||
null, /* 9 payment */
|
||||
null, /* 10 shipping */
|
||||
null, /* 11 created */
|
||||
null, /* 12 total */
|
||||
null /* 13 button */
|
||||
];
|
||||
|
||||
api.columns().every(function (idx) {
|
||||
var col = this;
|
||||
var $th = $('<th></th>').appendTo($filterRow);
|
||||
var def = defs[idx];
|
||||
if (!def) return;
|
||||
|
||||
if (def === 'text') {
|
||||
var $inp = $('<input type="text" placeholder="🔍">');
|
||||
$inp.appendTo($th);
|
||||
var timer;
|
||||
$inp.on('input', function () {
|
||||
clearTimeout(timer);
|
||||
var v = this.value;
|
||||
timer = setTimeout(function () { col.search(v).draw(); }, 450);
|
||||
});
|
||||
} else if (def.type === 'select') {
|
||||
var $sel = $('<select></select>');
|
||||
def.opts.forEach(function (o) {
|
||||
$sel.append($('<option>').val(o[0]).text(o[1]));
|
||||
});
|
||||
$sel.appendTo($th);
|
||||
$sel.on('change', function () { col.search(this.value).draw(); });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/* ── Search / filter triggers ────────────────────────────────── */
|
||||
$('#fb-search-btn').on('click', function () { table.draw(); });
|
||||
|
||||
/* redraw on date change */
|
||||
$('#@Html.IdFor(m => m.StartDate), #@Html.IdFor(m => m.EndDate)').on('change', function () { table.draw(); });
|
||||
|
||||
/* ── Partner (company) autocomplete ──────────────────────────── */
|
||||
$('#fb-company-display').autocomplete({
|
||||
delay : 400,
|
||||
minLength: 2,
|
||||
source : '@Url.Action("CustomerSearchAutoComplete", "CustomOrder")',
|
||||
select : function (e, ui) {
|
||||
$('#BillingCompany').val(ui.item.value);
|
||||
$('#fb-company-display').val(ui.item.label);
|
||||
$('#fb-company-clear').show();
|
||||
table.draw();
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
$('#fb-company-clear').on('click', function () {
|
||||
$('#BillingCompany').val('');
|
||||
$('#fb-company-display').val('');
|
||||
$(this).hide();
|
||||
table.draw();
|
||||
});
|
||||
|
||||
/* ── Checkbox: select all on current page ────────────────────── */
|
||||
$('#fb-check-all').on('change', function () {
|
||||
var checked = this.checked;
|
||||
$('#fb-orders-grid tbody .fb-row-check').prop('checked', checked);
|
||||
});
|
||||
|
||||
/* ── Inline editing: DateOfReceipt ───────────────────────────── */
|
||||
$(document).on('click', '#fb-orders-grid tbody td.fb-editable-date', function (e) {
|
||||
var $td = $(this);
|
||||
if ($td.find('input').length) return; // already in edit mode
|
||||
|
||||
var $row = $td.closest('tr');
|
||||
var rowData = table.row($row).data();
|
||||
if (!rowData) return;
|
||||
|
||||
// Build a datetime-local string (YYYY-MM-DDTHH:mm) for the input value
|
||||
function toDatetimeLocal(iso) {
|
||||
if (!iso) return '';
|
||||
var d = new Date(iso);
|
||||
if (isNaN(d)) return '';
|
||||
var pad = function(n) { return String(n).padStart(2, '0'); };
|
||||
return d.getFullYear() + '-' + pad(d.getMonth()+1) + '-' + pad(d.getDate()) +
|
||||
'T' + pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||
}
|
||||
|
||||
var currentIso = rowData.DateOfReceipt || null;
|
||||
// Default to now if no date set yet
|
||||
var inputVal = currentIso ? toDatetimeLocal(currentIso) : toDatetimeLocal(new Date().toISOString());
|
||||
var orderId = rowData.Id;
|
||||
var savedHtml = $td.html();
|
||||
|
||||
var $inp = $('<input type="datetime-local">').val(inputVal);
|
||||
$td.html('').append($inp);
|
||||
$inp.focus();
|
||||
|
||||
function restore() { $td.html(savedHtml); }
|
||||
|
||||
function persist() {
|
||||
var newVal = $inp.val(); // format: YYYY-MM-DDTHH:mm
|
||||
// Compare against original ISO; skip save only if identical
|
||||
var newIso = newVal ? new Date(newVal).toISOString() : null;
|
||||
var oldIso = currentIso ? new Date(currentIso).toISOString() : null;
|
||||
if (newIso === oldIso) { restore(); return; }
|
||||
|
||||
$.ajax({
|
||||
url : '@Url.Action("UpdateOrderField", "CustomOrder")',
|
||||
type : 'POST',
|
||||
data : antiForgery({ orderId: orderId, field: 'DateOfReceipt', value: newVal }),
|
||||
success: function (res) {
|
||||
if (res.success) {
|
||||
rowData.DateOfReceipt = newVal || null;
|
||||
table.row($row).data(rowData).invalidate();
|
||||
/* re-render only the date cell without full redraw */
|
||||
$td.html(renderDateOfReceipt(newVal));
|
||||
} else {
|
||||
restore();
|
||||
alert('Mentési hiba: ' + (res.error || 'Ismeretlen hiba'));
|
||||
}
|
||||
},
|
||||
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(); }
|
||||
});
|
||||
});
|
||||
|
||||
/* ── Go-to order by number ───────────────────────────────────── */
|
||||
$('#@Html.IdFor(m => m.GoDirectlyToCustomOrderNumber)').on('keydown', function (e) {
|
||||
if (e.keyCode === 13) { $('#go-to-order-by-number').trigger('click'); return false; }
|
||||
});
|
||||
$('#go-to-order-by-number').on('click', function () {
|
||||
var num = $('#@Html.IdFor(m => m.GoDirectlyToCustomOrderNumber)').val();
|
||||
if (num) {
|
||||
window.location.href = '@Url.Action("GoToOrderId", "CustomOrder")?' +
|
||||
'@Html.IdFor(m => m.GoDirectlyToCustomOrderNumber)=' + encodeURIComponent(num);
|
||||
}
|
||||
});
|
||||
|
||||
/* ── Export / PDF selected ───────────────────────────────────── */
|
||||
function exportSelected(formId, inputId) {
|
||||
var ids = getSelectedIds().join(',');
|
||||
if (!ids) { alert('@T("Admin.Orders.NoOrders")'); return; }
|
||||
$(inputId).val(ids);
|
||||
$(formId).submit();
|
||||
}
|
||||
|
||||
$('#exportxml-selected').on('click', function (e) { e.preventDefault(); exportSelected('#export-xml-selected-form', '#export-xml-ids'); });
|
||||
$('#exportexcel-selected').on('click', function (e) { e.preventDefault(); exportSelected('#export-excel-selected-form', '#export-excel-ids'); });
|
||||
$('#pdf-invoice-selected').on('click', function (e) { e.preventDefault(); exportSelected('#pdf-invoice-selected-form', '#pdf-invoice-ids'); });
|
||||
|
||||
/* ── Create order modal ──────────────────────────────────────── */
|
||||
var createProducts = [];
|
||||
|
||||
$('#create-order-customer-search').autocomplete({
|
||||
delay : 400,
|
||||
minLength: 2,
|
||||
source : '@Url.Action("CustomerSearchAutoComplete", "CustomOrder")',
|
||||
select : function (e, ui) {
|
||||
$('#create-order-customer-id').val(ui.item.value);
|
||||
$('#create-order-customer-name').html('<strong>' + ui.item.label + '</strong>');
|
||||
$('#create-order-customer-search').val('');
|
||||
$('#create-product-search-section').slideDown();
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
$('#create-order-product-search').autocomplete({
|
||||
delay : 400,
|
||||
minLength: 2,
|
||||
source : '@Url.Action("ProductSearchAutoComplete", "CustomOrder")',
|
||||
select : function (e, ui) {
|
||||
addCreateProduct(ui.item);
|
||||
$('#create-order-product-search').val('');
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
function addCreateProduct(item) {
|
||||
if (createProducts.find(function (p) { return p.id === item.value; })) return;
|
||||
createProducts.push({ id: item.value, name: item.label, quantity: 1, price: item.price || 0 });
|
||||
renderCreateProducts();
|
||||
}
|
||||
|
||||
function renderCreateProducts() {
|
||||
var $body = $('#create-products-body').empty();
|
||||
if (!createProducts.length) { $('#create-selected-products-section').hide(); return; }
|
||||
$('#create-selected-products-section').show();
|
||||
createProducts.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._fbUpdateQty(this)"></td>' +
|
||||
'<td><input type="text" class="form-control form-control-sm" value="' + p.price + '" data-idx="' + i + '" onchange="window._fbUpdatePrice(this)"></td>' +
|
||||
'<td class="text-center"><button type="button" class="btn btn-danger btn-xs" onclick="window._fbRemoveProduct(' + i + ')"><i class="fas fa-trash"></i></button></td>' +
|
||||
'</tr>'
|
||||
);
|
||||
});
|
||||
$('#create-order-products-json').val(JSON.stringify(createProducts));
|
||||
}
|
||||
|
||||
window._fbUpdateQty = function (el) { createProducts[+el.dataset.idx].quantity = +el.value; renderCreateProducts(); };
|
||||
window._fbUpdatePrice = function (el) { createProducts[+el.dataset.idx].price = +el.value; renderCreateProducts(); };
|
||||
window._fbRemoveProduct = function (i) { createProducts.splice(i, 1); renderCreateProducts(); };
|
||||
|
||||
$('#create-order-form').on('submit', function (e) {
|
||||
if (!$('#create-order-customer-id').val()) {
|
||||
e.preventDefault();
|
||||
$('#create-order-customer-error').show();
|
||||
}
|
||||
});
|
||||
|
||||
$('#create-order-window').on('hidden.bs.modal', function () {
|
||||
$('#create-order-customer-search, #create-order-customer-id, #create-order-customer-name').val('').html('');
|
||||
$('#create-order-customer-error').hide();
|
||||
$('#create-product-search-section').hide();
|
||||
createProducts = [];
|
||||
renderCreateProducts();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Models;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||
using Nop.Web.Areas.Admin.Models.Customers;
|
||||
using Nop.Web.Framework.Components;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Components;
|
||||
|
||||
[ViewComponent(Name = "CustomerCreditWidget")]
|
||||
public class CustomerCreditWidgetViewComponent : NopViewComponent
|
||||
{
|
||||
private readonly ICustomerCreditService _customerCreditService;
|
||||
|
||||
public CustomerCreditWidgetViewComponent(ICustomerCreditService customerCreditService)
|
||||
{
|
||||
_customerCreditService = customerCreditService;
|
||||
}
|
||||
|
||||
public async Task<IViewComponentResult> InvokeAsync(string widgetZone, object additionalData)
|
||||
{
|
||||
if (additionalData is not CustomerModel customerModel) return Content("");
|
||||
|
||||
var customerId = customerModel.Id;
|
||||
var credit = await _customerCreditService.GetByCustomerIdAsync(customerId);
|
||||
var outstanding = await _customerCreditService.GetOutstandingBalanceAsync(customerId);
|
||||
|
||||
var model = new CustomerCreditWidgetModel
|
||||
{
|
||||
CustomerId = customerId,
|
||||
HasCreditLimit = credit != null,
|
||||
CreditLimit = credit?.CreditLimit ?? 0m,
|
||||
OutstandingBalance = outstanding,
|
||||
RemainingCredit = credit != null ? credit.CreditLimit - outstanding : (decimal?)null,
|
||||
Comment = credit?.Comment
|
||||
};
|
||||
|
||||
return View("~/Plugins/Misc.FruitBankPlugin/Views/CustomerCreditWidget.cshtml", model);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,441 @@
|
|||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nop.Core;
|
||||
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 QuickOrderController : BasePluginController
|
||||
{
|
||||
private readonly IWorkContext _workContext;
|
||||
private readonly IStoreContext _storeContext;
|
||||
private readonly IProductService _productService;
|
||||
private readonly IShoppingCartService _shoppingCartService;
|
||||
private readonly ICustomerService _customerService;
|
||||
private readonly ILocalizationService _localizationService;
|
||||
private readonly CustomPriceCalculationService _customPriceCalculationService;
|
||||
private readonly OpenAIApiService _aiApiService;
|
||||
private readonly CerebrasAPIService _cerebrasApiService;
|
||||
private readonly FruitBankDbContext _dbContext;
|
||||
|
||||
// Resource key prefix
|
||||
private const string Prefix = "Plugins.Misc.FruitBankPlugin.QuickOrder.";
|
||||
|
||||
public QuickOrderController(
|
||||
IWorkContext workContext,
|
||||
IStoreContext storeContext,
|
||||
IProductService productService,
|
||||
IShoppingCartService shoppingCartService,
|
||||
ICustomerService customerService,
|
||||
ILocalizationService localizationService,
|
||||
IPriceCalculationService priceCalculationService,
|
||||
OpenAIApiService aiApiService,
|
||||
CerebrasAPIService cerebrasApiService,
|
||||
FruitBankDbContext dbContext)
|
||||
{
|
||||
_workContext = workContext;
|
||||
_storeContext = storeContext;
|
||||
_productService = productService;
|
||||
_shoppingCartService = shoppingCartService;
|
||||
_customerService = customerService;
|
||||
_localizationService = localizationService;
|
||||
_customPriceCalculationService = priceCalculationService as CustomPriceCalculationService;
|
||||
_aiApiService = aiApiService;
|
||||
_cerebrasApiService = cerebrasApiService;
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Challenge();
|
||||
|
||||
return View("~/Plugins/Misc.FruitBankPlugin/Views/QuickOrder/Index.cshtml");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return all available products with prices (for initial page load)
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAllProducts()
|
||||
{
|
||||
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 allProductDtos = (await _dbContext.ProductDtos.GetAll(true).ToListAsync()).Where(pd => pd.AvailableQuantity > 0);
|
||||
|
||||
//var dbProducts = await _productService.SearchProductsAsync(
|
||||
// pageIndex: 0,
|
||||
// pageSize: 500,
|
||||
// orderBy: );
|
||||
|
||||
var result = new List<object>();
|
||||
|
||||
foreach (var product in allProductDtos)
|
||||
{
|
||||
var productDto = allProductDtos.FirstOrDefault(x => x.Id == product.Id);
|
||||
if (productDto == null) continue;
|
||||
|
||||
var availableQty = product.StockQuantity + productDto.IncomingQuantity;
|
||||
if (availableQty <= 0) continue;
|
||||
|
||||
decimal? unitPrice = null;
|
||||
if (!productDto.IsMeasurable)
|
||||
{
|
||||
var tproduct = await _productService.GetProductByIdAsync(productDto.Id);
|
||||
var priceResult = await _customPriceCalculationService.GetFinalPriceAsync(
|
||||
tproduct, customer, store, null, 0, true, 1, null, null);
|
||||
unitPrice = priceResult.finalPrice;
|
||||
}
|
||||
|
||||
result.Add(new
|
||||
{
|
||||
id = product.Id,
|
||||
name = product.Name,
|
||||
quantity = 1,
|
||||
requestedQuantity = 1,
|
||||
unitPrice,
|
||||
stockQuantity = availableQty,
|
||||
searchTerm = (string)null,
|
||||
isQuantityReduced = false,
|
||||
isMeasurable = productDto.IsMeasurable
|
||||
});
|
||||
}
|
||||
|
||||
return Json(new { success = true, products = result });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[QuickOrder] GetAllProducts error: {ex.Message}");
|
||||
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a manually typed product list and return matching products with prices
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> SearchProducts(string text)
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Json(new { success = false, message = await L("NotLoggedIn") });
|
||||
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return Json(new { success = false, message = await L("NoTextProvided") });
|
||||
|
||||
try
|
||||
{
|
||||
var parsedProducts = await ParseProductsFromText(text);
|
||||
if (parsedProducts == null || parsedProducts.Count == 0)
|
||||
return Json(new { success = false, message = await L("NoProductsIdentified"), transcription = text });
|
||||
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
var enrichedProducts = await EnrichProductData(parsedProducts, customer, store);
|
||||
|
||||
return Json(new { success = true, transcription = text, products = enrichedProducts });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[QuickOrder] SearchProducts error: {ex.Message}");
|
||||
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transcribe voice audio (Hungarian) then parse and match products
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> TranscribeAndSearch(IFormFile audioFile)
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Json(new { success = false, message = await L("NotLoggedIn") });
|
||||
|
||||
if (audioFile == null || audioFile.Length == 0)
|
||||
return Json(new { success = false, message = await L("NoAudioReceived") });
|
||||
|
||||
try
|
||||
{
|
||||
var transcribedText = await TranscribeAudioFile(audioFile, "hu");
|
||||
if (string.IsNullOrEmpty(transcribedText))
|
||||
return Json(new { success = false, message = await L("TranscriptionFailed") });
|
||||
|
||||
Console.WriteLine($"[QuickOrder] Transcription: {transcribedText}");
|
||||
|
||||
var parsedProducts = await ParseProductsFromText(transcribedText);
|
||||
if (parsedProducts == null || parsedProducts.Count == 0)
|
||||
return Json(new { success = false, message = await L("NoProductsIdentified"), transcription = transcribedText });
|
||||
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
var enrichedProducts = await EnrichProductData(parsedProducts, customer, store);
|
||||
|
||||
return Json(new { success = true, transcription = transcribedText, products = enrichedProducts });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[QuickOrder] TranscribeAndSearch error: {ex.Message}");
|
||||
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a product to the current customer's shopping cart and return the updated cart
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> AddToCart(int productId, int quantity)
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Json(new { success = false, message = await L("NotLoggedIn") });
|
||||
|
||||
if (productId <= 0 || quantity <= 0)
|
||||
return Json(new { success = false, message = await L("InvalidProductOrQuantity") });
|
||||
|
||||
try
|
||||
{
|
||||
var product = await _productService.GetProductByIdAsync(productId);
|
||||
if (product == null || product.Deleted || !product.Published)
|
||||
return Json(new { success = false, message = await L("ProductNotAvailable") });
|
||||
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
|
||||
var warnings = await _shoppingCartService.AddToCartAsync(
|
||||
customer: customer,
|
||||
product: product,
|
||||
shoppingCartType: ShoppingCartType.ShoppingCart,
|
||||
storeId: 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)
|
||||
{
|
||||
Console.WriteLine($"[QuickOrder] AddToCart error: {ex.Message}");
|
||||
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return the current customer's cart as JSON (for cart panel refresh)
|
||||
/// </summary>
|
||||
[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();
|
||||
var cartItems = await GetCartItemsJson(customer, store);
|
||||
return Json(new { success = true, cartItems });
|
||||
}
|
||||
|
||||
#region Private helpers
|
||||
|
||||
/// <summary>Shorthand: get a localized QuickOrder resource string</summary>
|
||||
private Task<string> L(string keySuffix)
|
||||
=> _localizationService.GetResourceAsync(Prefix + keySuffix);
|
||||
|
||||
private async Task<string> TranscribeAudioFile(IFormFile audioFile, string language)
|
||||
{
|
||||
var fileName = $"quick_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 transcribedText;
|
||||
using (var audioStream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
|
||||
transcribedText = await _aiApiService.TranscribeAudioAsync(audioStream, fileName, language, null);
|
||||
|
||||
if (!string.IsNullOrEmpty(transcribedText) &&
|
||||
(transcribedText.EndsWith(".") || transcribedText.EndsWith("!") || transcribedText.EndsWith("?")))
|
||||
transcribedText = transcribedText[..^1];
|
||||
|
||||
try { System.IO.File.Delete(filePath); } catch { /* ignore cleanup errors */ }
|
||||
return transcribedText;
|
||||
}
|
||||
|
||||
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. Extract product names and quantities from ANY produce item
|
||||
2. Normalize product names to singular, lowercase (e.g., 'narancsok' → 'narancs')
|
||||
3. Handle Hungarian number words ('száz' = 100, 'ötven' = 50, 'húsz' = 20, 'tíz' = 10, 'öt' = 5, 'egy' = 1)
|
||||
4. Fix common transcription/typing errors (e.g., 'datója' → 'datolya', 'szűlő' → 'szőlő', 'mondarin' → 'mandarin')
|
||||
5. Return ONLY valid JSON array, no explanations
|
||||
6. DO NOT include units - only product name and quantity as a number
|
||||
7. ALWAYS return at least one product if you can parse anything from the input
|
||||
|
||||
OUTPUT FORMAT (JSON only):
|
||||
[
|
||||
{""product"": ""narancs"", ""quantity"": 100},
|
||||
{""product"": ""alma"", ""quantity"": 50}
|
||||
]";
|
||||
|
||||
var aiResponse = await _cerebrasApiService.GetSimpleResponseAsync(systemPrompt, $"Parse this: {text}");
|
||||
Console.WriteLine($"[QuickOrder] AI parse response: {aiResponse}");
|
||||
|
||||
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 (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[QuickOrder] JSON parse error: {ex.Message}");
|
||||
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 enrichedProducts = new List<object>();
|
||||
var allProductDtos = await _dbContext.ProductDtos.GetAll(true).ToListAsync();
|
||||
|
||||
foreach (var parsed in parsedProducts)
|
||||
{
|
||||
var dbProducts = await _productService.SearchProductsAsync(
|
||||
keywords: parsed.Product,
|
||||
pageIndex: 0,
|
||||
pageSize: 20);
|
||||
|
||||
if (!dbProducts.Any())
|
||||
{
|
||||
Console.WriteLine($"[QuickOrder] No products found for: {parsed.Product}");
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var product in dbProducts)
|
||||
{
|
||||
var productDto = allProductDtos.FirstOrDefault(x => x.Id == product.Id);
|
||||
if (productDto == null) continue;
|
||||
|
||||
var availableQty = product.StockQuantity + productDto.IncomingQuantity;
|
||||
if (availableQty <= 0) continue;
|
||||
|
||||
var requestedQty = parsed.Quantity;
|
||||
var finalQty = Math.Min(requestedQty, availableQty);
|
||||
var isReduced = finalQty < requestedQty;
|
||||
|
||||
decimal? unitPrice = null;
|
||||
if (!productDto.IsMeasurable)
|
||||
{
|
||||
var priceResult = await _customPriceCalculationService.GetFinalPriceAsync(
|
||||
product, customer, store, null, 0, true, finalQty, null, null);
|
||||
unitPrice = priceResult.finalPrice;
|
||||
}
|
||||
|
||||
enrichedProducts.Add(new
|
||||
{
|
||||
id = product.Id,
|
||||
name = product.Name,
|
||||
quantity = finalQty,
|
||||
requestedQuantity = requestedQty,
|
||||
unitPrice,
|
||||
stockQuantity = availableQty,
|
||||
searchTerm = parsed.Product,
|
||||
isQuantityReduced = isReduced,
|
||||
isMeasurable = productDto.IsMeasurable
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"[QuickOrder] Enriched product count: {enrichedProducts.Count}");
|
||||
return enrichedProducts;
|
||||
}
|
||||
|
||||
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 allProductDtos = 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 productDto = allProductDtos.FirstOrDefault(x => x.Id == product.Id);
|
||||
var isMeasurable = productDto?.IsMeasurable ?? false;
|
||||
|
||||
decimal? unitPrice = null;
|
||||
if (!isMeasurable)
|
||||
{
|
||||
var priceResult = await _customPriceCalculationService.GetFinalPriceAsync(
|
||||
product, customer, store, null, 0, true, item.Quantity, null, null);
|
||||
unitPrice = priceResult.finalPrice;
|
||||
}
|
||||
|
||||
result.Add(new
|
||||
{
|
||||
id = item.Id,
|
||||
productId = item.ProductId,
|
||||
name = product.Name,
|
||||
quantity = item.Quantity,
|
||||
unitPrice,
|
||||
isMeasurable
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Inner models
|
||||
|
||||
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; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
using FruitBank.Common.Entities;
|
||||
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 CustomerCreditDbTable : MgDbTableBase<CustomerCredit>
|
||||
{
|
||||
public CustomerCreditDbTable(
|
||||
IEventPublisher eventPublisher,
|
||||
INopDataProvider dataProvider,
|
||||
IShortTermCacheManager shortTermCacheManager,
|
||||
IStaticCacheManager staticCacheManager,
|
||||
AppSettings appSettings)
|
||||
: base(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings)
|
||||
{
|
||||
}
|
||||
|
||||
public Task<CustomerCredit?> GetByCustomerIdAsync(int customerId)
|
||||
=> GetAll().FirstOrDefaultAsync(x => x.CustomerId == customerId);
|
||||
}
|
||||
|
|
@ -40,7 +40,8 @@ public class FruitBankDbContext : MgDbContextBase,
|
|||
IShippingItemPalletDbSet<ShippingItemPalletDbTable>,
|
||||
IOrderItemPalletDbSet<OrderItemPalletDbTable>,
|
||||
IShippingDocumentToFilesDbSet<ShippingDocumentToFilesDbTable>,
|
||||
IFilesDbSet<FilesDbTable>
|
||||
IFilesDbSet<FilesDbTable>,
|
||||
ICustomerCreditDbSet<CustomerCreditDbTable>
|
||||
{
|
||||
private readonly FruitBankAttributeService _fruitBankAttributeService;
|
||||
private readonly IStoreContext _storeContext;
|
||||
|
|
@ -64,6 +65,7 @@ public class FruitBankDbContext : MgDbContextBase,
|
|||
public FilesDbTable Files { get; set; }
|
||||
public ShippingDocumentToFilesDbTable ShippingDocumentToFiles { get; set; }
|
||||
public StockQuantityHistoryDtoDbTable StockQuantityHistoryDtos { get; set; }
|
||||
public CustomerCreditDbTable CustomerCredits { get; set; }
|
||||
|
||||
public IRepository<Customer> Customers { get; set; }
|
||||
public IRepository<CustomerRole> CustomerRoles { get; set; }
|
||||
|
|
@ -79,7 +81,7 @@ public class FruitBankDbContext : MgDbContextBase,
|
|||
PartnerDbTable partnerDbTable, ShippingDbTable shippingDbTable, ShippingDocumentDbTable shippingDocumentDbTable, ShippingItemDbTable shippingItemDbTable,
|
||||
ShippingItemPalletDbTable shippingItemPalletDbTable, FilesDbTable filesDbTable, ShippingDocumentToFilesDbTable shippingDocumentToFilesDbTable,
|
||||
ProductDtoDbTable productDtoDbTable, OrderDtoDbTable orderDtoDbTable, OrderItemDtoDbTable orderItemDtoDbTable, OrderItemPalletDbTable orderItemPalletDbTable,
|
||||
StockQuantityHistoryDtoDbTable stockQuantityHistoryDtos,
|
||||
StockQuantityHistoryDtoDbTable stockQuantityHistoryDtos, CustomerCreditDbTable customerCreditDbTable,
|
||||
IProductService productService, IStaticCacheManager staticCacheManager,
|
||||
IRepository<Order> orderRepository,
|
||||
IRepository<OrderItem> orderItemRepository,
|
||||
|
|
@ -127,6 +129,7 @@ public class FruitBankDbContext : MgDbContextBase,
|
|||
StockQuantityHistories = stockQuantityHistories;
|
||||
StockQuantityHistoriesExt = stockQuantityHistoriesExt;
|
||||
StockQuantityHistoryDtos = stockQuantityHistoryDtos;
|
||||
CustomerCredits = customerCreditDbTable;
|
||||
}
|
||||
|
||||
public IQueryable<Customer> GetCustomersBySystemRoleName(string systemRoleName)
|
||||
|
|
|
|||
|
|
@ -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 ICustomerCreditDbSet<TDbTable> : IMgDbTableBase where TDbTable : IRepository<CustomerCredit>
|
||||
{
|
||||
public TDbTable CustomerCredits { get; set; }
|
||||
}
|
||||
|
|
@ -331,7 +331,9 @@ public class MgOrderModelFactory<TOrderListModelExt, TOrderModelExt> : OrderMode
|
|||
public virtual async Task<TOrderListModelExt> PrepareOrderListModelExtendedAsync(OrderSearchModelExtended searchModel, Func<OrderListModel, TOrderModelExt, Task> dataItemCopiedCallback)
|
||||
{
|
||||
var customerCompany = searchModel.BillingCompany;
|
||||
var customer = await _customerService.GetCustomerByIdAsync(Convert.ToInt32(customerCompany));
|
||||
var customer = int.TryParse(customerCompany, out var customerId) && customerId > 0
|
||||
? await _customerService.GetCustomerByIdAsync(customerId)
|
||||
: null;
|
||||
//var customer = customers.FirstOrDefault(c => c.Company != null && c.Company.Equals(customerCompany, StringComparison.InvariantCultureIgnoreCase));
|
||||
//var customer = customers.FirstOrDefault(c => c.Company != null && c.Company.Equals(customerCompany, StringComparison.InvariantCultureIgnoreCase));
|
||||
OrderListModel prefiltered;
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
|
||||
using FruitBank.Common.Server;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Components;
|
||||
using Nop.Core.Domain.Messages;
|
||||
using Nop.Services.Cms;
|
||||
using Nop.Services.Configuration;
|
||||
using Nop.Services.Localization;
|
||||
using Nop.Services.Messages;
|
||||
using Nop.Services.Plugins;
|
||||
using Nop.Services.Security;
|
||||
using Nop.Web.Framework.Infrastructure;
|
||||
using Nop.Web.Framework.Menu;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin
|
||||
|
|
@ -29,6 +31,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
|
|||
protected readonly ILocalizationService _localizationService;
|
||||
protected readonly IUrlHelperFactory _urlHelperFactory;
|
||||
private readonly IAdminMenu _adminMenu;
|
||||
private readonly IMessageTemplateService _messageTemplateService;
|
||||
|
||||
//handle AdminMenuCreatedEvent
|
||||
|
||||
|
|
@ -39,7 +42,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
|
|||
ILocalizationService localizationService,
|
||||
IPermissionService permissionService,
|
||||
IUrlHelperFactory urlHelperFactory,
|
||||
IAdminMenu adminMenu)
|
||||
IAdminMenu adminMenu,
|
||||
IMessageTemplateService messageTemplateService)
|
||||
{
|
||||
_actionContextAccessor = actionContextAccessor;
|
||||
_settingService = settingService;
|
||||
|
|
@ -48,6 +52,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
|
|||
_urlHelperFactory = urlHelperFactory;
|
||||
_adminMenu = adminMenu;
|
||||
_permissionService = permissionService;
|
||||
_messageTemplateService = messageTemplateService;
|
||||
}
|
||||
|
||||
// --- INSTALL ---
|
||||
|
|
@ -59,7 +64,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
|
|||
//TODO: Add "IsMeasurable" product attribute - FruitBankConst.IsMeasurableAttributeName
|
||||
//TODO: Add "NeedsToBeMeasured" product attribute if not exists
|
||||
|
||||
//TODO: Add unique index to GenericAttribute[EntityId, KeyGroup, Key]???!? ÁTGONDOLNI, mert lehet esetleg lista is a visszaadott, de a nopcommerce kódban felülírja ha azonos key-el vannak! - J.
|
||||
//TODO: Add unique index to GenericAttribute[EntityId, KeyGroup, Key]???!? ATGONDOLNI, mert lehet esetleg lista is a visszaadott, de a nopcommerce kodban felulirja ha azonos key-el vannak! - J.
|
||||
|
||||
// Default settings
|
||||
var settings = new FruitBankSettings
|
||||
|
|
@ -67,8 +72,217 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
|
|||
ApiKey = string.Empty
|
||||
};
|
||||
await _settingService.SaveSettingAsync(settings);
|
||||
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.ShipmentsList", "Shipment", "EN");
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.ShipmentsList", "Szállítmányok", "HU");
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.ShipmentsList", "Sz\u00e1ll\u00edtm\u00e1nyok", "HU");
|
||||
|
||||
// ── Quick Order page ───────────────────────────────────────────────────
|
||||
const string en = "EN";
|
||||
const string hu = "HU";
|
||||
|
||||
// Page title
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.PageTitle", "Quick Order", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.PageTitle", "Gyors rendel\u00e9s", hu);
|
||||
|
||||
// Navigation menu label
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MenuLabel", "Quick Order", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MenuLabel", "Gyors rendel\u00e9s", hu);
|
||||
|
||||
// Search bar
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicButtonTitle", "Start voice recording", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicButtonTitle", "Hangfelv\u00e9tel ind\u00edt\u00e1sa", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.StopButtonTitle", "Stop", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.StopButtonTitle", "Le\u00e1ll\u00edt\u00e1s", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchPlaceholder", "Search for products (e.g. orange 100, apple 50) or use the microphone...", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchPlaceholder", "Keress term\u00e9keket (pl. narancs 100, alma 50) vagy haszn\u00e1ld a mikrofont...", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ActiveRecordingPlaceholder", "Listening... (start speaking)", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ActiveRecordingPlaceholder", "Figye\u0151s... (kezdj el besz\u00e9lni)", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchButton", "Search", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchButton", "Keres\u00e9s", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ListeningStatus", "Listening...", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ListeningStatus", "Figye\u0151s...", hu);
|
||||
|
||||
// Product panel
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.TranscribedLabel", "I heard:", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.TranscribedLabel", "Hallottam:", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoResultsText", "No products found. Try a different search.", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoResultsText", "Nem tal\u00e1ltunk term\u00e9keket. Pr\u00f3b\u00e1ljunk m\u00e1s keres\u00e9st.", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.LoadingProducts", "Loading products...", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.LoadingProducts", "Term\u00e9kek bet\u00f6lt\u00e9se...", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AllProductsLabel", "All products", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AllProductsLabel", "\u00d6sszes term\u00e9k", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchResultsLabel", "Results", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchResultsLabel", "Tal\u00e1latok", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.QuantityHint", "\u2014 set quantity, then add to cart:", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.QuantityHint", "\u2014 \u00e1ll\u00edtsd be a mennyis\u00e9get, majd add a kos\u00e1rhoz:", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MeasurableBadge", "Requires weighing", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MeasurableBadge", "S\u00falym\u00e9r\u00e9st ig\u00e9nyel", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLabel", "Stock:", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLabel", "K\u00e9szlet:", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLimitedPrefix", "Only", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLimitedPrefix", "Csak", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLimitedSuffix", "pcs available", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLimitedSuffix", "db el\u00e9rhet\u0151", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.PieceUnit", "pcs", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.PieceUnit", "db", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.PricePerPiece", "Ft/pcs", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.PricePerPiece", "Ft/db", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AddToCartTitle", "Add to cart", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AddToCartTitle", "Kos\u00e1rba", hu);
|
||||
|
||||
// Cart panel
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CartTitle", "Cart", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CartTitle", "Kos\u00e1r", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine1", "Your cart is empty.", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine1", "A kos\u00e1r \u00fcres.", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine2", "Search for products and add them.", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine2", "Keress term\u00e9keket \u00e9s add hozz\u00e1 \u0151ket.", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CartTotalNote", "Prices for weighed items will be finalized after measurement.", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CartTotalNote", "A s\u00falym\u00e9r\u00e9st ig\u00e9nyl\u0151 t\u00e9teleikn\u00e9l az \u00e1r a m\u00e9r\u00e9s ut\u00e1n v\u00e9glegesedik.", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.EstimatedTotal", "Estimated total:", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.EstimatedTotal", "Becs\u00fclt \u00f6sszeg:", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CheckoutButton", "Proceed to checkout", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CheckoutButton", "Tov\u00e1bb a p\u00e9nzt\u00e1rhoz", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ViewCartButton", "View cart", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ViewCartButton", "Kos\u00e1r megtekint\u00e9se", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AddedToCart", "added", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AddedToCart", "hozz\u00e1adva", hu);
|
||||
|
||||
// JS voice / status strings
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.BrowserNotSupported", "Your browser does not support audio recording.", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.BrowserNotSupported", "A b\u00f6ng\u00e9sz\u0151 nem t\u00e1mogatja a hangfelv\u00e9telt.", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicAccessError", "Could not access microphone: ", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicAccessError", "Nem siker\u00fclt a mikrofon el\u00e9r\u00e9se: ", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicPermissionDenied", "Please allow microphone access.", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicPermissionDenied", "Enged\u00e9lyezd a mikrofon haszn\u00e1lat\u00e1t.", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicNotFound", "No microphone found.", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicNotFound", "Nincs mikrofon csatlakoztatva.", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.Calibrating", "Calibrating...", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.Calibrating", "Kalib\u00e1l\u00f3d\u00e1s...", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.Processing", "Processing...", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.Processing", "Feldolgoz\u00e1s...", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.RecordingFailed", "Could not record audio. Please try again.", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.RecordingFailed", "Nem siker\u00fclt hangot r\u00f6gz\u00edteni. K\u00e9rem, pr\u00f3b\u00e1lja \u00fajra.", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeHigh", "Loud and clear", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeHigh", "Hangos \u00e9s \u00e9rhet\u0151", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeSpeaking", "Speaking...", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeSpeaking", "Besz\u00e9l...", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeLouder", "Speak louder!", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeLouder", "Hangosabban!", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.Searching", "Searching...", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.Searching", "Keres\u00e9s...", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.EnterProducts", "Please enter the products!", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.EnterProducts", "K\u00e9rem, add meg a term\u00e9keket!", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchError", "Error during search.", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchError", "Hiba a keres\u00e9s sor\u00e1n.", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AudioError", "Error processing audio.", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AudioError", "Hiba a hangfeldolgoz\u00e1s sor\u00e1n.", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AddToCartError", "Error adding item to cart.", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AddToCartError", "Hiba a kos\u00e1rba helyez\u00e9s sor\u00e1n.", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ErrorPrefix", "Error: ", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ErrorPrefix", "Hiba: ", hu);
|
||||
|
||||
// Controller JSON error messages
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NotLoggedIn", "Not logged in", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NotLoggedIn", "Nincs bejelentkezve", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoTextProvided", "No text provided", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoTextProvided", "Nincs sz\u00f6veg megadva", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoProductsIdentified", "Could not identify products", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoProductsIdentified", "Nem siker\u00fclt term\u00e9keket azonos\u00edtani", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoAudioReceived", "No audio received", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoAudioReceived", "Nem \u00e9rkezett hangf\u00e1jl", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.TranscriptionFailed", "Speech recognition failed", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.TranscriptionFailed", "Nem siker\u00fclt a hangfelismer\u00e9s", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ProductNotAvailable", "Product not available", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ProductNotAvailable", "A term\u00e9k nem el\u00e9rhet\u0151", hu);
|
||||
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);
|
||||
|
||||
// ── Customer Credit ────────────────────────────────────────────────────
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle", "Customer Credit Management", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle", "\u00dcgyf\u00e9l hitelkeret kezel\u00e9s", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.BackToCustomer", "Back to customer", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.BackToCustomer", "Vissza az \u00fcgyf\u00e9lhez", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.EditTitle", "Set Credit Limit", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.EditTitle", "Hitelkeret be\u00e1ll\u00edt\u00e1sa", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit", "Credit Limit", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit", "Hitelkeret", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimitHint", "Set to 0 to block all orders. Leave the record absent to allow unlimited.", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimitHint", "0 eset\u00e9n minden rendel\u00e9s le van tiltva. Ha nincs rekord, a limit korl\u00e1tlan.", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance", "Outstanding Balance", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance", "Kintlév\u0151 egyenleg", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit", "Remaining Credit", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit", "Szabad keret", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited", "Unlimited", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited", "Korl\u00e1tlan", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment", "Notes", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment", "Megjegyz\u00e9s", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Save", "Save", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Save", "Ment\u00e9s", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.UnpaidOrdersTitle", "Unpaid / Pending Orders", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.UnpaidOrdersTitle", "Kifizetetlen / f\u00fcgg\u0151 rendel\u00e9sek", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.NoUnpaidOrders", "No unpaid orders.", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.NoUnpaidOrders", "Nincs kifizetetlen rendel\u00e9s.", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderId", "Order #", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderId", "Rendel\u00e9s #", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderDate", "Date", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderDate", "D\u00e1tum", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderTotal", "Total", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderTotal", "\u00d6sszeg", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderStatus", "Order Status", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderStatus", "Rendel\u00e9s \u00e1llapot", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.PaymentStatus", "Payment Status", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.PaymentStatus", "Fizet\u00e9si \u00e1llapot", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Total", "Total", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Total", "\u00d6sszesen", hu);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderBlocked", "Your order cannot be placed because your outstanding balance has reached your credit limit. Please settle your existing balance first.", en);
|
||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderBlocked", "A rendel\u00e9st nem lehet leadni, mert a kintlév\u0151 egyenlege el\u00e9rte a hitelkeret\u00e9t. K\u00e9rj\u00fck, el\u0151sz\u00f6r rendezze meglév\u0151 tartoz\u00e1s\u00e1t.", hu);
|
||||
|
||||
// ── Order Started email template ────────────────────────────────────
|
||||
var existingStartedTemplate = await _messageTemplateService
|
||||
.GetMessageTemplatesByNameAsync(FruitBankNotificationService.ORDER_STARTED_TEMPLATE_NAME, 0);
|
||||
if (!existingStartedTemplate.Any())
|
||||
{
|
||||
await _messageTemplateService.InsertMessageTemplateAsync(new MessageTemplate
|
||||
{
|
||||
Name = FruitBankNotificationService.ORDER_STARTED_TEMPLATE_NAME,
|
||||
Subject = "%Store.Name% - Rendelésed feldolgozás alatt (#%Order.OrderNumber%)",
|
||||
Body = "<p>Kedves %Order.CustomerFullName%,</p>" +
|
||||
"<p>Rendelésedet (<strong>#%Order.OrderNumber%</strong>) elkezdtük feldolgozni.</p>" +
|
||||
"%Order.MeasurableNote%" +
|
||||
"<p>Amint elkészül, értesítünk!</p>" +
|
||||
"<p>%Store.Name% csapata</p>",
|
||||
IsActive = true,
|
||||
EmailAccountId = 0,
|
||||
LimitedToStores = false,
|
||||
AllowDirectReply = false,
|
||||
AttachedDownloadId = 0,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Order Audited email template ─────────────────────────────────────
|
||||
var existingTemplate = await _messageTemplateService
|
||||
.GetMessageTemplatesByNameAsync(FruitBankNotificationService.ORDER_AUDITED_TEMPLATE_NAME, 0);
|
||||
if (existingTemplate.Count == 0)
|
||||
{
|
||||
await _messageTemplateService.InsertMessageTemplateAsync(new MessageTemplate
|
||||
{
|
||||
Name = FruitBankNotificationService.ORDER_AUDITED_TEMPLATE_NAME,
|
||||
Subject = "%Store.Name% - Rendelésed elkészült (#%Order.OrderNumber%)",
|
||||
Body = "<p>Kedves %Order.CustomerFullName%,</p>" +
|
||||
"<p>Rendelésed (<strong>#%Order.OrderNumber%</strong>) elkészült és átvételre vár.</p>" +
|
||||
"%Order.MeasurableNote%" +
|
||||
"<p>Végleges összeg: <strong>%Order.OrderTotal%</strong></p>" +
|
||||
"<p>Köszönjük a rendelésedet!</p>" +
|
||||
"<p>%Store.Name% csapata</p>",
|
||||
IsActive = true,
|
||||
EmailAccountId = 0, // 0 = use store default
|
||||
LimitedToStores = false,
|
||||
AllowDirectReply = false,
|
||||
AttachedDownloadId = 0,
|
||||
});
|
||||
}
|
||||
|
||||
await base.InstallAsync();
|
||||
}
|
||||
|
||||
|
|
@ -84,36 +298,20 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
|
|||
|
||||
public Task<IList<string>> GetWidgetZonesAsync()
|
||||
{
|
||||
return Task.FromResult<IList<string>>(new List<string> { PublicWidgetZones.ProductBoxAddinfoBefore, PublicWidgetZones.ProductDetailsBottom, AdminWidgetZones.ProductDetailsBlock, AdminWidgetZones.OrderDetailsBlock });
|
||||
return Task.FromResult<IList<string>>(new List<string>
|
||||
{
|
||||
PublicWidgetZones.ProductBoxAddinfoBefore,
|
||||
PublicWidgetZones.ProductDetailsBottom,
|
||||
AdminWidgetZones.ProductDetailsBlock,
|
||||
AdminWidgetZones.OrderDetailsBlock,
|
||||
AdminWidgetZones.CustomerDetailsBlock
|
||||
});
|
||||
}
|
||||
|
||||
//public string GetWidgetViewComponentName(string widgetZone)
|
||||
//{
|
||||
// return "ProductAIWidget"; // A ViewComponent neve
|
||||
//}
|
||||
|
||||
// --- ADMIN MENÜ ---
|
||||
//public async Task ManageSiteMapAsync(AdminMenuItem rootNode)
|
||||
//{
|
||||
// if (!await _permissionService.AuthorizeAsync(StandardPermission.Configuration.MANAGE_PLUGINS))
|
||||
// return;
|
||||
|
||||
// var pluginNode = new AdminMenuItem
|
||||
// {
|
||||
// SystemName = "FruitBankPlugin.Configure",
|
||||
// Title = "AI Assistant",
|
||||
// Url = $"{_webHelper.GetStoreLocation()}Admin/FruitBankPluginAdmin/Configure",
|
||||
// Visible = true
|
||||
// };
|
||||
// rootNode.ChildNodes.Add(pluginNode);
|
||||
// //return Task.CompletedTask;
|
||||
//}
|
||||
|
||||
public async Task ManageSiteMapAsync(AdminMenuItem rootNode)
|
||||
{
|
||||
if (!await _permissionService.AuthorizeAsync(StandardPermission.Configuration.MANAGE_PLUGINS))
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
public override string GetConfigurationPageUrl()
|
||||
|
|
@ -132,21 +330,23 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
|
|||
{
|
||||
if (widgetZone == PublicWidgetZones.ProductBoxAddinfoBefore || widgetZone == PublicWidgetZones.ProductDetailsBottom)
|
||||
{
|
||||
return zones.Any(widgetZone.Equals) ? typeof(ProductAIWidgetViewComponent) : null;
|
||||
return typeof(ProductAIWidgetViewComponent);
|
||||
}
|
||||
else if (widgetZone == AdminWidgetZones.ProductDetailsBlock)
|
||||
{
|
||||
return zones.Any(widgetZone.Equals) ? typeof(ProductAttributesViewComponent) : null;
|
||||
return typeof(ProductAttributesViewComponent);
|
||||
}
|
||||
|
||||
else if (widgetZone == AdminWidgetZones.OrderDetailsBlock)
|
||||
{
|
||||
return zones.Any(widgetZone.Equals) ? typeof(OrderAttributesViewComponent) : null;
|
||||
return typeof(OrderAttributesViewComponent);
|
||||
}
|
||||
else if (widgetZone == AdminWidgetZones.CustomerDetailsBlock)
|
||||
{
|
||||
return typeof(CustomerCreditWidgetViewComponent);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,237 @@
|
|||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using Nop.Core;
|
||||
using Nop.Core.Domain;
|
||||
using Nop.Core.Domain.Catalog;
|
||||
using Nop.Core.Domain.Common;
|
||||
using Nop.Core.Domain.Customers;
|
||||
using Nop.Core.Domain.Directory;
|
||||
using Nop.Core.Domain.Messages;
|
||||
using Nop.Core.Domain.Orders;
|
||||
using Nop.Core.Domain.Payments;
|
||||
using Nop.Core.Domain.Tax;
|
||||
using Nop.Core.Domain.Vendors;
|
||||
using Nop.Core.Events;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||
using Nop.Services.Attributes;
|
||||
using Nop.Services.Blogs;
|
||||
using Nop.Services.Catalog;
|
||||
using Nop.Services.Common;
|
||||
using Nop.Services.Customers;
|
||||
using Nop.Services.Directory;
|
||||
using Nop.Services.Events;
|
||||
using Nop.Services.Helpers;
|
||||
using Nop.Services.Html;
|
||||
using Nop.Services.Localization;
|
||||
using Nop.Services.Logging;
|
||||
using Nop.Services.Messages;
|
||||
using Nop.Services.News;
|
||||
using Nop.Services.Orders;
|
||||
using Nop.Services.Payments;
|
||||
using Nop.Services.Seo;
|
||||
using Nop.Services.Shipping;
|
||||
using Nop.Services.Stores;
|
||||
using Nop.Services.Vendors;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Infrastructure
|
||||
{
|
||||
public class FruitBankMessageTokenProvider : MessageTokenProvider
|
||||
{
|
||||
private readonly IOrderService _orderService;
|
||||
private readonly IPriceFormatter _priceFormatter;
|
||||
private readonly ICurrencyService _currencyService;
|
||||
private readonly CurrencySettings _currencySettings;
|
||||
private readonly FruitBankDbContext _dbContext;
|
||||
|
||||
public FruitBankMessageTokenProvider(
|
||||
CatalogSettings catalogSettings,
|
||||
CurrencySettings currencySettings,
|
||||
IActionContextAccessor actionContextAccessor,
|
||||
IAddressService addressService,
|
||||
IAttributeFormatter<AddressAttribute, AddressAttributeValue> addressAttributeFormatter,
|
||||
IAttributeFormatter<CustomerAttribute, CustomerAttributeValue> customerAttributeFormatter,
|
||||
IAttributeFormatter<VendorAttribute, VendorAttributeValue> vendorAttributeFormatter,
|
||||
IBlogService blogService,
|
||||
ICountryService countryService,
|
||||
ICurrencyService currencyService,
|
||||
ICustomerService customerService,
|
||||
IDateTimeHelper dateTimeHelper,
|
||||
IEventPublisher eventPublisher,
|
||||
IGenericAttributeService genericAttributeService,
|
||||
IGiftCardService giftCardService,
|
||||
IHtmlFormatter htmlFormatter,
|
||||
ILanguageService languageService,
|
||||
ILocalizationService localizationService,
|
||||
ILogger logger,
|
||||
INewsService newsService,
|
||||
IOrderService orderService,
|
||||
IPaymentPluginManager paymentPluginManager,
|
||||
IPaymentService paymentService,
|
||||
IPriceFormatter priceFormatter,
|
||||
IProductService productService,
|
||||
IRewardPointService rewardPointService,
|
||||
IShipmentService shipmentService,
|
||||
IStateProvinceService stateProvinceService,
|
||||
IStoreContext storeContext,
|
||||
IStoreService storeService,
|
||||
IUrlHelperFactory urlHelperFactory,
|
||||
IUrlRecordService urlRecordService,
|
||||
IWorkContext workContext,
|
||||
MessageTemplatesSettings templatesSettings,
|
||||
PaymentSettings paymentSettings,
|
||||
StoreInformationSettings storeInformationSettings,
|
||||
TaxSettings taxSettings,
|
||||
FruitBankDbContext dbContext
|
||||
) : base(
|
||||
catalogSettings,
|
||||
currencySettings,
|
||||
actionContextAccessor,
|
||||
addressService,
|
||||
addressAttributeFormatter,
|
||||
customerAttributeFormatter,
|
||||
vendorAttributeFormatter,
|
||||
blogService,
|
||||
countryService,
|
||||
currencyService,
|
||||
customerService,
|
||||
dateTimeHelper,
|
||||
eventPublisher,
|
||||
genericAttributeService,
|
||||
giftCardService,
|
||||
htmlFormatter,
|
||||
languageService,
|
||||
localizationService,
|
||||
logger,
|
||||
newsService,
|
||||
orderService,
|
||||
paymentPluginManager,
|
||||
paymentService,
|
||||
priceFormatter,
|
||||
productService,
|
||||
rewardPointService,
|
||||
shipmentService,
|
||||
stateProvinceService,
|
||||
storeContext,
|
||||
storeService,
|
||||
urlHelperFactory,
|
||||
urlRecordService,
|
||||
workContext,
|
||||
templatesSettings,
|
||||
paymentSettings,
|
||||
storeInformationSettings,
|
||||
taxSettings)
|
||||
{
|
||||
_orderService = orderService;
|
||||
_priceFormatter = priceFormatter;
|
||||
_currencyService = currencyService;
|
||||
_currencySettings = currencySettings;
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
public override async Task AddOrderTokensAsync(
|
||||
IList<Token> tokens,
|
||||
Order order,
|
||||
int languageId,
|
||||
int vendorId = 0)
|
||||
{
|
||||
// Run base first to populate all other Order.* tokens
|
||||
await base.AddOrderTokensAsync(tokens, order, languageId, vendorId);
|
||||
|
||||
// Replace the product table token with our custom version
|
||||
var existing = tokens.FirstOrDefault(t => t.Key == "Order.Product(s)");
|
||||
if (existing != null)
|
||||
tokens.Remove(existing);
|
||||
|
||||
tokens.Add(new Token("Order.Product(s)", await BuildCustomProductTableAsync(order, languageId), true));
|
||||
}
|
||||
|
||||
private async Task<string> BuildCustomProductTableAsync(Order order, int languageId)
|
||||
{
|
||||
var currency = await _currencyService.GetCurrencyByCodeAsync(order.CustomerCurrencyCode)
|
||||
?? await _currencyService.GetCurrencyByIdAsync(_currencySettings.PrimaryStoreCurrencyId);
|
||||
|
||||
var items = await _orderService.GetOrderItemsAsync(order.Id);
|
||||
var itemDtos = await _dbContext.OrderItemDtos.GetAllByOrderId(order.Id).ToListAsync();
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine(@"
|
||||
<table cellspacing=""0"" cellpadding=""6"" border=""1"" style=""width:100%;border-collapse:collapse;font-family:Arial,sans-serif;font-size:13px;"">
|
||||
<thead>
|
||||
<tr style=""background-color:#4a7c3f;color:#ffffff;"">
|
||||
<th style=""text-align:left;padding:8px;"">Termék</th>
|
||||
<th style=""text-align:center;padding:8px;"">Mennyiség</th>
|
||||
<th style=""text-align:right;padding:8px;"">Egységár</th>
|
||||
<th style=""text-align:right;padding:8px;"">Összesen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>");
|
||||
|
||||
var rowIndex = 0;
|
||||
foreach (var item in itemDtos)
|
||||
{
|
||||
var product = await _orderService.GetProductByOrderItemIdAsync(item.Id);
|
||||
if (product == null) continue;
|
||||
|
||||
var unitPrice = await _priceFormatter.FormatPriceAsync(
|
||||
item.UnitPriceInclTax, true, currency, languageId, true);
|
||||
var lineTotal = await _priceFormatter.FormatPriceAsync(
|
||||
item.PriceInclTax, true, currency, languageId, true);
|
||||
|
||||
var rowBg = rowIndex % 2 == 0 ? "#ffffff" : "#f2f7f0";
|
||||
rowIndex++;
|
||||
if (item.IsMeasurable)
|
||||
{
|
||||
var averageWeight = item.AverageWeight;
|
||||
var approximatePrice = item.Quantity * item.UnitPriceInclTax * (decimal)averageWeight;
|
||||
sb.AppendLine($@"
|
||||
<tr style=""background-color:{rowBg};"">
|
||||
<td style=""padding:8px;"">{product.Name}</td>
|
||||
<td style=""padding:8px;text-align:center;"">{item.Quantity}</td>
|
||||
<td style=""padding:8px;text-align:right;"">{unitPrice}</td>
|
||||
<td style=""padding:8px;text-align:right;"">Kalkuláció alatt, nagyságrendileg {approximatePrice}</td>
|
||||
</tr>");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine($@"
|
||||
<tr style=""background-color:{rowBg};"">
|
||||
<td style=""padding:8px;"">{product.Name}</td>
|
||||
<td style=""padding:8px;text-align:center;"">{item.Quantity}</td>
|
||||
<td style=""padding:8px;text-align:right;"">{unitPrice}</td>
|
||||
<td style=""padding:8px;text-align:right;"">{lineTotal}</td>
|
||||
</tr>");
|
||||
}
|
||||
}
|
||||
|
||||
var orderTotal = await _priceFormatter.FormatPriceAsync(
|
||||
order.OrderTotal, true, currency, languageId, true);
|
||||
|
||||
if(itemDtos.Any(i => i.IsMeasurable))
|
||||
{
|
||||
sb.AppendLine($@"
|
||||
<tr style=""background-color:#e8f0e5;font-weight:bold;"">
|
||||
<td colspan=""3"" style=""padding:8px;text-align:right;"">Végösszeg:</td>
|
||||
<td style=""padding:8px;text-align:right;"">Mérendő termék miatt kalkuláció alatt...</td>
|
||||
</tr>");
|
||||
}
|
||||
|
||||
else
|
||||
{
|
||||
sb.AppendLine($@"
|
||||
<tr style=""background-color:#e8f0e5;font-weight:bold;"">
|
||||
<td colspan=""3"" style=""padding:8px;text-align:right;"">Végösszeg:</td>
|
||||
<td style=""padding:8px;text-align:right;"">{orderTotal}</td>
|
||||
</tr>");
|
||||
|
||||
}
|
||||
|
||||
|
||||
sb.AppendLine(" </tbody>\n</table>");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ using Microsoft.AspNetCore.Mvc.Razor;
|
|||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Nop.Core.Domain.Orders;
|
||||
using Nop.Core.Infrastructure;
|
||||
using Nop.Data;
|
||||
|
|
@ -80,6 +81,7 @@ public class PluginNopStartup : INopStartup
|
|||
services.AddScoped<StockTakingDbTable>();
|
||||
services.AddScoped<StockTakingItemDbTable>();
|
||||
services.AddScoped<StockTakingItemPalletDbTable>();
|
||||
services.AddScoped<CustomerCreditDbTable>();
|
||||
|
||||
services.AddScoped<StockTakingDbContext>();
|
||||
services.AddScoped<FruitBankDbContext>();
|
||||
|
|
@ -90,8 +92,15 @@ public class PluginNopStartup : INopStartup
|
|||
services.AddScoped<IStockSignalREndpointServer, StockSignalREndpointServer>();
|
||||
|
||||
//services.AddScoped<CustomModelFactory, ICustomerModelFactory>();
|
||||
services.AddScoped<IPriceCalculationService, CustomPriceCalculationService>();
|
||||
services.AddScoped<PriceCalculationService, CustomPriceCalculationService>();
|
||||
//services.AddScoped<IPriceCalculationService, CustomPriceCalculationService>();
|
||||
//services.AddScoped<PriceCalculationService, CustomPriceCalculationService>();
|
||||
services.Replace(
|
||||
ServiceDescriptor.Scoped<IPriceCalculationService, CustomPriceCalculationService>()
|
||||
);
|
||||
//services.AddScoped<IMessageTokenProvider, FruitBankMessageTokenProvider>();
|
||||
services.Replace(
|
||||
ServiceDescriptor.Scoped<IMessageTokenProvider, FruitBankMessageTokenProvider>()
|
||||
);
|
||||
services.AddScoped<IConsumer<OrderPlacedEvent>, EventConsumer>();
|
||||
services.AddScoped<IOrderMeasurementService, OrderMeasurementService>();
|
||||
services.AddScoped<PendingMeasurementCheckoutFilter>();
|
||||
|
|
@ -119,8 +128,7 @@ public class PluginNopStartup : INopStartup
|
|||
//services.AddScoped<IAIAPIService, OpenAIApiService>();
|
||||
services.AddScoped<AICalculationService>();
|
||||
services.AddScoped<PdfToImageService>();
|
||||
services.AddScoped<IWorkflowMessageService, WorkflowMessageService>();
|
||||
|
||||
services.AddScoped<FruitBankNotificationService>();
|
||||
services.AddSingleton<IFileStorageProvider>(sp =>
|
||||
new LocalFileStorageProvider() // Uses default wwwroot/uploads
|
||||
// Or specify custom path:
|
||||
|
|
@ -129,6 +137,7 @@ public class PluginNopStartup : INopStartup
|
|||
|
||||
// Register the file storage service
|
||||
services.AddScoped<FileStorageService>();
|
||||
services.AddScoped<ICustomerCreditService, CustomerCreditService>();
|
||||
|
||||
services.AddControllersWithViews(options =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -21,51 +21,50 @@ public class RouteProvider : IRouteProvider
|
|||
pattern: "Admin/FruitBankPlugin/Configure",
|
||||
defaults: new { controller = "FruitBankPluginAdmin", action = "Configure", area = AreaNames.ADMIN });
|
||||
|
||||
//endpointRouteBuilder.MapHub<FruitBankHub>("/fbhub");//.RequireCors("AllowBlazorClient");
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Admin.Order.List",
|
||||
pattern: "Admin/Order/List",
|
||||
defaults: new { controller = "CustomOrder", action = "List", area = AreaNames.ADMIN }
|
||||
//constraints: new { area = AreaNames.ADMIN }
|
||||
);
|
||||
defaults: new { controller = "CustomOrder", action = "NewList", area = AreaNames.ADMIN });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Admin.Order.OrderList",
|
||||
pattern: "Admin/Order/OrderList",
|
||||
defaults: new { controller = "CustomOrder", action = "OrderList", area = AreaNames.ADMIN });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Admin.Order.FruitBankOrderList",
|
||||
pattern: "Admin/CustomOrder/FruitBankOrderList",
|
||||
defaults: new { controller = "CustomOrder", action = "FruitBankOrderList", area = AreaNames.ADMIN });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Admin.Order.UpdateOrderField",
|
||||
pattern: "Admin/CustomOrder/UpdateOrderField",
|
||||
defaults: new { controller = "CustomOrder", action = "UpdateOrderField", area = AreaNames.ADMIN });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Admin.Order.Test",
|
||||
pattern: "Admin/Order/Test",
|
||||
defaults: new { controller = "CustomOrder", action = "Test", area = AreaNames.ADMIN }
|
||||
//constraints: new { area = AreaNames.ADMIN }
|
||||
);
|
||||
defaults: new { controller = "CustomOrder", action = "Test", area = AreaNames.ADMIN });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Admin.Index",
|
||||
pattern: "Admin",
|
||||
defaults: new { controller = "CustomDashboard", action = "Index", area = AreaNames.ADMIN }
|
||||
//constraints: new { area = AreaNames.ADMIN }
|
||||
);
|
||||
defaults: new { controller = "CustomDashboard", action = "Index", area = AreaNames.ADMIN });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Admin.Shipping.List",
|
||||
pattern: "Admin/Shipping/List",
|
||||
defaults: new { controller = "Shipping", action = "List", area = AreaNames.ADMIN }
|
||||
);
|
||||
defaults: new { controller = "Shipping", action = "List", area = AreaNames.ADMIN });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Admin.Shipping.ShippingList",
|
||||
pattern: "Admin/Shipping/ShippingList",
|
||||
defaults: new { controller = "Shipping", action = "ShippingList", area = AreaNames.ADMIN }
|
||||
);
|
||||
defaults: new { controller = "Shipping", action = "ShippingList", area = AreaNames.ADMIN });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Admin.Invoices.List",
|
||||
pattern: "Admin/Invoices/List",
|
||||
defaults: new { controller = "Invoice", action = "List", area = AreaNames.ADMIN }
|
||||
);
|
||||
defaults: new { controller = "Invoice", action = "List", area = AreaNames.ADMIN });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Admin.Shipping.Create",
|
||||
|
|
@ -181,6 +180,53 @@ public class RouteProvider : IRouteProvider
|
|||
name: "Plugin.FruitBank.Admin.ExtractText",
|
||||
pattern: "Admin/ExtractText",
|
||||
defaults: new { controller = "FileManager", action = "ImageTextExtraction", area = AreaNames.ADMIN });
|
||||
|
||||
// ── Customer Credit ──────────────────────────────────────────────────
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Admin.CustomerCredit.List",
|
||||
pattern: "Admin/CustomerCredit/List",
|
||||
defaults: new { controller = "CustomerCredit", action = "List", area = AreaNames.ADMIN });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Admin.CustomerCredit.CustomerCreditList",
|
||||
pattern: "Admin/CustomerCredit/CustomerCreditList",
|
||||
defaults: new { controller = "CustomerCredit", action = "CustomerCreditList", area = AreaNames.ADMIN });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.Admin.CustomerCredit.UpdateCreditLimit",
|
||||
pattern: "Admin/CustomerCredit/UpdateCreditLimit",
|
||||
defaults: new { controller = "CustomerCredit", action = "UpdateCreditLimit", area = AreaNames.ADMIN });
|
||||
|
||||
// ── Public: Quick Order ──────────────────────────────────────────────
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.QuickOrder.Index",
|
||||
pattern: "gyors-rendeles",
|
||||
defaults: new { controller = "QuickOrder", action = "Index" });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.QuickOrder.GetAllProducts",
|
||||
pattern: "gyors-rendeles/osszes-termek",
|
||||
defaults: new { controller = "QuickOrder", action = "GetAllProducts" });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.QuickOrder.SearchProducts",
|
||||
pattern: "gyors-rendeles/kereses",
|
||||
defaults: new { controller = "QuickOrder", action = "SearchProducts" });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.QuickOrder.TranscribeAndSearch",
|
||||
pattern: "gyors-rendeles/hang",
|
||||
defaults: new { controller = "QuickOrder", action = "TranscribeAndSearch" });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.QuickOrder.AddToCart",
|
||||
pattern: "gyors-rendeles/kosarba",
|
||||
defaults: new { controller = "QuickOrder", action = "AddToCart" });
|
||||
|
||||
endpointRouteBuilder.MapControllerRoute(
|
||||
name: "Plugin.FruitBank.QuickOrder.GetCartItems",
|
||||
pattern: "gyors-rendeles/kosar",
|
||||
defaults: new { controller = "QuickOrder", action = "GetCartItems" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Language Name="English" IsDefault="false" IsRightToLeft="false">
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
Customer Credit — Plugins.Misc.FruitBankPlugin.CustomerCredit.*
|
||||
Import: Admin > Configuration > Languages > [English] > Import resources
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
|
||||
<!-- Page -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle">
|
||||
<Value><![CDATA[Customer Credit Management]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.BackToCustomer">
|
||||
<Value><![CDATA[Back to customer]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.EditTitle">
|
||||
<Value><![CDATA[Set Credit Limit]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Summary cards -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit">
|
||||
<Value><![CDATA[Credit Limit]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimitHint">
|
||||
<Value><![CDATA[Set to 0 to block all orders. Leave the record absent to allow unlimited.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance">
|
||||
<Value><![CDATA[Outstanding Balance]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit">
|
||||
<Value><![CDATA[Remaining Credit]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited">
|
||||
<Value><![CDATA[Unlimited]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Form -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment">
|
||||
<Value><![CDATA[Notes]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.Save">
|
||||
<Value><![CDATA[Save]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- List page -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.CustomerName">
|
||||
<Value><![CDATA[Customer Name]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.CustomerEmail">
|
||||
<Value><![CDATA[Email]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Unpaid orders table -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.UnpaidOrdersTitle">
|
||||
<Value><![CDATA[Unpaid / Pending Orders]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.NoUnpaidOrders">
|
||||
<Value><![CDATA[No unpaid orders.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderId">
|
||||
<Value><![CDATA[Order #]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderDate">
|
||||
<Value><![CDATA[Date]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderTotal">
|
||||
<Value><![CDATA[Total]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderStatus">
|
||||
<Value><![CDATA[Order Status]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.PaymentStatus">
|
||||
<Value><![CDATA[Payment Status]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.Total">
|
||||
<Value><![CDATA[Total]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Enforcement -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderBlocked">
|
||||
<Value><![CDATA[Your order cannot be placed because your outstanding balance has reached your credit limit. Please settle your existing balance first.]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
</Language>
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Language Name="Hungarian" IsDefault="false" IsRightToLeft="false">
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
Customer Credit — Plugins.Misc.FruitBankPlugin.CustomerCredit.*
|
||||
Import: Admin > Konfiguráció > Nyelvek > [Magyar] > Erőforrások importálása
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
|
||||
<!-- Oldal -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle">
|
||||
<Value><![CDATA[Ügyfél hitelkeret kezelés]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.BackToCustomer">
|
||||
<Value><![CDATA[Vissza az ügyfélhez]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.EditTitle">
|
||||
<Value><![CDATA[Hitelkeret beállítása]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Összefoglaló kártyák -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit">
|
||||
<Value><![CDATA[Hitelkeret]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimitHint">
|
||||
<Value><![CDATA[0 esetén minden rendelés le van tiltva. Ha nincs rekord, a limit korlátlan.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance">
|
||||
<Value><![CDATA[Kintlévő egyenleg]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit">
|
||||
<Value><![CDATA[Szabad keret]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited">
|
||||
<Value><![CDATA[Korlátlan]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Űrlap -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment">
|
||||
<Value><![CDATA[Megjegyzés]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.Save">
|
||||
<Value><![CDATA[Mentés]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Lista oldal -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.CustomerName">
|
||||
<Value><![CDATA[Ügyfél neve]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.CustomerEmail">
|
||||
<Value><![CDATA[Email]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Kifizetetlen rendelések táblázat -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.UnpaidOrdersTitle">
|
||||
<Value><![CDATA[Kifizetetlen / függő rendelések]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.NoUnpaidOrders">
|
||||
<Value><![CDATA[Nincs kifizetetlen rendelés.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderId">
|
||||
<Value><![CDATA[Rendelés #]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderDate">
|
||||
<Value><![CDATA[Dátum]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderTotal">
|
||||
<Value><![CDATA[Összeg]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderStatus">
|
||||
<Value><![CDATA[Rendelés állapot]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.PaymentStatus">
|
||||
<Value><![CDATA[Fizetési állapot]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.Total">
|
||||
<Value><![CDATA[Összesen]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Hitelkeret túllépés hibaüzenet -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderBlocked">
|
||||
<Value><![CDATA[A rendelést nem lehet leadni, mert a kintlévő egyenlege elérte a hitelkeretét. Kérjük, először rendezze meglévő tartozását.]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
</Language>
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Language Name="English" IsDefault="false" IsRightToLeft="false">
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
Quick Order page — Plugins.Misc.FruitBankPlugin.QuickOrder.*
|
||||
Import: Admin > Configuration > Languages > [English] > Import resources
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
|
||||
<!-- Page general -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.PageTitle">
|
||||
<Value><![CDATA[Quick Order]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.MenuLabel">
|
||||
<Value><![CDATA[Quick Order]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Search bar -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.MicButtonTitle">
|
||||
<Value><![CDATA[Start voice recording]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.StopButtonTitle">
|
||||
<Value><![CDATA[Stop]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.SearchPlaceholder">
|
||||
<Value><![CDATA[Search for products (e.g. orange 100, apple 50) or use the microphone...]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.ActiveRecordingPlaceholder">
|
||||
<Value><![CDATA[Listening... (start speaking)]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.SearchButton">
|
||||
<Value><![CDATA[Search]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.ListeningStatus">
|
||||
<Value><![CDATA[Listening...]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Product panel -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.TranscribedLabel">
|
||||
<Value><![CDATA[I heard:]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.NoResultsText">
|
||||
<Value><![CDATA[No products found. Try a different search.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.LoadingProducts">
|
||||
<Value><![CDATA[Loading products...]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.AllProductsLabel">
|
||||
<Value><![CDATA[All products]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.SearchResultsLabel">
|
||||
<Value><![CDATA[Results]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.QuantityHint">
|
||||
<Value><![CDATA[— set quantity, then add to cart:]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.MeasurableBadge">
|
||||
<Value><![CDATA[Requires weighing]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.StockLabel">
|
||||
<Value><![CDATA[Stock:]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.StockLimitedPrefix">
|
||||
<Value><![CDATA[Only]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.StockLimitedSuffix">
|
||||
<Value><![CDATA[pcs available]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.PieceUnit">
|
||||
<Value><![CDATA[pcs]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.PricePerPiece">
|
||||
<Value><![CDATA[Ft/pcs]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.AddToCartTitle">
|
||||
<Value><![CDATA[Add to cart]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Cart panel -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.CartTitle">
|
||||
<Value><![CDATA[Cart]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine1">
|
||||
<Value><![CDATA[Your cart is empty.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine2">
|
||||
<Value><![CDATA[Search for products and add them.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.CartTotalNote">
|
||||
<Value><![CDATA[Prices for weighed items will be finalized after measurement.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.EstimatedTotal">
|
||||
<Value><![CDATA[Estimated total:]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.CheckoutButton">
|
||||
<Value><![CDATA[Proceed to checkout]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.ViewCartButton">
|
||||
<Value><![CDATA[View cart]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.AddedToCart">
|
||||
<Value><![CDATA[added]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- JavaScript voice recording strings -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.BrowserNotSupported">
|
||||
<Value><![CDATA[Your browser does not support audio recording.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.MicAccessError">
|
||||
<Value><![CDATA[Could not access microphone: ]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.MicPermissionDenied">
|
||||
<Value><![CDATA[Please allow microphone access.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.MicNotFound">
|
||||
<Value><![CDATA[No microphone found.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.Calibrating">
|
||||
<Value><![CDATA[Calibrating...]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.Processing">
|
||||
<Value><![CDATA[Processing...]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.RecordingFailed">
|
||||
<Value><![CDATA[Could not record audio. Please try again.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeHigh">
|
||||
<Value><![CDATA[Loud and clear]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeSpeaking">
|
||||
<Value><![CDATA[Speaking...]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeLouder">
|
||||
<Value><![CDATA[Speak louder!]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.Searching">
|
||||
<Value><![CDATA[Searching...]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.EnterProducts">
|
||||
<Value><![CDATA[Please enter the products!]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.SearchError">
|
||||
<Value><![CDATA[Error during search.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.AudioError">
|
||||
<Value><![CDATA[Error processing audio.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.AddToCartError">
|
||||
<Value><![CDATA[Error adding item to cart.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.ErrorPrefix">
|
||||
<Value><![CDATA[Error: ]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Controller error messages (JSON responses) -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.NotLoggedIn">
|
||||
<Value><![CDATA[Not logged in]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.NoTextProvided">
|
||||
<Value><![CDATA[No text provided]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.NoProductsIdentified">
|
||||
<Value><![CDATA[Could not identify products]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.NoAudioReceived">
|
||||
<Value><![CDATA[No audio received]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.TranscriptionFailed">
|
||||
<Value><![CDATA[Speech recognition failed]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.ProductNotAvailable">
|
||||
<Value><![CDATA[Product not available]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.InvalidProductOrQuantity">
|
||||
<Value><![CDATA[Invalid product or quantity]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
</Language>
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Language Name="Hungarian" IsDefault="false" IsRightToLeft="false">
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
Gyors rendelés oldal — Plugins.Misc.FruitBankPlugin.QuickOrder.*
|
||||
Import: Admin > Konfiguráció > Nyelvek > [Magyar] > Erőforrások importálása
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
|
||||
<!-- Oldal általános -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.PageTitle">
|
||||
<Value><![CDATA[Gyors rendelés]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.MenuLabel">
|
||||
<Value><![CDATA[Gyors rendelés]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Keresősáv -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.MicButtonTitle">
|
||||
<Value><![CDATA[Hangfelvétel indítása]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.StopButtonTitle">
|
||||
<Value><![CDATA[Leállítás]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.SearchPlaceholder">
|
||||
<Value><![CDATA[Keress termékeket (pl. narancs 100, alma 50) vagy használd a mikrofont...]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.ActiveRecordingPlaceholder">
|
||||
<Value><![CDATA[Figyelés... (kezdj el beszélni)]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.SearchButton">
|
||||
<Value><![CDATA[Keresés]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.ListeningStatus">
|
||||
<Value><![CDATA[Figyelés...]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Termék panel -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.TranscribedLabel">
|
||||
<Value><![CDATA[Hallottam:]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.NoResultsText">
|
||||
<Value><![CDATA[Nem találtunk termékeket. Próbáljunk más keresést.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.LoadingProducts">
|
||||
<Value><![CDATA[Termékek betöltése...]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.AllProductsLabel">
|
||||
<Value><![CDATA[Összes termék]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.SearchResultsLabel">
|
||||
<Value><![CDATA[Találatok]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.QuantityHint">
|
||||
<Value><![CDATA[— állítsd be a mennyiséget, majd add a kosárhoz:]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.MeasurableBadge">
|
||||
<Value><![CDATA[Súlymérést igényel]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.StockLabel">
|
||||
<Value><![CDATA[Készlet:]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.StockLimitedPrefix">
|
||||
<Value><![CDATA[Csak]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.StockLimitedSuffix">
|
||||
<Value><![CDATA[db elérhető]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.PieceUnit">
|
||||
<Value><![CDATA[db]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.PricePerPiece">
|
||||
<Value><![CDATA[Ft/db]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.AddToCartTitle">
|
||||
<Value><![CDATA[Kosárba]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Kosár panel -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.CartTitle">
|
||||
<Value><![CDATA[Kosár]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine1">
|
||||
<Value><![CDATA[A kosár üres.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine2">
|
||||
<Value><![CDATA[Keress termékeket és add hozzá őket.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.CartTotalNote">
|
||||
<Value><![CDATA[A súlymérést igénylő tételeknél az ár a mérés után véglegesedik.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.EstimatedTotal">
|
||||
<Value><![CDATA[Becsült összeg:]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.CheckoutButton">
|
||||
<Value><![CDATA[Tovább a pénztárhoz]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.ViewCartButton">
|
||||
<Value><![CDATA[Kosár megtekintése]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.AddedToCart">
|
||||
<Value><![CDATA[hozzáadva]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- JavaScript hangfelvétel szövegek -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.BrowserNotSupported">
|
||||
<Value><![CDATA[A böngésző nem támogatja a hangfelvételt.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.MicAccessError">
|
||||
<Value><![CDATA[Nem sikerült a mikrofon elérése: ]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.MicPermissionDenied">
|
||||
<Value><![CDATA[Engedélyezd a mikrofon használatát.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.MicNotFound">
|
||||
<Value><![CDATA[Nincs mikrofon csatlakoztatva.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.Calibrating">
|
||||
<Value><![CDATA[Kalibrálódás...]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.Processing">
|
||||
<Value><![CDATA[Feldolgozás...]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.RecordingFailed">
|
||||
<Value><![CDATA[Nem sikerült hangot rögzíteni. Kérem, próbálja újra.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeHigh">
|
||||
<Value><![CDATA[Hangos és érthető]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeSpeaking">
|
||||
<Value><![CDATA[Beszél...]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeLouder">
|
||||
<Value><![CDATA[Hangosabban!]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.Searching">
|
||||
<Value><![CDATA[Keresés...]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.EnterProducts">
|
||||
<Value><![CDATA[Kérem, add meg a termékeket!]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.SearchError">
|
||||
<Value><![CDATA[Hiba a keresés során.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.AudioError">
|
||||
<Value><![CDATA[Hiba a hangfeldolgozás során.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.AddToCartError">
|
||||
<Value><![CDATA[Hiba a kosárba helyezés során.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.ErrorPrefix">
|
||||
<Value><![CDATA[Hiba: ]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Controller hibaüzenetek (JSON válaszok) -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.NotLoggedIn">
|
||||
<Value><![CDATA[Nincs bejelentkezve]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.NoTextProvided">
|
||||
<Value><![CDATA[Nincs szöveg megadva]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.NoProductsIdentified">
|
||||
<Value><![CDATA[Nem sikerült termékeket azonosítani]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.NoAudioReceived">
|
||||
<Value><![CDATA[Nem érkezett hangfájl]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.TranscriptionFailed">
|
||||
<Value><![CDATA[Nem sikerült a hangfelismerés]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.ProductNotAvailable">
|
||||
<Value><![CDATA[A termék nem elérhető]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.InvalidProductOrQuantity">
|
||||
<Value><![CDATA[Érvénytelen termék vagy mennyiség]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
</Language>
|
||||
|
|
@ -42,6 +42,7 @@ public partial class NameCompatibility : INameCompatibility
|
|||
{ typeof(StockTaking), FruitBankConstClient.StockTakingDbTableName},
|
||||
{ typeof(StockTakingItem), FruitBankConstClient.StockTakingItemDbTableName},
|
||||
{ typeof(StockTakingItemPallet), FruitBankConstClient.StockTakingItemPalletDbTableName},
|
||||
{ typeof(CustomerCredit), FruitBankConstClient.CustomerCreditDbTableName},
|
||||
};
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
using Nop.Web.Framework.Models;
|
||||
using Nop.Web.Framework.Mvc.ModelBinding;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Models;
|
||||
|
||||
public record CustomerCreditWidgetModel : BaseNopModel
|
||||
{
|
||||
public int CustomerId { get; set; }
|
||||
public bool HasCreditLimit { get; set; }
|
||||
|
||||
[NopResourceDisplayName("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit")]
|
||||
public decimal CreditLimit { get; set; }
|
||||
|
||||
[NopResourceDisplayName("Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance")]
|
||||
public decimal OutstandingBalance { get; set; }
|
||||
|
||||
[NopResourceDisplayName("Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit")]
|
||||
public decimal? RemainingCredit { get; set; }
|
||||
|
||||
public string? Comment { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
using System;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Models.Orders
|
||||
{
|
||||
/// <summary>
|
||||
/// Lightweight DTO returned by the FruitBankOrderList endpoint.
|
||||
/// Contains only what the grid needs – avoids serialising heavy OrderModel navigation properties.
|
||||
/// </summary>
|
||||
public record FruitBankOrderRowDto
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public string CustomOrderNumber { get; init; }
|
||||
public string CustomerCompany { get; init; }
|
||||
public int CustomerId { get; init; }
|
||||
|
||||
// FruitBank-specific fields
|
||||
public string InnvoiceTechId { get; init; }
|
||||
public bool IsAllOrderItemAvgWeightValid { get; init; }
|
||||
public bool IsMeasurable { get; init; }
|
||||
public int MeasuringStatus { get; init; }
|
||||
public string MeasuringStatusString { get; init; }
|
||||
public DateTime? DateOfReceipt { get; init; }
|
||||
|
||||
// NopCommerce order fields
|
||||
public int OrderStatusId { get; init; }
|
||||
public string OrderStatus { get; init; }
|
||||
public int PaymentStatusId { get; init; }
|
||||
public string PaymentStatus { get; init; }
|
||||
public int ShippingStatusId { get; init; }
|
||||
public string ShippingStatus { get; init; }
|
||||
public string StoreName { get; init; }
|
||||
public DateTime CreatedOn { get; init; }
|
||||
public string OrderTotal { get; init; }
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<None Remove="Areas\Admin\Views\Order\Edit.cshtml" />
|
||||
<None Remove="css\quick-order.css" />
|
||||
<None Remove="logo.jpg" />
|
||||
<None Remove="plugin.json" />
|
||||
<None Remove="Views\_ViewImports.cshtml" />
|
||||
|
|
@ -65,6 +66,9 @@
|
|||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="css\quick-order.css">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="logo.jpg">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
|
|
@ -169,6 +173,12 @@
|
|||
<None Update="Areas\Admin\Views\AppDownload\Index.cshtml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Areas\Admin\Views\CustomerCredit\Details.cshtml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Areas\Admin\Views\CustomerCredit\List.cshtml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Areas\Admin\Views\Extras\ImageTextExtraction.cshtml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
|
|
@ -181,6 +191,9 @@
|
|||
<None Update="Areas\Admin\Views\Order\FileUploadGridComponent.cshtml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Areas\Admin\Views\Order\FruitBankOrderList.cshtml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Areas\Admin\Views\Order\TestGridComponent.cshtml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
|
|
@ -646,6 +659,9 @@
|
|||
<None Update="Views\Checkout\PendingMeasurementWarning.cshtml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Views\CustomerCreditWidget.cshtml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Views\ProductAIListWidget.cshtml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
|
|
@ -661,6 +677,9 @@
|
|||
<None Update="Views\ProductAIWidget.cshtml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Views\QuickOrder\Index.cshtml">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,351 @@
|
|||
# FruitBank Plugin – Claude Skill Reference
|
||||
|
||||
> **Purpose:** This file is a reference document for Claude to quickly understand the FruitBank NopCommerce plugin codebase, patterns, and conventions so that new work sessions can ramp up without re-reading the entire codebase from scratch.
|
||||
|
||||
---
|
||||
|
||||
## 1. Project Identity
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| Plugin system name | `Misc.FruitBankPlugin` |
|
||||
| DLL | `Nop.Plugin.Misc.FruitBankPlugin.dll` |
|
||||
| Namespace root | `Nop.Plugin.Misc.FruitBankPlugin` |
|
||||
| NopCommerce version | **4.80** |
|
||||
| Author | Adam Gelencser |
|
||||
| Plugin source path | `D:\REPOS\MANGO\source\Nopcommerce.Common\4.70\Plugins\Nop.Plugin.Misc.AIPlugin` |
|
||||
| Theme path | `D:\REPOS\MANGO\source\FruitBank\Presentation\Nop.Web\Themes\CarHaven` |
|
||||
|
||||
The plugin is called **AIPlugin** on disk (folder/csproj) but the assembly and namespace use `FruitBankPlugin`. Both names are the same thing.
|
||||
|
||||
---
|
||||
|
||||
## 2. Business Domain
|
||||
|
||||
FruitBank is a **Hungarian fruit and vegetable wholesale company** running a private B2B NopCommerce webshop. The typical user is a warehouse employee or admin working on mobile. Key business concepts:
|
||||
|
||||
- **Partners** – business customers (companies), matched by name across multiple systems
|
||||
- **Shipping documents** – PDF/image documents received from suppliers, parsed by AI
|
||||
- **Measurable products** – products that require physical weighing before price is finalized; `IsMeasurable` is determined server-side only
|
||||
- **Stock taking** – periodic inventory audit workflow with discrepancy reports
|
||||
- **InnVoice** – external accounting/invoicing system, synced via `InnVoiceOrderService` / `InnVoiceApiService`
|
||||
- **Voice ordering** – warehouse staff dictate orders in Hungarian; transcribed via Whisper
|
||||
|
||||
---
|
||||
|
||||
## 3. Folder Structure
|
||||
|
||||
```
|
||||
Nop.Plugin.Misc.AIPlugin/
|
||||
├── Areas/Admin/
|
||||
│ ├── Controllers/ # All admin-area controllers
|
||||
│ ├── Components/ # Admin view components
|
||||
│ ├── Factories/ # CustomOrderModelFactory, CustomProductModelFactory
|
||||
│ ├── Models/ # Admin view models (extended Nop models)
|
||||
│ ├── Validators/
|
||||
│ └── Views/ # Admin Razor views; custom layouts: _FruitBankAdminLayout.cshtml
|
||||
├── Controllers/ # Public-facing controllers (QuickOrder, Checkout, FruitBankData)
|
||||
├── Components/ # Widget view components (ProductAI, ProductAttributes, OrderAttributes)
|
||||
├── css/ / js/ # Static assets for the plugin
|
||||
├── Domains/
|
||||
│ └── DataLayer/ # LinqToDB table classes + DbContexts
|
||||
│ ├── FruitBankDbContext.cs
|
||||
│ ├── StockTakingDbContext.cs
|
||||
│ └── *DbTable.cs # One file per custom table
|
||||
├── Infrastructure/
|
||||
│ ├── PluginNopStartup.cs # DI registration + SignalR + middleware
|
||||
│ ├── RouteProvider.cs
|
||||
│ ├── ViewLocationExpander.cs
|
||||
│ └── FruitBankMessageTokenProvider.cs # Overrides IMessageTokenProvider
|
||||
├── Services/ # Business logic services
|
||||
├── Localization/
|
||||
│ ├── quickorder.en.xml
|
||||
│ └── quickorder.hu.xml
|
||||
├── FruitBankPlugin.cs # Main plugin class (IWidgetPlugin)
|
||||
├── FruitBankSettings.cs # Plugin settings (ApiKey etc.)
|
||||
├── FruitBankConst.cs # Constants
|
||||
└── plugin.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Key Services
|
||||
|
||||
### AI / LLM
|
||||
| Service | Purpose |
|
||||
|---|---|
|
||||
| `OpenAIApiService` | Primary OpenAI integration – chat completions, Whisper transcription |
|
||||
| `OpenAiService` | Lightweight wrapper using `gpt-4o-mini` for simple prompts |
|
||||
| `CerebrasAPIService` | Alternative LLM provider |
|
||||
| `ReplicateService` | Replicate.com API (image/audio models); registered with a hardcoded Bearer token |
|
||||
| `AICalculationService` | AI-assisted price/measurement calculations |
|
||||
|
||||
### Storage
|
||||
| Service | Purpose |
|
||||
|---|---|
|
||||
| `FileStorageService` | Generic file storage: SHA256 hash dedup, GZip compression, path building |
|
||||
| `IFileStorageProvider` / `LocalFileStorageProvider` | Strategy pattern storage backend (currently local disk / wwwroot/uploads) |
|
||||
|
||||
**FileStorageService patterns:**
|
||||
- Calculates SHA256 on upload BEFORE any AI processing → prevents duplicate API calls
|
||||
- Skips GZip for already-compressed formats (jpg, pdf, mp4, zip, etc.)
|
||||
- Path format: `{userId}/{featureName}/{entityType}-{entityId}/{fileName}_{id}.ext`
|
||||
- DB record created first to get ID, then file is saved; rolled back on failure
|
||||
|
||||
### Order / Measurement
|
||||
| Service | Purpose |
|
||||
|---|---|
|
||||
| `OrderMeasurementService` / `IOrderMeasurementService` | Handles orders that contain measurable products |
|
||||
| `MeasurementService` / `IMeasurementService` | Core weighing logic |
|
||||
| `InnVoiceOrderService` | Syncs orders with InnVoice accounting system |
|
||||
| `InnVoiceApiService` | HTTP client for the InnVoice REST API |
|
||||
|
||||
### Infrastructure
|
||||
| Service | Purpose |
|
||||
|---|---|
|
||||
| `FruitBankAttributeService` | Custom product/order attribute helpers |
|
||||
| `LockService` / `ILockService` | Singleton distributed lock |
|
||||
| `PdfToImageService` | Converts PDF pages to images for AI vision processing |
|
||||
| `EventConsumer` | Handles `OrderPlacedEvent` |
|
||||
| `FruitBankHub` | SignalR hub for real-time admin notifications |
|
||||
|
||||
---
|
||||
|
||||
## 5. Admin Controllers
|
||||
|
||||
| Controller | Purpose |
|
||||
|---|---|
|
||||
| `CustomOrderController` | Extended order management: **split order** feature (audit-based + manual selection modes), order notes, SignalR events |
|
||||
| `CustomDashboardController` | AI-powered admin dashboard with `GetWelcomeMessageAsync` (store summary, order totals, stock discrepancies, OpenWeatherMap weather) |
|
||||
| `ShippingController` | Shipping document management + AI PDF extraction workflow |
|
||||
| `VoiceOrderController` | Voice-to-order admin tool (mobile-optimized) |
|
||||
| `FruitBankAudioController` | Audio upload/processing endpoint for Whisper transcription |
|
||||
| `InvoiceController` | Invoice generation and management |
|
||||
| `InnVoiceOrderController` | InnVoice order sync UI |
|
||||
| `InnVoiceOrderSyncController` | InnVoice sync API endpoints |
|
||||
| `ManagementPageController` | General management page |
|
||||
| `FileManagerController` + `FileManagerScriptsApiController` | File manager UI |
|
||||
| `FileStorageController` | File storage API endpoints |
|
||||
| `AppDownloadController` | App download/distribution page |
|
||||
| `FruitBankPluginAdminController` | Plugin configuration page |
|
||||
| `CustomProductController` | Extended product admin (IsMeasurable etc.) |
|
||||
|
||||
---
|
||||
|
||||
## 6. Public Controllers
|
||||
|
||||
| Controller | Route | Purpose |
|
||||
|---|---|---|
|
||||
| `QuickOrderController` | `/gyors-rendeles` | Customer-facing quick order page with voice + text search |
|
||||
| `CheckoutController` | `/checkout/*` | Custom checkout flow override |
|
||||
| `FruitBankDataController` | `/fruitbankdata/*` | Public data API endpoints; also implements `IFruitBankDataControllerServer` |
|
||||
|
||||
---
|
||||
|
||||
## 7. Widget Zones
|
||||
|
||||
The plugin registers widgets in:
|
||||
- `PublicWidgetZones.ProductBoxAddinfoBefore` → `ProductAIWidgetViewComponent`
|
||||
- `PublicWidgetZones.ProductDetailsBottom` → `ProductAIWidgetViewComponent`
|
||||
- `AdminWidgetZones.ProductDetailsBlock` → `ProductAttributesViewComponent`
|
||||
- `AdminWidgetZones.OrderDetailsBlock` → `OrderAttributesViewComponent`
|
||||
|
||||
---
|
||||
|
||||
## 8. DI Registration Patterns (PluginNopStartup)
|
||||
|
||||
Important overrides / replacements:
|
||||
```csharp
|
||||
// Replaces the default NopCommerce price calculator
|
||||
services.Replace(ServiceDescriptor.Scoped<IPriceCalculationService, CustomPriceCalculationService>());
|
||||
|
||||
// Overrides email order table rendering
|
||||
services.Replace(ServiceDescriptor.Scoped<IMessageTokenProvider, FruitBankMessageTokenProvider>());
|
||||
|
||||
// Overrides generic attribute service
|
||||
services.AddScoped<IGenericAttributeService, GenericAttributeService>();
|
||||
|
||||
// Overrides order model and product model factories
|
||||
services.AddScoped<IOrderModelFactory, CustomOrderModelFactory>();
|
||||
services.AddScoped<IProductModelFactory, CustomProductModelFactory>();
|
||||
|
||||
// Overrides WorkflowMessageService (order emails)
|
||||
services.AddScoped<IWorkflowMessageService, WorkflowMessageService>();
|
||||
```
|
||||
|
||||
SignalR is configured with:
|
||||
- MaximumReceiveMessageSize / StatefulReconnectBufferSize: 30 MB
|
||||
- `DevAdminSignalRHub` on `/{FruitBankConstClient.DefaultHubName}` (WebSockets only)
|
||||
- `LoggerSignalRHub` on `/{FruitBankConstClient.LoggerHubName}`
|
||||
|
||||
---
|
||||
|
||||
## 9. Database / Data Layer
|
||||
|
||||
Custom tables use **LinqToDB** (not EF Core) through wrapper DbTable classes registered in DI. Two DbContext wrappers:
|
||||
- `FruitBankDbContext` – main plugin data
|
||||
- `StockTakingDbContext` – stock taking workflow data
|
||||
|
||||
Key custom tables:
|
||||
| DbTable class | Purpose |
|
||||
|---|---|
|
||||
| `PartnerDbTable` | Business partners (wholesale customers) |
|
||||
| `ShippingDbTable` | Shipping records |
|
||||
| `ShippingDocumentDbTable` | Parsed shipping document metadata |
|
||||
| `ShippingItemDbTable` | Line items from shipping documents |
|
||||
| `ShippingDocumentToFilesDbTable` | Junction: document ↔ file |
|
||||
| `FilesDbTable` | Generic file records (hash, compression flag, raw text) |
|
||||
| `OrderDtoDbTable` / `OrderItemDtoDbTable` | Order DTO projections |
|
||||
| `OrderItemPalletDbTable` / `ShippingItemPalletDbTable` / etc. | Pallet tracking for measurement workflow |
|
||||
| `StockTakingDbTable` / `StockTakingItemDbTable` | Stock audit records |
|
||||
| `StockQuantityHistoryDtoDbTable` | Stock movement history |
|
||||
| `MeasuringItemPalletBaseDbTable` | Base pallet measuring data |
|
||||
|
||||
**N+1 query prevention:** Always batch DB calls with `Task.WhenAll`. Never query per-item inside a loop.
|
||||
|
||||
---
|
||||
|
||||
## 10. Localization
|
||||
|
||||
All resource keys follow the prefix: `Plugins.Misc.FruitBankPlugin.*`
|
||||
|
||||
- Keys are registered programmatically in `FruitBankPlugin.InstallAsync()` for **both EN and HU**
|
||||
- XML locale files in `/Localization/`: `quickorder.en.xml`, `quickorder.hu.xml`
|
||||
- When adding new keys: update **all three places** (InstallAsync + both XML files)
|
||||
- Hungarian is the **primary** language; English is secondary
|
||||
- Use `_localizationService.AddOrUpdateLocaleResourceAsync("key", "value", "HU")` pattern
|
||||
|
||||
Common key prefixes:
|
||||
- `Plugins.Misc.FruitBankPlugin.Menu.*` – navigation
|
||||
- `Plugins.Misc.FruitBankPlugin.QuickOrder.*` – quick order page (extensive set)
|
||||
|
||||
---
|
||||
|
||||
## 11. Quick Order Page (`/gyors-rendeles`)
|
||||
|
||||
**Controller:** `QuickOrderController`
|
||||
**View:** `/Views/QuickOrder/`
|
||||
**CSS:** `/css/quick-order.css` (in plugin) + deployed to CarHaven theme
|
||||
|
||||
Design system tokens (CarHaven theme):
|
||||
```css
|
||||
--theme-color: #2d7a3a /* green */
|
||||
--active-color: #f4a236 /* amber */
|
||||
--dark: #1a3c22
|
||||
--light-bg: #f5f7f2
|
||||
font-family: 'DM Sans'
|
||||
border-radius: 8px
|
||||
```
|
||||
|
||||
Product cards use full-width flex rows: `.product-card { flex-direction: row }` with `.pc-body` (left, grows) and `.pc-actions` (right, fixed).
|
||||
|
||||
Navigation menu integration:
|
||||
- CarHaven `TopMenu/Default.cshtml` has a `<li class="quick-order-menu-item">` for both desktop (`.notmobile`) and mobile (`.mobile`) menu blocks
|
||||
- Guarded by `@if (Model.DisplayCustomerInfoMenuItem)` (login-gated)
|
||||
- Menu item styled in `quick-order-menu.css` (amber, bold) included via `Head.cshtml`
|
||||
- Uses `fa fa-bolt` icon
|
||||
|
||||
Voice input:
|
||||
- Records audio in browser, POSTs to `FruitBankAudioController`
|
||||
- Whisper transcription with Hungarian vocabulary hints (partner names + produce terms)
|
||||
- **Prompt character limit is 224** – use keyword extraction, not full company names
|
||||
- Fallback: manual text search input
|
||||
|
||||
---
|
||||
|
||||
## 12. Split Order Feature
|
||||
|
||||
Admin page on order detail. Two modes selectable via radio buttons:
|
||||
|
||||
| Mode | Behaviour |
|
||||
|---|---|
|
||||
| **Audit-based** | Available only when order has both "started" and "non-started" audit items. Audited items stay; non-audited items move to new order. |
|
||||
| **Manual selection** | Always available (except for fully audited orders). Checkbox per item; user chooses what moves. |
|
||||
|
||||
Split button is always enabled (except audited orders). Mode availability is communicated visually if a mode is disabled.
|
||||
|
||||
**Critical lesson:** `TransactionSafeAsync` caused deadlocks because `TaskHelper.ToThreadPoolTask` creates async/await context switching in ASP.NET. The transaction wrapper was removed. Avoid wrapping split logic in `TransactionSafeAsync`.
|
||||
|
||||
After split: inventory adjustments, order notes written, SignalR notification sent to admin clients.
|
||||
|
||||
---
|
||||
|
||||
## 13. AI Admin Dashboard (`GetWelcomeMessageAsync`)
|
||||
|
||||
Located in `CustomDashboardController`. Generates a structured OpenAI prompt containing:
|
||||
- Store data summary
|
||||
- Today's order totals
|
||||
- Stock discrepancy summary (from stock taking audit)
|
||||
- OpenWeatherMap weather data (real API key configured in settings)
|
||||
|
||||
Patterns used:
|
||||
- Typed C# records for data transfer
|
||||
- `Task.WhenAll` for parallel DB calls
|
||||
- Batched product history queries (no N+1)
|
||||
- Bilingual (Hungarian/English) system prompt with JSON field guide for the AI
|
||||
- `salesAdjustmentSum` – be careful not to double-count
|
||||
|
||||
---
|
||||
|
||||
## 14. Shipping Document Processing
|
||||
|
||||
AI-driven workflow for extracting partner + product data from uploaded PDFs/images:
|
||||
|
||||
1. File uploaded → SHA256 hash calculated **before AI call**
|
||||
2. Hash checked against DB → if duplicate, load existing data (skip AI, save API cost)
|
||||
3. PDF converted to image(s) via `PdfToImageService` if needed
|
||||
4. OpenAI vision API extracts structured product/partner data
|
||||
5. Multi-stage matching: string search → historical shipping data → AI semantic match
|
||||
6. UI shows visual matched/unmatched indicators + autocomplete for manual correction
|
||||
7. `IsMeasurable` is **server-side only** – never expose in frontend forms
|
||||
|
||||
---
|
||||
|
||||
## 15. NopCommerce 4.80 Gotchas
|
||||
|
||||
| Issue | Correct approach |
|
||||
|---|---|
|
||||
| `ICustomerAttributeService` does not exist | Use direct `XDocument.Parse` on the XML stored in `GenericAttribute.Value` |
|
||||
| `ParseAttributeValuesAsync` returns empty for free-text attributes | It's designed for predefined selection attributes (ID lookup). For free-text: parse XML directly: `<Attributes><CustomerAttribute ID="1"><CustomerAttributeValue><Value>...</Value>...` |
|
||||
| `TransactionSafeAsync` + async = deadlock | `TaskHelper.ToThreadPoolTask` inside it causes context switching deadlocks in ASP.NET; remove transaction wrapper for affected code |
|
||||
| Email order table customization | Override `IMessageTokenProvider` with `FruitBankMessageTokenProvider` (already done); base class constructor has many parameters – pass all through exactly |
|
||||
| `OrderPlaced.CustomerNotification` email template | Configured in NopCommerce admin under Content → Message Templates; code hook is `WorkflowMessageService.SendOrderPlacedCustomerNotificationAsync` |
|
||||
|
||||
---
|
||||
|
||||
## 16. Theme (CarHaven)
|
||||
|
||||
Path: `D:\REPOS\MANGO\source\FruitBank\Presentation\Nop.Web\Themes\CarHaven`
|
||||
|
||||
Relevant files modified by this plugin's work:
|
||||
- `TopMenu/Default.cshtml` – quick order nav item added
|
||||
- `Head.cshtml` – includes `quick-order-menu.css`
|
||||
- `css/quick-order-menu.css` – amber nav item styling
|
||||
|
||||
---
|
||||
|
||||
## 17. External APIs / Credentials
|
||||
|
||||
| Service | Usage | Notes |
|
||||
|---|---|---|
|
||||
| OpenAI | Chat completions (gpt-4o-mini / gpt-4o), Whisper transcription | API key in `FruitBankSettings.ApiKey` |
|
||||
| OpenWeatherMap | Weather data for dashboard welcome message | Real API key confirmed in settings |
|
||||
| Replicate | Image/audio AI models | Bearer token hardcoded in `PluginNopStartup` HTTP client registration |
|
||||
| InnVoice | Hungarian accounting/invoicing system | REST API via `InnVoiceApiService` |
|
||||
|
||||
---
|
||||
|
||||
## 18. Conventions & Patterns to Follow
|
||||
|
||||
1. **Always bilingual** – every new locale resource key goes into InstallAsync (EN + HU) and both XML files.
|
||||
2. **Mobile-first** for warehouse tools – large touch targets, step-by-step UX, pulse animations.
|
||||
3. **Server-side business rules** – never expose `IsMeasurable` or similar computed flags in frontend forms.
|
||||
4. **Batch DB calls** – use `Task.WhenAll`, never query inside a loop.
|
||||
5. **Hash before AI** – always deduplicate files by SHA256 hash before calling any AI API.
|
||||
6. **CarHaven design tokens** – use CSS variables (`--theme-color`, `--active-color`, `--dark`, `--light-bg`, `DM Sans` font) consistently.
|
||||
7. **No `TransactionSafeAsync`** on code paths that use async/await through thread pool.
|
||||
8. **Expect corrections** – Adam knows the codebase deeply; treat any correction as authoritative and apply it without re-litigating.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2026-03-18 | Maintained by: Claude (auto-generated from codebase + project chat history)*
|
||||
|
|
@ -26,6 +26,7 @@ public class CustomPriceCalculationService : PriceCalculationService
|
|||
private readonly IProductAttributeService _productAttributeService;
|
||||
private readonly ISpecificationAttributeService _specificationAttributeService;
|
||||
private readonly ILocalizationService _localizationService;
|
||||
private readonly IStoreContext _storeContext;
|
||||
private ILogger _logger;
|
||||
|
||||
public CustomPriceCalculationService(
|
||||
|
|
@ -46,7 +47,8 @@ public class CustomPriceCalculationService : PriceCalculationService
|
|||
ILocalizationService localizationService,
|
||||
IStaticCacheManager cacheManager,
|
||||
IWorkContext workContext,
|
||||
IEnumerable<IAcLogWriterBase> logWriters)
|
||||
IEnumerable<IAcLogWriterBase> logWriters,
|
||||
IStoreContext storeContext)
|
||||
: base(catalogSettings, currencySettings, categoryService, currencyService, customerService, discountService, manufacturerService,
|
||||
productAttributeParser, productService,
|
||||
cacheManager)
|
||||
|
|
@ -58,6 +60,7 @@ public class CustomPriceCalculationService : PriceCalculationService
|
|||
_productAttributeService = productAttributeService;
|
||||
_specificationAttributeService = specificationAttributeService;
|
||||
_localizationService = localizationService;
|
||||
_storeContext = storeContext;
|
||||
}
|
||||
|
||||
public static decimal CalculateOrderItemFinalPrice(bool isMeasurable, decimal unitPrice, int quantity, double netWeight)
|
||||
|
|
@ -135,9 +138,26 @@ public class CustomPriceCalculationService : PriceCalculationService
|
|||
_logger.Info($"order.OrderTotal({order.OrderTotal}) == prevOrderTotal({prevOrderTotal})");
|
||||
|
||||
order.OrderSubtotalInclTax = order.OrderTotal;
|
||||
order.OrderSubtotalExclTax = order.OrderTotal;
|
||||
order.OrderSubTotalDiscountInclTax = order.OrderTotal;
|
||||
order.OrderSubTotalDiscountExclTax = order.OrderTotal;
|
||||
order.OrderSubtotalExclTax = (order.OrderTotal / (decimal)1.27);
|
||||
|
||||
//mivel csak csekkolunk, de nem adunk vissza semmilyen kedvezményt, így a subtotal discount értékek kiszámolááshoz meg kell hívni megint a calculate final price-t
|
||||
decimal orderSubTotalDiscountInclTax = 0;
|
||||
decimal orderSubTotalDiscountExclTax = 0;
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
|
||||
foreach (var orderItem in orderItems)
|
||||
{
|
||||
var orderItemDto = orderItemDtosById[orderItem.Id];
|
||||
var product = await _dbContext.Products.GetByIdAsync(orderItem.ProductId);
|
||||
var customer = await _dbContext.Customers.GetByIdAsync(order.CustomerId);
|
||||
var itemPrice = await GetFinalPriceAsync(product, customer, store, 0, true, orderItemDto.Quantity);
|
||||
orderSubTotalDiscountInclTax += itemPrice.appliedDiscountAmount;
|
||||
orderSubTotalDiscountExclTax += itemPrice.appliedDiscountAmount / (decimal)1.27;
|
||||
}
|
||||
|
||||
|
||||
order.OrderSubTotalDiscountInclTax = orderSubTotalDiscountInclTax;
|
||||
order.OrderSubTotalDiscountExclTax = orderSubTotalDiscountExclTax;
|
||||
|
||||
await _dbContext.Orders.UpdateAsync(order, false);
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
using FruitBank.Common.Entities;
|
||||
using Nop.Core.Domain.Orders;
|
||||
using Nop.Core.Domain.Payments;
|
||||
using Nop.Data;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||
|
||||
public class CustomerCreditService : ICustomerCreditService
|
||||
{
|
||||
private readonly CustomerCreditDbTable _customerCreditDbTable;
|
||||
private readonly IRepository<Order> _orderRepository;
|
||||
|
||||
public CustomerCreditService(
|
||||
CustomerCreditDbTable customerCreditDbTable,
|
||||
IRepository<Order> orderRepository)
|
||||
{
|
||||
_customerCreditDbTable = customerCreditDbTable;
|
||||
_orderRepository = orderRepository;
|
||||
}
|
||||
|
||||
public Task<CustomerCredit?> GetByCustomerIdAsync(int customerId)
|
||||
=> _customerCreditDbTable.GetByCustomerIdAsync(customerId);
|
||||
|
||||
public async Task SaveAsync(CustomerCredit entity)
|
||||
{
|
||||
entity.UpdatedOnUtc = DateTime.UtcNow;
|
||||
|
||||
if (entity.Id <= 0)
|
||||
{
|
||||
entity.CreatedOnUtc = DateTime.UtcNow;
|
||||
await _customerCreditDbTable.InsertAsync(entity);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _customerCreditDbTable.UpdateAsync(entity);
|
||||
}
|
||||
}
|
||||
|
||||
public Task DeleteAsync(CustomerCredit entity)
|
||||
=> _customerCreditDbTable.DeleteAsync(entity);
|
||||
|
||||
public async Task<decimal> GetOutstandingBalanceAsync(int customerId)
|
||||
{
|
||||
return await _orderRepository.Table
|
||||
.Where(o =>
|
||||
o.CustomerId == customerId &&
|
||||
o.OrderStatusId != (int)OrderStatus.Cancelled &&
|
||||
(o.PaymentStatusId == (int)PaymentStatus.Pending ||
|
||||
o.PaymentStatusId == (int)PaymentStatus.PartiallyRefunded))
|
||||
.SumAsync(o => (decimal?)o.OrderTotal) ?? 0m;
|
||||
}
|
||||
|
||||
public async Task<decimal?> GetRemainingCreditAsync(int customerId)
|
||||
{
|
||||
var credit = await GetByCustomerIdAsync(customerId);
|
||||
if (credit == null) return null;
|
||||
|
||||
var outstanding = await GetOutstandingBalanceAsync(customerId);
|
||||
return credit.CreditLimit - outstanding;
|
||||
}
|
||||
|
||||
public async Task<bool> IsOrderAllowedAsync(int customerId, decimal newOrderTotal)
|
||||
{
|
||||
var credit = await GetByCustomerIdAsync(customerId);
|
||||
if (credit == null) return true;
|
||||
|
||||
var outstanding = await GetOutstandingBalanceAsync(customerId);
|
||||
return outstanding + newOrderTotal <= credit.CreditLimit;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
using FruitBank.Common.Interfaces;
|
||||
using FruitBank.Common.Enums;
|
||||
using FruitBank.Common.Interfaces;
|
||||
using FruitBank.Common.Server;
|
||||
using Mango.Nop.Core.Dtos;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
|
@ -8,6 +9,7 @@ using Nop.Core;
|
|||
using Nop.Core.Domain.Catalog;
|
||||
using Nop.Core.Domain.Common;
|
||||
using Nop.Core.Domain.Customers;
|
||||
using Nop.Core.Domain.Messages;
|
||||
using Nop.Core.Domain.Orders;
|
||||
using Nop.Core.Domain.Tax;
|
||||
using Nop.Core.Events;
|
||||
|
|
@ -20,11 +22,13 @@ using Nop.Services.Common;
|
|||
using Nop.Services.Customers;
|
||||
using Nop.Services.Events;
|
||||
using Nop.Services.Localization;
|
||||
using Nop.Services.Messages;
|
||||
using Nop.Services.Orders;
|
||||
using Nop.Services.Plugins;
|
||||
using Nop.Web.Framework.Events;
|
||||
using Nop.Web.Framework.Menu;
|
||||
using Nop.Web.Models.Sitemap;
|
||||
using NUglify.JavaScript.Syntax;
|
||||
using System.Linq;
|
||||
using System.Xml.Linq;
|
||||
|
||||
|
|
@ -47,6 +51,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
|
|||
private readonly FruitBankDbContext _dbContext;
|
||||
private readonly IAttributeParser<CustomerAttribute, CustomerAttributeValue> _attributeParser;
|
||||
private readonly ICustomerService _customerService;
|
||||
private readonly IWorkflowMessageService _workflowMessageService;
|
||||
private readonly FruitBankNotificationService _fruitBankNotificationService;
|
||||
|
||||
public EventConsumer(
|
||||
IGenericAttributeService genericAttributeService,
|
||||
|
|
@ -64,7 +70,9 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
|
|||
FruitBankAttributeService fruitBankAttributeService,
|
||||
FruitBankDbContext dbContext,
|
||||
IAttributeParser<CustomerAttribute, CustomerAttributeValue> attributeParser,
|
||||
ICustomerService customerService
|
||||
ICustomerService customerService,
|
||||
IWorkflowMessageService workflowMessageService,
|
||||
FruitBankNotificationService fruitBankNotificationService
|
||||
) : base(pluginManager)
|
||||
{
|
||||
_genericAttributeService = genericAttributeService;
|
||||
|
|
@ -82,6 +90,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
|
|||
_dbContext = dbContext;
|
||||
_attributeParser = attributeParser;
|
||||
_customerService = customerService;
|
||||
_workflowMessageService = workflowMessageService;
|
||||
_fruitBankNotificationService = fruitBankNotificationService;
|
||||
}
|
||||
|
||||
protected override string PluginSystemName => "Misc.FruitBankPlugin";
|
||||
|
|
@ -93,6 +103,43 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
|
|||
public async Task HandleEventAsync(EntityUpdatedEvent<Order> eventMessage)
|
||||
{
|
||||
await SaveOrderCustomAttributesAsync(eventMessage.Entity);
|
||||
if (eventMessage.Entity == null) return;
|
||||
|
||||
var orderDto = await _dbContext.OrderDtos.GetByIdAsync(eventMessage.Entity.Id, true);
|
||||
if (orderDto == null) return;
|
||||
|
||||
if (orderDto.MeasuringStatus == MeasuringStatus.Audited)
|
||||
{
|
||||
var alreadySent = await _fruitBankAttributeService
|
||||
.GetGenericAttributeValueAsync<Order, bool>(eventMessage.Entity.Id, "OrderAuditedNotificationSent");
|
||||
|
||||
if (!alreadySent)
|
||||
{
|
||||
await _fruitBankNotificationService
|
||||
.SendOrderAuditedCustomerNotificationAsync(eventMessage.Entity, orderDto.IsMeasurable);
|
||||
|
||||
await _fruitBankAttributeService
|
||||
.InsertOrUpdateGenericAttributeAsync<Order, bool>(
|
||||
eventMessage.Entity.Id, "OrderAuditedNotificationSent", true);
|
||||
}
|
||||
}
|
||||
else if (orderDto.MeasuringStatus == MeasuringStatus.Started)
|
||||
{
|
||||
var alreadySent = await _fruitBankAttributeService
|
||||
.GetGenericAttributeValueAsync<Order, bool>(eventMessage.Entity.Id, "OrderStartedNotificationSent");
|
||||
|
||||
if (!alreadySent)
|
||||
{
|
||||
await _fruitBankNotificationService
|
||||
.SendOrderStartedCustomerNotificationAsync(eventMessage.Entity, orderDto.IsMeasurable);
|
||||
|
||||
await _fruitBankAttributeService
|
||||
.InsertOrUpdateGenericAttributeAsync<Order, bool>(
|
||||
eventMessage.Entity.Id, "OrderStartedNotificationSent", true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
using Nop.Core;
|
||||
using Nop.Core.Domain.Messages;
|
||||
using Nop.Core.Domain.Orders;
|
||||
using Nop.Services.Customers;
|
||||
using Nop.Services.Messages;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||
|
||||
public class FruitBankNotificationService(
|
||||
IMessageTemplateService messageTemplateService,
|
||||
IEmailAccountService emailAccountService,
|
||||
EmailAccountSettings emailAccountSettings,
|
||||
IMessageTokenProvider messageTokenProvider,
|
||||
IWorkflowMessageService workflowMessageService,
|
||||
ICustomerService customerService,
|
||||
IStoreContext storeContext)
|
||||
{
|
||||
public const string ORDER_AUDITED_TEMPLATE_NAME = "FruitBank.OrderAudited.CustomerNotification";
|
||||
public const string ORDER_STARTED_TEMPLATE_NAME = "FruitBank.OrderStarted.CustomerNotification";
|
||||
|
||||
/// <summary>
|
||||
/// Sends the "order started" (being prepared) customer notification.
|
||||
/// For measurable orders, informs the customer that final prices will be
|
||||
/// confirmed after weighing. Fires once when MeasuringStatus transitions to Started.
|
||||
/// </summary>
|
||||
public async Task<int> SendOrderStartedCustomerNotificationAsync(Order order, bool isMeasurable)
|
||||
{
|
||||
var measurableNote = isMeasurable
|
||||
? "<p>Rendelésed mérhető tételeket tartalmaz. A végleges ár a mérés után kerül megerősítésre.</p>"
|
||||
: string.Empty;
|
||||
|
||||
return await SendNotificationAsync(ORDER_STARTED_TEMPLATE_NAME, order, measurableNote);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends the "order audited" customer notification.
|
||||
/// For measurable orders, confirms that weights have been recorded and
|
||||
/// the final price is as shown on the order.
|
||||
/// Fires once when MeasuringStatus transitions to Audited.
|
||||
/// </summary>
|
||||
public async Task<int> SendOrderAuditedCustomerNotificationAsync(Order order, bool isMeasurable)
|
||||
{
|
||||
var measurableNote = isMeasurable
|
||||
? "<p>A mért tételek súlyait rögzítettük, a végleges ár a rendelésen feltüntetett összeg.</p>"
|
||||
: string.Empty;
|
||||
|
||||
return await SendNotificationAsync(ORDER_AUDITED_TEMPLATE_NAME, order, measurableNote);
|
||||
}
|
||||
|
||||
// ── shared core ─────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<int> SendNotificationAsync(string templateName, Order order, string measurableNote)
|
||||
{
|
||||
var store = await storeContext.GetCurrentStoreAsync();
|
||||
|
||||
var templates = await messageTemplateService.GetMessageTemplatesByNameAsync(templateName, store.Id);
|
||||
var messageTemplate = templates?.FirstOrDefault();
|
||||
|
||||
if (messageTemplate is null || !messageTemplate.IsActive)
|
||||
return 0;
|
||||
|
||||
var emailAccount = await emailAccountService.GetEmailAccountByIdAsync(messageTemplate.EmailAccountId)
|
||||
?? await emailAccountService.GetEmailAccountByIdAsync(emailAccountSettings.DefaultEmailAccountId);
|
||||
|
||||
var tokens = new List<Token>();
|
||||
await messageTokenProvider.AddStoreTokensAsync(tokens, store, emailAccount, order.CustomerLanguageId);
|
||||
await messageTokenProvider.AddOrderTokensAsync(tokens, order, order.CustomerLanguageId);
|
||||
|
||||
var customer = await customerService.GetCustomerByIdAsync(order.CustomerId);
|
||||
await messageTokenProvider.AddCustomerTokensAsync(tokens, customer);
|
||||
|
||||
tokens.Add(new Token("Order.MeasurableNote", measurableNote, true));
|
||||
|
||||
var toEmail = customer.Email;
|
||||
var toName = $"{customer.FirstName} {customer.LastName}".Trim();
|
||||
if (string.IsNullOrWhiteSpace(toName)) toName = customer.Email;
|
||||
|
||||
return await workflowMessageService.SendNotificationAsync(
|
||||
messageTemplate, emailAccount,
|
||||
order.CustomerLanguageId,
|
||||
tokens,
|
||||
toEmail, toName);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
using FruitBank.Common.Entities;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||
|
||||
public interface ICustomerCreditService
|
||||
{
|
||||
/// <summary>Gets the credit record for a customer, or null if none exists (= unlimited).</summary>
|
||||
Task<CustomerCredit?> GetByCustomerIdAsync(int customerId);
|
||||
|
||||
/// <summary>Insert or update a customer credit record.</summary>
|
||||
Task SaveAsync(CustomerCredit entity);
|
||||
|
||||
/// <summary>Delete the credit record for a customer, restoring unlimited access.</summary>
|
||||
Task DeleteAsync(CustomerCredit entity);
|
||||
|
||||
/// <summary>
|
||||
/// Sum of OrderTotal for all pending/unpaid, non-cancelled orders for the customer.
|
||||
/// </summary>
|
||||
Task<decimal> GetOutstandingBalanceAsync(int customerId);
|
||||
|
||||
/// <summary>
|
||||
/// CreditLimit - OutstandingBalance. Returns null if no credit record exists (= unlimited).
|
||||
/// </summary>
|
||||
Task<decimal?> GetRemainingCreditAsync(int customerId);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the customer is allowed to place a new order with the given total.
|
||||
/// Rule: no credit record = always allowed.
|
||||
/// Otherwise: OutstandingBalance + newOrderTotal must be <= CreditLimit.
|
||||
/// </summary>
|
||||
Task<bool> IsOrderAllowedAsync(int customerId, decimal newOrderTotal);
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
@model Nop.Plugin.Misc.FruitBankPlugin.Models.CustomerCreditWidgetModel
|
||||
|
||||
@{
|
||||
var remaining = Model.RemainingCredit;
|
||||
var statusClass = !Model.HasCreditLimit ? "text-muted"
|
||||
: remaining <= 0 ? "text-danger"
|
||||
: remaining < Model.CreditLimit * 0.2m ? "text-warning"
|
||||
: "text-success";
|
||||
}
|
||||
|
||||
<div class="card card-default">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-credit-card"></i>
|
||||
@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle")
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3">
|
||||
<label>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit")</label>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<span class="form-control-plaintext">
|
||||
@(Model.HasCreditLimit ? Model.CreditLimit.ToString("N0") + " Ft" : T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited").Text)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3">
|
||||
<label>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance")</label>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<span class="form-control-plaintext">
|
||||
@Model.OutstandingBalance.ToString("N0") Ft
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3">
|
||||
<label>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit")</label>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<span class="form-control-plaintext @statusClass">
|
||||
<strong>
|
||||
@if (!Model.HasCreditLimit)
|
||||
{
|
||||
@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited")
|
||||
}
|
||||
else
|
||||
{
|
||||
@(remaining!.Value.ToString("N0"))
|
||||
<span>Ft</span>
|
||||
}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Comment))
|
||||
{
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3">
|
||||
<label>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment")</label>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<span class="form-control-plaintext text-muted">@Model.Comment</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-md-9 offset-md-3">
|
||||
<a href="/Admin/CustomerCredit/Details/@Model.CustomerId" class="btn btn-default">
|
||||
<i class="fas fa-edit"></i>
|
||||
@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.EditTitle")
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,566 @@
|
|||
@{
|
||||
Layout = "_Root";
|
||||
ViewBag.Title = T("Plugins.Misc.FruitBankPlugin.QuickOrder.PageTitle").Text;
|
||||
}
|
||||
|
||||
<link rel="stylesheet" href="~/Plugins/Misc.FruitBankPlugin/css/quick-order.css" />
|
||||
|
||||
<div class="quick-order-page">
|
||||
|
||||
<!-- Full-width Search Bar -->
|
||||
<div class="qo-search-bar-wrapper">
|
||||
<div class="qo-search-bar">
|
||||
<div class="search-input-group">
|
||||
<button id="recordBtn" class="mic-btn" title="@T("Plugins.Misc.FruitBankPlugin.QuickOrder.MicButtonTitle")">
|
||||
<i class="fa fa-microphone"></i>
|
||||
</button>
|
||||
<button id="stopBtn" class="mic-btn mic-btn-recording" style="display:none;" title="@T("Plugins.Misc.FruitBankPlugin.QuickOrder.StopButtonTitle")">
|
||||
<i class="fa fa-stop"></i>
|
||||
<span class="mic-pulse"></span>
|
||||
</button>
|
||||
<input type="text"
|
||||
id="searchInput"
|
||||
class="qo-input"
|
||||
placeholder="@T("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchPlaceholder")"
|
||||
onkeypress="if(event.key==='Enter') submitTextSearch()">
|
||||
<button class="qo-search-btn" onclick="submitTextSearch()">
|
||||
<i class="fa fa-search"></i> @T("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchButton")
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
@Html.AntiForgeryToken()
|
||||
|
||||
@* JS string bundle — Razor renders these once so JS never contains raw Hungarian *@
|
||||
<script asp-location="Footer">
|
||||
var qoStr = {
|
||||
allProducts: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.AllProductsLabel").Text))',
|
||||
searchResults: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchResultsLabel").Text))',
|
||||
searchPlaceholder: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchPlaceholder").Text))',
|
||||
activeRecordingPlaceholder: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.ActiveRecordingPlaceholder").Text))',
|
||||
listeningStatus: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.ListeningStatus").Text))',
|
||||
browserNotSupported: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.BrowserNotSupported").Text))',
|
||||
micAccessError: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.MicAccessError").Text))',
|
||||
micPermissionDenied: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.MicPermissionDenied").Text))',
|
||||
micNotFound: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.MicNotFound").Text))',
|
||||
calibrating: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.Calibrating").Text))',
|
||||
processing: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.Processing").Text))',
|
||||
recordingFailed: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.RecordingFailed").Text))',
|
||||
volumeHigh: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeHigh").Text))',
|
||||
volumeSpeaking: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeSpeaking").Text))',
|
||||
volumeLouder: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeLouder").Text))',
|
||||
searching: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.Searching").Text))',
|
||||
enterProducts: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.EnterProducts").Text))',
|
||||
searchError: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchError").Text))',
|
||||
audioError: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.AudioError").Text))',
|
||||
addToCartError: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.AddToCartError").Text))',
|
||||
errorPrefix: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.ErrorPrefix").Text))',
|
||||
measurableBadge: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.MeasurableBadge").Text))',
|
||||
stockLabel: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLabel").Text))',
|
||||
stockLimitedPrefix: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLimitedPrefix").Text))',
|
||||
stockLimitedSuffix: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLimitedSuffix").Text))',
|
||||
pieceUnit: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.PieceUnit").Text))',
|
||||
pricePerPiece: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.PricePerPiece").Text))',
|
||||
addToCartTitle: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.AddToCartTitle").Text))',
|
||||
addedToCart: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.AddedToCart").Text))'
|
||||
};
|
||||
</script>
|
||||
|
||||
<script asp-location="Footer">
|
||||
var mediaRecorder = null;
|
||||
var audioChunks = [];
|
||||
var isRecording = false;
|
||||
var audioContext = null;
|
||||
var analyser = null;
|
||||
var volumeCheckInterval = null;
|
||||
var recordingStartTime = null;
|
||||
var baselineNoiseLevel = -60;
|
||||
var volumeHistory = [];
|
||||
|
||||
var VAD_CONFIG = {
|
||||
silenceDuration: 1500,
|
||||
minRecordingTime: 800,
|
||||
volumeCheckInterval: 100,
|
||||
calibrationTime: 500,
|
||||
noiseGateOffset: 15,
|
||||
volumeHistorySize: 10
|
||||
};
|
||||
|
||||
$(document).ready(function () {
|
||||
$('#recordBtn').click(startRecording);
|
||||
$('#stopBtn').click(function () { stopRecording(false); });
|
||||
loadCart();
|
||||
loadAllProducts();
|
||||
});
|
||||
|
||||
// ── Product list ──────────────────────────────────────────────────────────
|
||||
|
||||
function loadAllProducts() {
|
||||
$('#transcribedCard').hide();
|
||||
$('#noResultsCard').hide();
|
||||
$('#productMatchesCard').hide();
|
||||
$('#productsLoadingState').show();
|
||||
$('#matchesLabelText').text(qoStr.allProducts);
|
||||
|
||||
$.ajax({
|
||||
url: '@Url.Action("GetAllProducts", "QuickOrder")',
|
||||
type: 'GET',
|
||||
success: function (result) {
|
||||
$('#productsLoadingState').hide();
|
||||
if (result.success && result.products && result.products.length > 0) {
|
||||
displayProductMatches(result.products);
|
||||
} else {
|
||||
$('#noResultsCard').show();
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
$('#productsLoadingState').hide();
|
||||
$('#noResultsCard').show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Voice recording ───────────────────────────────────────────────────────
|
||||
|
||||
function getSupportedMimeType() {
|
||||
var types = ['audio/webm', 'audio/webm;codecs=opus', 'audio/ogg;codecs=opus', 'audio/mp4'];
|
||||
for (var i = 0; i < types.length; i++) {
|
||||
if (MediaRecorder.isTypeSupported(types[i])) return types[i];
|
||||
}
|
||||
return 'audio/webm';
|
||||
}
|
||||
|
||||
function startRecording() {
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
alert(qoStr.browserNotSupported);
|
||||
return;
|
||||
}
|
||||
navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
.then(function (stream) {
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
analyser = audioContext.createAnalyser();
|
||||
audioContext.createMediaStreamSource(stream).connect(analyser);
|
||||
analyser.fftSize = 512;
|
||||
|
||||
var mimeType = getSupportedMimeType();
|
||||
mediaRecorder = new MediaRecorder(stream, { mimeType: mimeType });
|
||||
audioChunks = [];
|
||||
recordingStartTime = Date.now();
|
||||
isRecording = true;
|
||||
|
||||
mediaRecorder.addEventListener('dataavailable', function (e) { audioChunks.push(e.data); });
|
||||
mediaRecorder.addEventListener('stop', function () {
|
||||
var blob = new Blob(audioChunks, { type: mimeType });
|
||||
stream.getTracks().forEach(function (t) { t.stop(); });
|
||||
if (audioContext) { audioContext.close(); audioContext = null; }
|
||||
analyser = null;
|
||||
isRecording = false;
|
||||
if (blob.size === 0) {
|
||||
alert(qoStr.recordingFailed);
|
||||
resetRecordingUI();
|
||||
return;
|
||||
}
|
||||
processAudio(blob, mimeType);
|
||||
});
|
||||
|
||||
mediaRecorder.start();
|
||||
$('#recordBtn').hide();
|
||||
$('#stopBtn').show();
|
||||
$('#searchInput').attr('placeholder', qoStr.activeRecordingPlaceholder);
|
||||
showStatus(qoStr.activeRecordingPlaceholder);
|
||||
startVAD();
|
||||
})
|
||||
.catch(function (err) {
|
||||
var msg = qoStr.micAccessError;
|
||||
if (err.name === 'NotAllowedError') msg += qoStr.micPermissionDenied;
|
||||
else if (err.name === 'NotFoundError') msg += qoStr.micNotFound;
|
||||
else msg += err.message;
|
||||
alert(msg);
|
||||
});
|
||||
}
|
||||
|
||||
function startVAD() {
|
||||
var bufferLength = analyser.frequencyBinCount;
|
||||
var dataArray = new Uint8Array(bufferLength);
|
||||
if (volumeCheckInterval) clearInterval(volumeCheckInterval);
|
||||
|
||||
var silentChecks = 0;
|
||||
var silentNeeded = Math.ceil(VAD_CONFIG.silenceDuration / VAD_CONFIG.volumeCheckInterval);
|
||||
var calibrated = false;
|
||||
var calibSamples = [];
|
||||
volumeHistory = [];
|
||||
|
||||
volumeCheckInterval = setInterval(function () {
|
||||
if (!isRecording || !analyser) { clearInterval(volumeCheckInterval); return; }
|
||||
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
var sum = 0;
|
||||
for (var i = 0; i < bufferLength; i++) sum += dataArray[i];
|
||||
var avg = sum / bufferLength;
|
||||
var volume = 20 * Math.log10(avg / 255);
|
||||
var elapsed = Date.now() - recordingStartTime;
|
||||
|
||||
if (!calibrated && elapsed < VAD_CONFIG.calibrationTime) {
|
||||
calibSamples.push(volume);
|
||||
updateVolumeBar(volume, false, qoStr.calibrating);
|
||||
return;
|
||||
}
|
||||
if (!calibrated && calibSamples.length > 0) {
|
||||
var total = 0;
|
||||
for (var j = 0; j < calibSamples.length; j++) total += calibSamples[j];
|
||||
baselineNoiseLevel = total / calibSamples.length;
|
||||
calibrated = true;
|
||||
}
|
||||
|
||||
volumeHistory.push(volume);
|
||||
if (volumeHistory.length > VAD_CONFIG.volumeHistorySize) volumeHistory.shift();
|
||||
var volSum = 0;
|
||||
for (var k = 0; k < volumeHistory.length; k++) volSum += volumeHistory[k];
|
||||
var avgVol = volSum / volumeHistory.length;
|
||||
var threshold = baselineNoiseLevel + VAD_CONFIG.noiseGateOffset;
|
||||
|
||||
updateVolumeBar(volume, true, null);
|
||||
if (elapsed < VAD_CONFIG.minRecordingTime) return;
|
||||
|
||||
if (avgVol < threshold) {
|
||||
silentChecks++;
|
||||
if (silentChecks >= silentNeeded) { clearInterval(volumeCheckInterval); stopRecording(true); }
|
||||
} else {
|
||||
silentChecks = 0;
|
||||
}
|
||||
}, VAD_CONFIG.volumeCheckInterval);
|
||||
}
|
||||
|
||||
function updateVolumeBar(volume, active, customMsg) {
|
||||
var threshold = baselineNoiseLevel + VAD_CONFIG.noiseGateOffset;
|
||||
var norm = Math.max(0, Math.min(100, ((volume - threshold + 10) / 40) * 100));
|
||||
var text = customMsg || qoStr.listeningStatus;
|
||||
var cls = 'volume-bar-silent';
|
||||
if (active && !customMsg) {
|
||||
if (norm > 60) { text = qoStr.volumeHigh; cls = 'volume-bar-high'; }
|
||||
else if (norm > 30) { text = qoStr.volumeSpeaking; cls = 'volume-bar-medium'; }
|
||||
else if (norm > 10) { text = qoStr.volumeLouder; cls = 'volume-bar-low'; }
|
||||
else { text = qoStr.listeningStatus; }
|
||||
}
|
||||
$('#statusText').text(text);
|
||||
$('#recordingStatus .volume-bar')
|
||||
.removeClass('volume-bar-low volume-bar-medium volume-bar-high volume-bar-silent')
|
||||
.addClass(cls).css('width', norm + '%');
|
||||
}
|
||||
|
||||
function stopRecording(auto) {
|
||||
if (volumeCheckInterval) { clearInterval(volumeCheckInterval); volumeCheckInterval = null; }
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
showStatus(qoStr.processing);
|
||||
mediaRecorder.stop();
|
||||
}
|
||||
}
|
||||
|
||||
function processAudio(blob, mimeType) {
|
||||
var formData = new FormData();
|
||||
formData.append('audioFile', blob, 'recording.webm');
|
||||
formData.append('__RequestVerificationToken', $('input[name="__RequestVerificationToken"]').val());
|
||||
$.ajax({
|
||||
url: '@Url.Action("TranscribeAndSearch", "QuickOrder")',
|
||||
type: 'POST',
|
||||
data: formData,
|
||||
processData: false,
|
||||
contentType: false,
|
||||
success: function (result) { resetRecordingUI(); handleSearchResult(result); },
|
||||
error: function (err) { resetRecordingUI(); alert(qoStr.audioError); console.error(err); }
|
||||
});
|
||||
}
|
||||
|
||||
function resetRecordingUI() {
|
||||
$('#recordingStatus').hide();
|
||||
$('#recordBtn').show();
|
||||
$('#stopBtn').hide();
|
||||
$('#searchInput').attr('placeholder', qoStr.searchPlaceholder);
|
||||
}
|
||||
|
||||
function showStatus(msg) {
|
||||
$('#statusText').text(msg);
|
||||
$('#recordingStatus').show();
|
||||
}
|
||||
|
||||
// ── Search ────────────────────────────────────────────────────────────────
|
||||
|
||||
function submitTextSearch() {
|
||||
var text = $('#searchInput').val().trim();
|
||||
if (!text) { alert(qoStr.enterProducts); return; }
|
||||
showStatus(qoStr.searching);
|
||||
$('#recordingStatus').show();
|
||||
$.ajax({
|
||||
url: '@Url.Action("SearchProducts", "QuickOrder")',
|
||||
type: 'POST',
|
||||
data: { text: text, __RequestVerificationToken: $('input[name="__RequestVerificationToken"]').val() },
|
||||
success: function (result) { $('#recordingStatus').hide(); handleSearchResult(result); },
|
||||
error: function () { $('#recordingStatus').hide(); alert(qoStr.searchError); }
|
||||
});
|
||||
}
|
||||
|
||||
function handleSearchResult(result) {
|
||||
$('#noResultsCard').hide();
|
||||
$('#productMatchesCard').hide();
|
||||
$('#transcribedCard').hide();
|
||||
$('#productsLoadingState').hide();
|
||||
|
||||
if (!result.success) { alert(qoStr.errorPrefix + result.message); return; }
|
||||
if (result.transcription) { $('#transcribedText').text(result.transcription); $('#transcribedCard').show(); }
|
||||
if (!result.products || result.products.length === 0) { $('#noResultsCard').show(); return; }
|
||||
|
||||
$('#matchesLabelText').text(qoStr.searchResults);
|
||||
displayProductMatches(result.products);
|
||||
}
|
||||
|
||||
// ── Product cards ─────────────────────────────────────────────────────────
|
||||
|
||||
function displayProductMatches(products) {
|
||||
var container = $('#productButtons').empty();
|
||||
var grouped = {};
|
||||
for (var i = 0; i < products.length; i++) {
|
||||
var key = products[i].searchTerm || '';
|
||||
if (!grouped[key]) grouped[key] = [];
|
||||
grouped[key].push(products[i]);
|
||||
}
|
||||
var keys = Object.keys(grouped);
|
||||
var multiGroup = keys.length > 1 || (keys.length === 1 && keys[0] !== '');
|
||||
|
||||
for (var g = 0; g < keys.length; g++) {
|
||||
var term = keys[g];
|
||||
if (multiGroup && term) container.append('<div class="group-label"><i class="fa fa-tag"></i> ' + term + '</div>');
|
||||
var group = grouped[term];
|
||||
for (var p = 0; p < group.length; p++) {
|
||||
(function (product) {
|
||||
var isMeasurable = product.isMeasurable;
|
||||
var isReduced = product.isQuantityReduced;
|
||||
var maxQty = product.stockQuantity;
|
||||
var defaultQty = product.quantity;
|
||||
|
||||
var priceHtml = isMeasurable
|
||||
? '<span class="measurable-badge"><i class="fa fa-balance-scale"></i> ' + qoStr.measurableBadge + '</span>'
|
||||
: '<span class="pm-price">' + formatFt(product.unitPrice) + ' ' + qoStr.pricePerPiece + '</span>';
|
||||
|
||||
var warningHtml = isReduced
|
||||
? '<div class="stock-warning-badge"><i class="fa fa-exclamation-triangle"></i> ' + qoStr.stockLimitedPrefix + ' ' + maxQty + ' ' + qoStr.stockLimitedSuffix + '</div>'
|
||||
: '';
|
||||
|
||||
var card = $('<div>').addClass('product-card' + (isReduced ? ' has-warning' : ''));
|
||||
card.html(
|
||||
'<div class="pc-body">' +
|
||||
'<div class="pc-name"><i class="fa fa-cube"></i> ' + product.name + '</div>' +
|
||||
warningHtml +
|
||||
'<div class="pc-meta">' +
|
||||
'<span class="pc-stock' + (maxQty < 50 ? ' stock-low' : '') + '">' + qoStr.stockLabel + ' ' + maxQty + ' ' + qoStr.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" value="' + defaultQty + '" min="1" max="' + maxQty + '">' +
|
||||
'<button type="button" class="qty-btn qty-plus" tabindex="-1"><i class="fa fa-plus"></i></button>' +
|
||||
'</div>' +
|
||||
'<button type="button" class="pc-add-btn" title="' + qoStr.addToCartTitle + '">' +
|
||||
'<i class="fa fa-cart-arrow-down"></i>' +
|
||||
'</button>' +
|
||||
'</div>'
|
||||
);
|
||||
|
||||
card.find('.qty-minus').click(function () {
|
||||
var inp = $(this).siblings('.qty-input');
|
||||
var val = parseInt(inp.val()) || 1;
|
||||
if (val > 1) inp.val(val - 1);
|
||||
});
|
||||
card.find('.qty-plus').click(function () {
|
||||
var inp = $(this).siblings('.qty-input');
|
||||
var val = parseInt(inp.val()) || 1;
|
||||
if (val < maxQty) inp.val(val + 1);
|
||||
});
|
||||
card.find('.qty-input').on('change blur', function () {
|
||||
var val = parseInt($(this).val()) || 1;
|
||||
val = Math.max(1, Math.min(maxQty, val));
|
||||
$(this).val(val);
|
||||
});
|
||||
card.find('.pc-add-btn').click(function () {
|
||||
var qty = parseInt(card.find('.qty-input').val()) || 1;
|
||||
addToCart(product.id, qty, product.name, $(this));
|
||||
});
|
||||
|
||||
container.append(card);
|
||||
})(group[p]);
|
||||
}
|
||||
}
|
||||
$('#productMatchesCard').show();
|
||||
}
|
||||
|
||||
// ── Cart ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function addToCart(productId, quantity, name, btnEl) {
|
||||
btnEl.prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i>');
|
||||
$.ajax({
|
||||
url: '@Url.Action("AddToCart", "QuickOrder")',
|
||||
type: 'POST',
|
||||
data: { productId: productId, quantity: quantity, __RequestVerificationToken: $('input[name="__RequestVerificationToken"]').val() },
|
||||
success: function (result) {
|
||||
if (result.success) {
|
||||
btnEl.html('<i class="fa fa-check"></i>').addClass('added');
|
||||
renderCart(result.cartItems);
|
||||
showCartToast(name, quantity);
|
||||
setTimeout(function () {
|
||||
$('#searchInput').val('');
|
||||
$('#transcribedCard').hide();
|
||||
loadAllProducts();
|
||||
}, 700);
|
||||
} else {
|
||||
alert(qoStr.errorPrefix + result.message);
|
||||
btnEl.prop('disabled', false).html('<i class="fa fa-cart-arrow-down"></i>');
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
alert(qoStr.addToCartError);
|
||||
btnEl.prop('disabled', false).html('<i class="fa fa-cart-arrow-down"></i>');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadCart() {
|
||||
$.ajax({
|
||||
url: '@Url.Action("GetCartItems", "QuickOrder")',
|
||||
type: 'GET',
|
||||
success: function (result) { if (result.success) renderCart(result.cartItems); }
|
||||
});
|
||||
}
|
||||
|
||||
function renderCart(items) {
|
||||
var list = $('#cartItemsList').empty();
|
||||
var count = items.length;
|
||||
$('#cartItemCount').text(count);
|
||||
if (count === 0) {
|
||||
$('#cartEmptyState').show();
|
||||
$('#cartItemsList, #cartTotalRow, #cartActions').hide();
|
||||
return;
|
||||
}
|
||||
$('#cartEmptyState').hide();
|
||||
$('#cartItemsList, #cartTotalRow, #cartActions').show();
|
||||
|
||||
var estimatedTotal = 0;
|
||||
var hasMeasurable = false;
|
||||
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var item = items[i];
|
||||
if (item.isMeasurable) hasMeasurable = true;
|
||||
var lineTotal = item.isMeasurable ? null : (item.unitPrice * item.quantity);
|
||||
if (lineTotal) estimatedTotal += lineTotal;
|
||||
|
||||
var lineTotalHtml = item.isMeasurable
|
||||
? '<span class="measurable-badge-sm"><i class="fa fa-balance-scale"></i></span>'
|
||||
: '<strong class="line-total">' + formatFt(lineTotal) + ' Ft</strong>';
|
||||
var priceHtml = item.isMeasurable ? '' : '<span class="ci-price">' + formatFt(item.unitPrice) + ' ' + qoStr.pricePerPiece + '</span>';
|
||||
|
||||
list.append(
|
||||
'<div class="cart-item">' +
|
||||
'<div class="ci-name">' + item.name + '</div>' +
|
||||
'<div class="ci-details">' +
|
||||
'<span class="ci-qty">' + item.quantity + ' ' + qoStr.pieceUnit + '</span>' +
|
||||
priceHtml + lineTotalHtml +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
$('#cartTotalAmount').text(formatFt(estimatedTotal) + ' Ft');
|
||||
if (hasMeasurable) $('#cartTotalRow .cart-total-note').show();
|
||||
else $('#cartTotalRow .cart-total-note').hide();
|
||||
}
|
||||
|
||||
function showCartToast(name, qty) {
|
||||
var toast = $('<div class="qo-toast"><i class="fa fa-check-circle"></i> <strong>' + name + '</strong> (' + qty + ' ' + qoStr.pieceUnit + ') ' + qoStr.addedToCart + '</div>');
|
||||
$('body').append(toast);
|
||||
setTimeout(function () { toast.addClass('show'); }, 10);
|
||||
setTimeout(function () { toast.removeClass('show'); setTimeout(function () { toast.remove(); }, 400); }, 2500);
|
||||
}
|
||||
|
||||
function formatFt(val) {
|
||||
if (val === null || val === undefined) return '-';
|
||||
return Math.round(val).toLocaleString('hu-HU');
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,756 @@
|
|||
/*
|
||||
* Quick Order Page — FruitBank / CarHaven Theme
|
||||
* Design tokens inherited from themes/CarHaven/Content/css/styles.css :root
|
||||
* --theme-color : #2d7a3a (forest green)
|
||||
* --active-color: #f4a236 (amber / CTA)
|
||||
* --dark : #1a3c22 (dark green)
|
||||
* --light-bg : #f5f7f2 (off-white green tint)
|
||||
* --text-primary: #2c2c2c
|
||||
* --text-muted : #6b7c6e
|
||||
* --accent-lime : #8cb63c
|
||||
* --warm-bg : #faebd7
|
||||
* font : 'DM Sans', sans-serif
|
||||
* radius : 8px
|
||||
*/
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
PAGE SHELL
|
||||
───────────────────────────────────────────── */
|
||||
.quick-order-page {
|
||||
width: 94%;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 0 60px;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
color: #2c2c2c;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
SEARCH BAR
|
||||
───────────────────────────────────────────── */
|
||||
.qo-search-bar-wrapper {
|
||||
background: #fff;
|
||||
border: 1px solid #dde8da;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(45, 122, 58, 0.08);
|
||||
padding: 18px 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.search-input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* Mic button */
|
||||
.mic-btn {
|
||||
flex-shrink: 0;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border: 2px solid #2d7a3a;
|
||||
background: #fff;
|
||||
color: #2d7a3a;
|
||||
border-radius: 8px 0 0 8px;
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mic-btn:hover {
|
||||
background: #2d7a3a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.mic-btn-recording {
|
||||
background: #1a3c22;
|
||||
color: #f4a236;
|
||||
border-color: #1a3c22;
|
||||
animation: mic-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes mic-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(244,162,54,.5); }
|
||||
50% { box-shadow: 0 0 0 8px rgba(244,162,54,0); }
|
||||
}
|
||||
|
||||
.mic-pulse {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Search text input */
|
||||
.qo-input {
|
||||
flex: 1;
|
||||
height: 46px;
|
||||
border: 2px solid #dde8da;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-radius: 0;
|
||||
padding: 0 16px;
|
||||
font-size: 15px;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
color: #2c2c2c;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.qo-input:focus {
|
||||
border-color: #2d7a3a;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.qo-input::placeholder {
|
||||
color: #6b7c6e;
|
||||
}
|
||||
|
||||
/* Search button */
|
||||
.qo-search-btn {
|
||||
flex-shrink: 0;
|
||||
height: 46px;
|
||||
padding: 0 22px;
|
||||
background: #2d7a3a;
|
||||
color: #fff;
|
||||
border: 2px solid #2d7a3a;
|
||||
border-radius: 0 8px 8px 0;
|
||||
font-size: 14px;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.qo-search-btn:hover {
|
||||
background: #1a3c22;
|
||||
border-color: #1a3c22;
|
||||
}
|
||||
|
||||
/* Recording status bar */
|
||||
.recording-status-bar {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
background: #f5f7f2;
|
||||
border: 1px solid #dde8da;
|
||||
border-radius: 6px;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
#statusText {
|
||||
font-size: 13px;
|
||||
color: #2d7a3a;
|
||||
font-weight: 600;
|
||||
min-width: 130px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.volume-bar-container {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: #dde8da;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.volume-bar {
|
||||
height: 100%;
|
||||
width: 0;
|
||||
border-radius: 3px;
|
||||
transition: width 0.1s ease, background 0.2s;
|
||||
background: #dde8da;
|
||||
}
|
||||
.volume-bar-low { background: #f4a236; }
|
||||
.volume-bar-medium { background: #8cb63c; }
|
||||
.volume-bar-high { background: #2d7a3a; }
|
||||
.volume-bar-silent { background: #dde8da; }
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
TWO-COLUMN LAYOUT
|
||||
───────────────────────────────────────────── */
|
||||
.qo-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 340px;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
PRODUCTS PANEL (LEFT)
|
||||
───────────────────────────────────────────── */
|
||||
|
||||
/* "I heard" transcription card */
|
||||
.result-card {
|
||||
background: #fff;
|
||||
border: 1px solid #dde8da;
|
||||
border-left: 4px solid #2d7a3a;
|
||||
border-radius: 8px;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.result-label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: #2d7a3a;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.result-text {
|
||||
font-size: 15px;
|
||||
color: #2c2c2c;
|
||||
}
|
||||
|
||||
/* No results / empty */
|
||||
.no-results-card {
|
||||
background: #fff;
|
||||
border: 1px dashed #dde8da;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: #6b7c6e;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.no-results-card .fa {
|
||||
font-size: 28px;
|
||||
color: #dde8da;
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.products-empty-state {
|
||||
background: #fff;
|
||||
border: 1px solid #dde8da;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
padding: 48px 20px;
|
||||
color: #6b7c6e;
|
||||
}
|
||||
|
||||
.products-empty-state .fa {
|
||||
font-size: 28px;
|
||||
color: #2d7a3a;
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Section header above product list */
|
||||
.matches-label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #6b7c6e;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.matches-label .fa {
|
||||
color: #2d7a3a;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Group label (search results grouped by keyword) */
|
||||
.group-label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #f4a236;
|
||||
border-bottom: 1px solid #f5f7f2;
|
||||
padding: 4px 0 8px;
|
||||
margin: 12px 0 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
PRODUCT LIST — full-width rows
|
||||
───────────────────────────────────────────── */
|
||||
.product-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
background: #fff;
|
||||
border: 1px solid #dde8da;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
transition: box-shadow 0.18s, border-color 0.18s;
|
||||
}
|
||||
|
||||
.product-card:hover {
|
||||
box-shadow: 0 3px 12px rgba(45, 122, 58, 0.10);
|
||||
border-color: #2d7a3a;
|
||||
}
|
||||
|
||||
.product-card.has-warning {
|
||||
border-left: 3px solid #f4a236;
|
||||
}
|
||||
|
||||
/* Body — grows, holds name + meta inline */
|
||||
.pc-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 14px;
|
||||
}
|
||||
|
||||
/* Product name */
|
||||
.pc-name {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #1a3c22;
|
||||
line-height: 1.3;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
flex: 1 1 200px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pc-name .fa {
|
||||
color: #8cb63c;
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Meta row — stock + price inline */
|
||||
.pc-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pc-stock {
|
||||
font-size: 12px;
|
||||
color: #6b7c6e;
|
||||
background: #f5f7f2;
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pc-stock.stock-low {
|
||||
background: #fff8ee;
|
||||
color: #e8734a;
|
||||
}
|
||||
|
||||
.pm-price {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #2d7a3a;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.stock-warning-badge {
|
||||
font-size: 11px;
|
||||
color: #e8734a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.measurable-badge {
|
||||
font-size: 11px;
|
||||
background: #faebd7;
|
||||
color: #e8734a;
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Actions — fixed width, right-aligned */
|
||||
.pc-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Qty stepper */
|
||||
.qty-stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid #dde8da;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.qty-btn {
|
||||
width: 34px;
|
||||
height: 36px;
|
||||
background: #f5f7f2;
|
||||
border: none;
|
||||
color: #2d7a3a;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.qty-btn:hover {
|
||||
background: #dde8da;
|
||||
}
|
||||
|
||||
.qty-input {
|
||||
width: 48px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-left: 1px solid #dde8da;
|
||||
border-right: 1px solid #dde8da;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #1a3c22;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.qty-input::-webkit-outer-spin-button,
|
||||
.qty-input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/* Add to cart button */
|
||||
.pc-add-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #2d7a3a;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.18s, transform 0.12s;
|
||||
}
|
||||
|
||||
.pc-add-btn:hover {
|
||||
background: #1a3c22;
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.pc-add-btn:disabled {
|
||||
background: #dde8da;
|
||||
cursor: default;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.pc-add-btn.added {
|
||||
background: #8cb63c;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
CART PANEL (RIGHT)
|
||||
───────────────────────────────────────────── */
|
||||
.qo-cart-panel {
|
||||
background: #fff;
|
||||
border: 1px solid #dde8da;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(45, 122, 58, 0.06);
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.qo-section-title {
|
||||
background: #1a3c22;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
padding: 14px 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.qo-section-title .fa {
|
||||
color: #f4a236;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.cart-count-badge {
|
||||
background: #f4a236;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
border-radius: 12px;
|
||||
padding: 1px 7px;
|
||||
margin-left: auto;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cart-empty {
|
||||
padding: 36px 20px;
|
||||
text-align: center;
|
||||
color: #6b7c6e;
|
||||
}
|
||||
|
||||
.cart-empty .fa {
|
||||
font-size: 30px;
|
||||
color: #dde8da;
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.cart-empty p {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cart-items-list {
|
||||
padding: 4px 0;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.cart-item {
|
||||
padding: 11px 18px;
|
||||
border-bottom: 1px solid #f5f7f2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.cart-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ci-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1a3c22;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.ci-details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ci-qty {
|
||||
font-size: 12px;
|
||||
background: #f5f7f2;
|
||||
color: #2d7a3a;
|
||||
font-weight: 700;
|
||||
border-radius: 4px;
|
||||
padding: 1px 7px;
|
||||
}
|
||||
|
||||
.ci-price {
|
||||
font-size: 12px;
|
||||
color: #6b7c6e;
|
||||
}
|
||||
|
||||
.line-total {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #2d7a3a;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.measurable-badge-sm {
|
||||
font-size: 12px;
|
||||
color: #e8734a;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.cart-total-row {
|
||||
border-top: 1px solid #dde8da;
|
||||
padding: 14px 18px;
|
||||
background: #f5f7f2;
|
||||
}
|
||||
|
||||
.cart-total-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: #6b7c6e;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.cart-total-note .fa {
|
||||
color: #f4a236;
|
||||
margin-top: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cart-total {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
color: #2c2c2c;
|
||||
}
|
||||
|
||||
.cart-total strong {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: #1a3c22;
|
||||
}
|
||||
|
||||
#cartActions {
|
||||
padding: 14px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border-top: 1px solid #dde8da;
|
||||
}
|
||||
|
||||
.btn-checkout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: #2d7a3a;
|
||||
color: #fff !important;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
transition: background 0.18s;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.btn-checkout:hover {
|
||||
background: #1a3c22;
|
||||
}
|
||||
|
||||
.btn-checkout .fa {
|
||||
font-size: 16px;
|
||||
color: #f4a236;
|
||||
}
|
||||
|
||||
.btn-view-cart {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 10px;
|
||||
background: #f5f7f2;
|
||||
color: #2d7a3a !important;
|
||||
border: 1px solid #dde8da;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
transition: background 0.18s;
|
||||
}
|
||||
|
||||
.btn-view-cart:hover {
|
||||
background: #dde8da;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
TOAST NOTIFICATION
|
||||
───────────────────────────────────────────── */
|
||||
.qo-toast {
|
||||
position: fixed;
|
||||
bottom: 28px;
|
||||
right: 24px;
|
||||
background: #1a3c22;
|
||||
color: #fff;
|
||||
padding: 13px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
box-shadow: 0 4px 20px rgba(26, 60, 34, 0.3);
|
||||
z-index: 9999;
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
transition: opacity 0.28s, transform 0.28s;
|
||||
max-width: 320px;
|
||||
border-left: 4px solid #f4a236;
|
||||
}
|
||||
|
||||
.qo-toast.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.qo-toast .fa {
|
||||
color: #8cb63c;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
RESPONSIVE
|
||||
───────────────────────────────────────────── */
|
||||
@media (max-width: 960px) {
|
||||
.qo-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.qo-cart-panel {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.quick-order-page {
|
||||
width: 100%;
|
||||
padding: 12px 12px 40px;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pc-body {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.pc-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.qo-search-btn {
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue