This commit is contained in:
Loretta 2026-05-26 16:41:38 +02:00
commit d0c300f2b1
83 changed files with 15382 additions and 923 deletions

View File

@ -0,0 +1,428 @@
@* FruitBank operational dashboard — loaded via AJAX, never blocks page render *@
<div id="fb-dashboard-loading" class="text-center py-3">
<i class="fas fa-spinner fa-spin fa-lg text-muted"></i>
<span class="text-muted ml-2">Operatív adatok betöltése...</span>
</div>
<div id="fb-dashboard-content" style="display:none;">
<div class="row" id="fb-pipeline-stats">
<div class="col-6 col-md col-sm-4">
<div class="info-box mb-3">
<span class="info-box-icon bg-info elevation-1"><i class="fas fa-shopping-basket"></i></span>
<div class="info-box-content">
<span class="info-box-text">Mai rendelés</span>
<span class="info-box-number" id="fb-stat-total"></span>
</div>
</div>
</div>
<div class="col-6 col-md col-sm-4">
<div class="info-box mb-3">
<span class="info-box-icon bg-warning elevation-1"><i class="fas fa-weight-hanging"></i></span>
<div class="info-box-content">
<span class="info-box-text">Mérés alatt</span>
<span class="info-box-number" id="fb-stat-measuring"></span>
</div>
</div>
</div>
<div class="col-6 col-md col-sm-4">
<div class="info-box mb-3">
<span class="info-box-icon bg-success elevation-1"><i class="fas fa-check-circle"></i></span>
<div class="info-box-content">
<span class="info-box-text">Auditálva</span>
<span class="info-box-number" id="fb-stat-audited"></span>
</div>
</div>
</div>
<div class="col-6 col-md col-sm-4">
<div class="info-box mb-3">
<span class="info-box-icon bg-danger elevation-1"><i class="fas fa-file-invoice-dollar"></i></span>
<div class="info-box-content">
<span class="info-box-text">InnVoice hiányzik</span>
<span class="info-box-number" id="fb-stat-innvoice"></span>
</div>
</div>
</div>
<div class="col-6 col-md col-sm-4">
<div class="info-box mb-3">
<span class="info-box-icon bg-secondary elevation-1"><i class="fas fa-flag-checkered"></i></span>
<div class="info-box-content">
<span class="info-box-text">Befejezve</span>
<span class="info-box-number" id="fb-stat-completed"></span>
</div>
</div>
</div>
</div>
<div class="card card-primary" id="fb-orders-card">
<div class="card-header">
<h3 class="card-title">
<i class="fas fa-list mr-1"></i>
Mai rendelések
</h3>
<div class="card-tools">
<button type="button" class="btn btn-tool" data-card-widget="collapse">
<i class="fas fa-minus"></i>
</button>
</div>
</div>
<div class="card-body p-0">
<table class="table table-sm table-striped table-hover mb-0" id="fb-orders-table">
<thead class="thead-light">
<tr>
<th>Rendelés</th>
<th>Partner</th>
<th class="text-right">Összeg</th>
<th class="text-center">Mérés</th>
<th class="text-center">InnVoice</th>
<th class="text-center">Státusz</th>
<th>Átvétel</th>
</tr>
</thead>
<tbody id="fb-orders-body">
<tr><td colspan="7" class="text-center text-muted py-3">Betöltés...</td></tr>
</tbody>
</table>
</div>
<div class="card-footer text-muted small" id="fb-orders-footer" style="display:none;">
<a href="/Admin/CustomOrder/List">Összes rendelés &rarr;</a>
</div>
</div>
<div class="card" id="fb-alerts-card" style="display:none;">
<div class="card-header bg-danger">
<h3 class="card-title text-white">
<i class="fas fa-exclamation-triangle mr-1"></i>
Figyelmet igénylő tételek
<span class="badge badge-light ml-2" id="fb-alerts-count">0</span>
</h3>
<div class="card-tools">
<button type="button" class="btn btn-tool text-white" data-card-widget="collapse">
<i class="fas fa-minus"></i>
</button>
</div>
</div>
<div class="card-body p-0">
<table class="table table-sm table-striped mb-0">
<thead class="thead-light">
<tr>
<th>Típus</th>
<th>Partner</th>
<th>Részlet</th>
<th>Link</th>
</tr>
</thead>
<tbody id="fb-alerts-body"></tbody>
</table>
</div>
</div>
<div class="card card-warning collapsed-card" id="fb-credits-card" style="display:none;">
<div class="card-header">
<h3 class="card-title">
<i class="fas fa-credit-card mr-1"></i>
Hitelkeret státusz
<span class="badge badge-secondary ml-2" id="fb-credits-count">0</span>
</h3>
<div class="card-tools">
<button type="button" class="btn btn-tool" data-card-widget="collapse">
<i class="fas fa-plus"></i>
</button>
</div>
</div>
<div class="card-body p-0">
<table class="table table-sm table-striped mb-0">
<thead class="thead-light">
<tr>
<th>Partner</th>
<th class="text-right">Hitelkeret</th>
<th class="text-right">Kint lévő</th>
<th class="text-right">Szabad</th>
<th class="text-center">Státusz</th>
</tr>
</thead>
<tbody id="fb-credits-body"></tbody>
</table>
</div>
</div>
<div class="card card-info collapsed-card" id="fb-preorders-card" style="display:none;">
<div class="card-header">
<h3 class="card-title">
<i class="fas fa-clock mr-1"></i>
Nyitott előrendelések
<span class="badge badge-secondary ml-2" id="fb-preorders-count">0</span>
</h3>
<div class="card-tools">
<button type="button" class="btn btn-tool" data-card-widget="collapse">
<i class="fas fa-plus"></i>
</button>
</div>
</div>
<div class="card-body p-0">
<table class="table table-sm table-striped mb-0">
<thead class="thead-light">
<tr>
<th>Partner</th>
<th>Létrehozva</th>
<th class="text-center">Tételek</th>
<th class="text-center">Teljesített</th>
<th class="text-center">Státusz</th>
<th></th>
</tr>
</thead>
<tbody id="fb-preorders-body"></tbody>
</table>
</div>
<div class="card-footer text-muted small">
<a href="/Admin/Preorders">Összes előrendelés &rarr;</a>
</div>
</div>
<div class="card card-secondary collapsed-card" id="fb-docs-card" style="display:none;">
<div class="card-header">
<h3 class="card-title">
<i class="fas fa-truck-loading mr-1"></i>
Feldolgozatlan szállítmányok
<span class="badge badge-secondary ml-2" id="fb-docs-count">0</span>
</h3>
<div class="card-tools">
<button type="button" class="btn btn-tool" data-card-widget="collapse">
<i class="fas fa-plus"></i>
</button>
</div>
</div>
<div class="card-body p-0">
<table class="table table-sm table-striped mb-0">
<thead class="thead-light">
<tr>
<th>Partner</th>
<th>Szállítás dátuma</th>
<th class="text-center">Tételek</th>
<th class="text-center">Feldolgozva</th>
<th></th>
</tr>
</thead>
<tbody id="fb-docs-body"></tbody>
</table>
</div>
<div class="card-footer text-muted small">
<a href="/Admin/Shipping">Összes szállítmány &rarr;</a>
</div>
</div>
</div>
<script>
(function () {
// ── Helpers ──────────────────────────────────────────────────────────────
function fmt(num) {
return new Intl.NumberFormat('hu-HU', { style: 'currency', currency: 'HUF', maximumFractionDigits: 0 }).format(num);
}
function measuringBadge(status) {
var map = {
'NotStarted': '<span class="badge badge-secondary">Nem kezdett</span>',
'Started': '<span class="badge badge-warning">Mérés alatt</span>',
'Audited': '<span class="badge badge-success">Auditálva</span>'
};
return map[status] || '<span class="badge badge-light">' + status + '</span>';
}
function orderStatusBadge(status) {
var map = {
'Pending': '<span class="badge badge-secondary">Függőben</span>',
'Processing': '<span class="badge badge-info">Folyamatban</span>',
'Complete': '<span class="badge badge-success">Befejezve</span>',
'Cancelled': '<span class="badge badge-danger">Törölve</span>'
};
return map[status] || '<span class="badge badge-light">' + status + '</span>';
}
function creditBadge(status) {
var map = {
'ok': '<span class="badge badge-success">OK</span>',
'warning': '<span class="badge badge-warning">Közel a limithez</span>',
'exceeded': '<span class="badge badge-danger">Túllépve</span>'
};
return map[status] || status;
}
function preorderStatusBadge(status) {
var map = {
'Pending': '<span class="badge badge-warning">Várakozik</span>',
'PartiallyFulfilled': '<span class="badge badge-info">Részben teljesítve</span>',
'Confirmed': '<span class="badge badge-success">Megerősítve</span>',
'Cancelled': '<span class="badge badge-secondary">Törölve</span>'
};
return map[status] || status;
}
function alertTypeBadge(type) {
var map = {
'missing_innvoice': '<span class="badge badge-danger">InnVoice</span>',
'stale_measuring': '<span class="badge badge-warning">Régi mérés</span>',
'credit_exceeded': '<span class="badge badge-danger">Hitelkeret</span>',
'old_preorder': '<span class="badge badge-warning">Előrendelés</span>'
};
return map[type] || '<span class="badge badge-secondary">' + type + '</span>';
}
function alertLink(alert) {
if (alert.type === 'missing_innvoice' || alert.type === 'stale_measuring')
return '<a href="/Admin/Order/Edit/' + alert.orderId + '" class="btn btn-xs btn-outline-primary">Rendelés</a>';
if (alert.type === 'credit_exceeded')
return '<a href="/Admin/CustomerCredit/Details/' + alert.customerId + '" class="btn btn-xs btn-outline-warning">Hitelkeret</a>';
if (alert.type === 'old_preorder')
return '<a href="/Admin/Preorders" class="btn btn-xs btn-outline-info">Előrendelések</a>';
return '';
}
function alertDetail(alert) {
if (alert.type === 'missing_innvoice' || alert.type === 'stale_measuring')
return '#' + (alert.orderNumber || alert.orderId) + (alert.dateOfReceipt ? ' &nbsp;<small class="text-muted">' + alert.dateOfReceipt + '</small>' : '');
if (alert.type === 'credit_exceeded')
return fmt(alert.outstanding) + ' / ' + fmt(alert.creditLimit);
if (alert.type === 'old_preorder')
return alert.createdAt;
return alert.message || '';
}
// ── Main load ────────────────────────────────────────────────────────────
$.ajax({
url: '/Admin/CustomDashboard/GetDashboardData',
type: 'GET',
timeout: 60000,
success: function (data) {
// ── Pipeline stat boxes ──────────────────────────────────────────
var p = data.pipeline;
$('#fb-stat-total').text(p.total);
$('#fb-stat-measuring').text(p.measuring);
$('#fb-stat-audited').text(p.audited);
$('#fb-stat-innvoice').text(p.missingInnVoice);
$('#fb-stat-completed').text(p.completed);
// Highlight the InnVoice box red if there are missing syncs
if (p.missingInnVoice > 0)
$('#fb-stat-innvoice').closest('.info-box').addClass('bg-danger text-white').find('.info-box-text').addClass('text-white');
// ── Today's order rows ───────────────────────────────────────────
var $ob = $('#fb-orders-body').empty();
if (p.rows && p.rows.length > 0) {
p.rows.forEach(function (o) {
var innVoiceIcon = o.hasInnVoice
? '<i class="fas fa-check text-success"></i>'
: '<i class="fas fa-times text-danger"></i>';
$ob.append(
'<tr>' +
'<td><a href="/Admin/Order/Edit/' + o.id + '">#' + (o.orderNumber || o.id) + '</a></td>' +
'<td>' + (o.company || '') + '</td>' +
'<td class="text-right text-nowrap">' + fmt(o.total) + '</td>' +
'<td class="text-center">' + measuringBadge(o.measuringStatus) + '</td>' +
'<td class="text-center">' + innVoiceIcon + '</td>' +
'<td class="text-center">' + orderStatusBadge(o.orderStatus) + '</td>' +
'<td class="text-nowrap small text-muted">' + (o.dateOfReceipt || '') + '</td>' +
'</tr>'
);
});
if (p.total > 20) {
$('#fb-orders-footer').show();
}
} else {
$ob.append('<tr><td colspan="7" class="text-center text-muted py-3">Nincs mai rendelés</td></tr>');
}
// ── Alerts ───────────────────────────────────────────────────────
if (data.alerts && data.alerts.length > 0) {
$('#fb-alerts-count').text(data.alerts.length);
var $ab = $('#fb-alerts-body').empty();
data.alerts.forEach(function (a) {
$ab.append(
'<tr>' +
'<td>' + alertTypeBadge(a.type) + '</td>' +
'<td>' + (a.company || '') + '</td>' +
'<td class="small">' + alertDetail(a) + '</td>' +
'<td>' + alertLink(a) + '</td>' +
'</tr>'
);
});
$('#fb-alerts-card').show();
}
// ── Credit status ────────────────────────────────────────────────
if (data.creditStatus && data.creditStatus.length > 0) {
$('#fb-credits-count').text(data.creditStatus.length);
var $cb = $('#fb-credits-body').empty();
data.creditStatus.forEach(function (c) {
var rowClass = c.status === 'exceeded' ? 'table-danger' : c.status === 'warning' ? 'table-warning' : '';
$cb.append(
'<tr class="' + rowClass + '">' +
'<td><a href="/Admin/CustomerCredit/Details/' + c.customerId + '">' + c.company + '</a></td>' +
'<td class="text-right text-nowrap">' + fmt(c.creditLimit) + '</td>' +
'<td class="text-right text-nowrap">' + fmt(c.outstanding) + '</td>' +
'<td class="text-right text-nowrap">' + fmt(c.remaining) + '</td>' +
'<td class="text-center">' + creditBadge(c.status) + '</td>' +
'</tr>'
);
});
$('#fb-credits-card').show();
}
// ── Pending preorders ────────────────────────────────────────────
if (data.pendingPreorders && data.pendingPreorders.length > 0) {
$('#fb-preorders-count').text(data.pendingPreorders.length);
var $pb = $('#fb-preorders-body').empty();
data.pendingPreorders.forEach(function (p) {
$pb.append(
'<tr>' +
'<td>' + p.company + '</td>' +
'<td class="small text-muted">' + p.createdAt + '</td>' +
'<td class="text-center">' + p.itemCount + '</td>' +
'<td class="text-center">' + p.fulfilledCount + ' / ' + p.itemCount + '</td>' +
'<td class="text-center">' + preorderStatusBadge(p.status) + '</td>' +
'<td><a href="/Admin/Preorders/Details/' + p.id + '" class="btn btn-xs btn-outline-secondary">Részletek</a></td>' +
'</tr>'
);
});
$('#fb-preorders-card').show();
}
// ── Unprocessed shipping docs ────────────────────────────────────
if (data.unprocessedDocs && data.unprocessedDocs.length > 0) {
$('#fb-docs-count').text(data.unprocessedDocs.length);
var $db = $('#fb-docs-body').empty();
data.unprocessedDocs.forEach(function (d) {
var progress = d.totalItems > 0
? d.measuredItems + ' / ' + d.totalItems
: '';
$db.append(
'<tr>' +
'<td>' + (d.partnerName || '') + '</td>' +
'<td class="small text-muted">' + (d.shippingDate || '') + '</td>' +
'<td class="text-center">' + d.totalItems + '</td>' +
'<td class="text-center">' + progress + '</td>' +
'<td><a href="/Admin/Shipping/ShippingDocument/' + d.id + '" class="btn btn-xs btn-outline-secondary">Megnyitás</a></td>' +
'</tr>'
);
});
$('#fb-docs-card').show();
}
// ── Show everything, hide spinner ────────────────────────────────
$('#fb-dashboard-loading').hide();
$('#fb-dashboard-content').show();
},
error: function (xhr, status, error) {
$('#fb-dashboard-loading').html(
'<p class="text-muted"><i class="fas fa-exclamation-circle mr-1"></i>Nem sikerült betölteni az operatív adatokat. (' + (xhr.status || status) + ')</p>'
);
}
});
}());
</script>

View File

@ -1,62 +1,59 @@
@using AyCode.Utils.Extensions
@using Nop.Core;
@using Nop.Plugin.Misc.FruitBankPlugin.Services
@using Nop.Core.Domain.Customers
@inject IWorkContext workContext
@inject AICalculationService aiCalculationService
@{
var customer = await workContext.GetCurrentCustomerAsync();
}
<div class="card card-primary">
<div class="card-header">
<h3 class="card-title">
<i class="fas fa-leaf"></i>
Üdvözöljük a Fruit Bank rendszerben!
</h3>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-8">
@{
Customer customer = await workContext.GetCurrentCustomerAsync();
<div class="card-header">
<h3 class="card-title">
<i class="fas fa-leaf"></i>
Üdvözöljük a Fruit Bank rendszerben!
</h3>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-8">
<div id="welcome-message-content">
<p class="lead text-muted">
<i class="fas fa-spinner fa-spin mr-2"></i>
Összefoglaló betöltése...
</p>
</div>
<p>
Mai dátum: <strong>@DateTime.Now.ToString("yyyy. MMMM dd., dddd", new System.Globalization.CultureInfo("hu-HU"))</strong>
</p>
</div>
<div class="col-md-4">
<div class="bg-light p-3 rounded">
<h5><i class="fas fa-chart-line text-warning"></i> Mai összefoglaló</h5>
<ul class="list-unstyled">
<li><i class="fas fa-clock text-info"></i> Bejelentkezés ideje: @DateTime.Now.ToString("HH:mm")</li>
<li><i class="fas fa-calendar text-success"></i> Aktív napok: @DateTime.Now.DayOfYear</li>
<li><i class="fas fa-sun text-warning"></i> Szép napot kívánunk!</li>
</ul>
</div>
</div>
</div>
</div>
</div>
var welcomeMessage = await aiCalculationService.GetWelcomeMessageAsync(customer);
if (welcomeMessage.IsNullOrWhiteSpace())
//if(string.IsNullOrWhiteSpace(welcomeMessage))
{
<p class="lead">Nincs kapcsolat az AI szerverrel...</p>
}
else
{
var email = customer.Email;
<h4>Ssytem check</h4>
<p class="lead">@welcomeMessage</p>
}
}
<p>
Mai dátum: <strong>@DateTime.Now.ToString("yyyy. MMMM dd., dddd", new System.Globalization.CultureInfo("hu-HU"))</strong>
</p>
@*
<hr>
<div class="row">
<div class="col-sm-6">
<h5><i class="fas fa-apple-alt text-success"></i> Gyümölcsök</h5>
<p class="text-muted">Friss, minőségi gyümölcsök széles választéka</p>
</div>
<div class="col-sm-6">
<h5><i class="fas fa-shipping-fast text-primary"></i> Gyors szállítás</h5>
<p class="text-muted">Megbízható kiszállítás országszerte</p>
</div>
</div> *@
</div>
<div class="col-md-4">
<div class="bg-light p-3 rounded">
<h5><i class="fas fa-chart-line text-warning"></i> Mai összefoglaló</h5>
<ul class="list-unstyled">
<li><i class="fas fa-clock text-info"></i> Bejelentkezés ideje: @DateTime.Now.ToString("HH:mm")</li>
<li><i class="fas fa-calendar text-success"></i> Aktív napok: @DateTime.Now.DayOfYear</li>
<li><i class="fas fa-sun text-warning"></i> Szép napot kívánunk!</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<script>
$(function () {
$.ajax({
url: '/Admin/CustomDashboard/GetWelcomeMessage',
type: 'GET',
timeout: 30000,
success: function (html) {
$('#welcome-message-content').html(html);
},
error: function () {
$('#welcome-message-content').html(
'<p class="lead text-muted"><i class="fas fa-exclamation-circle mr-2"></i>Nincs kapcsolat az AI szerverrel...</p>'
);
}
});
});
</script>

View File

@ -1,8 +1,14 @@
using ExCSS;
using ExCSS;
using FruitBank.Common.Entities;
using FruitBank.Common.Enums;
using Microsoft.AspNetCore.Mvc;
using Nop.Core;
using Nop.Core.Domain.Common;
using Nop.Core.Domain.Customers;
using Nop.Core.Domain.Orders;
using Nop.Core.Domain.Payments;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Plugin.Misc.FruitBankPlugin.Services;
using Nop.Services.Common;
using Nop.Services.Configuration;
using Nop.Services.Localization;
@ -29,12 +35,16 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
protected readonly ISettingService _settingService;
protected readonly IGenericAttributeService _genericAttributeService;
protected readonly IWorkContext _workContext;
protected readonly AICalculationService _aiCalculationService;
protected readonly FruitBankDbContext _fruitBankDbContext;
protected readonly PreorderDbContext _preorderDbContext;
#endregion
#region Ctor
public CustomDashboardController(AdminAreaSettings adminAreaSettings,
public CustomDashboardController(
AdminAreaSettings adminAreaSettings,
ICommonModelFactory commonModelFactory,
IHomeModelFactory homeModelFactory,
ILocalizationService localizationService,
@ -42,7 +52,10 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
IPermissionService permissionService,
ISettingService settingService,
IGenericAttributeService genericAttributeService,
IWorkContext workContext)
IWorkContext workContext,
AICalculationService aiCalculationService,
FruitBankDbContext fruitBankDbContext,
PreorderDbContext preorderDbContext)
{
_adminAreaSettings = adminAreaSettings;
_commonModelFactory = commonModelFactory;
@ -53,6 +66,9 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
_settingService = settingService;
_workContext = workContext;
_genericAttributeService = genericAttributeService;
_aiCalculationService = aiCalculationService;
_fruitBankDbContext = fruitBankDbContext;
_preorderDbContext = preorderDbContext;
}
#endregion
@ -61,9 +77,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
public virtual async Task<IActionResult> Index()
{
//display a warning to a store owner if there are some error
var customer = await _workContext.GetCurrentCustomerAsync();
var email = customer.Email;
var hideCard = await _genericAttributeService.GetAttributeAsync<bool>(customer, NopCustomerDefaults.HideConfigurationStepsAttribute);
var closeCard = await _genericAttributeService.GetAttributeAsync<bool>(customer, NopCustomerDefaults.CloseConfigurationStepsAttribute);
@ -73,11 +87,10 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
if (warnings.Any(warning => warning.Level == SystemWarningLevel.Fail || warning.Level == SystemWarningLevel.Warning))
{
var locale = await _localizationService.GetResourceAsync("Admin.System.Warnings.Errors");
_notificationService.WarningNotification(string.Format(locale, Url.Action("Warnings", "Common")), false); //do not encode URLs
_notificationService.WarningNotification(string.Format(locale, Url.Action("Warnings", "Common")), false);
}
}
//progress of localization
var currentLanguage = await _workContext.GetWorkingLanguageAsync();
var progress = await _genericAttributeService.GetAttributeAsync<string>(currentLanguage, NopCommonDefaults.LanguagePackProgressAttribute);
if (!string.IsNullOrEmpty(progress))
@ -87,10 +100,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
await _genericAttributeService.SaveAttributeAsync(currentLanguage, NopCommonDefaults.LanguagePackProgressAttribute, string.Empty);
}
//prepare model
var model = await _homeModelFactory.PrepareDashboardModelAsync(new DashboardModel());
//return View(model);
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Index.cshtml", model);
}
@ -99,7 +109,6 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
{
_adminAreaSettings.HideAdvertisementsOnAdminArea = !_adminAreaSettings.HideAdvertisementsOnAdminArea;
await _settingService.SaveSettingAsync(_adminAreaSettings);
return Content("Setting changed");
}
@ -145,7 +154,300 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
return PartialView("Table", model);
}
[HttpGet]
public virtual async Task<IActionResult> GetWelcomeMessage()
{
try
{
var customer = await _workContext.GetCurrentCustomerAsync();
var message = await _aiCalculationService.GetWelcomeMessageAsync(customer);
if (string.IsNullOrWhiteSpace(message))
return Content("<p class=\"lead text-muted\">Nincs kapcsolat az AI szerverrel...</p>");
return Content($"<p class=\"lead\">{System.Net.WebUtility.HtmlEncode(message)}</p>");
}
catch (Exception ex)
{
return Content($"<p class=\"lead text-muted\"><i class=\"fas fa-exclamation-circle mr-2\"></i>{System.Net.WebUtility.HtmlEncode(ex.Message)}</p>");
}
}
/// <summary>
/// Returns all FruitBank dashboard data as a single JSON payload.
/// Called via AJAX after page load — never blocks the dashboard render.
/// </summary>
[HttpGet]
public virtual async Task<IActionResult> GetDashboardData()
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Orders.ORDERS_VIEW))
return Forbid();
var today = DateTime.Now.Date;
// ── Batch 1: parallel queries ─────────────────────────────────────
// NOTE: PreorderItems is not a LinqToDB [Association] — never use LoadWith on it.
// Preorders and items are loaded separately and joined in memory below.
var allOrdersTask = _fruitBankDbContext.OrderDtos.GetAll(true).ToListAsync();
var allCreditsTask = _fruitBankDbContext.CustomerCredits.GetAll().ToListAsync();
var allPreordersTask = _preorderDbContext.Preorders.GetAll().ToListAsync();
var unprocessedDocsTask = _fruitBankDbContext.ShippingDocuments.GetAllNotMeasured(true).ToListAsync();
await Task.WhenAll(allOrdersTask, allCreditsTask, allPreordersTask, unprocessedDocsTask);
var allOrders = await allOrdersTask;
var credits = await allCreditsTask;
var unprocessedDocs = await unprocessedDocsTask;
// Filter pending preorders in memory — LinqToDB cannot translate enum comparisons to SQL
var pendingStatuses = new[] { PreorderStatus.Pending, PreorderStatus.PartiallyFulfilled };
var pendingPreorders = (await allPreordersTask)
.Where(p => pendingStatuses.Contains(p.Status))
.ToList();
// ── Batch 1b: preorder items for pending preorders only ────────────
var pendingPreorderIds = pendingPreorders.Select(p => p.Id).ToList();
var pendingItems = pendingPreorderIds.Any()
? await _preorderDbContext.PreorderItems.GetAll()
.Where(i => pendingPreorderIds.Contains(i.PreorderId))
.ToListAsync()
: new List<PreorderItem>();
// ── Today's orders (in-memory filter, same pattern as AICalculationService)
var todaysOrders = allOrders
.Where(o => o.DateOfReceiptOrCreated.Date == today)
.OrderByDescending(o => o.DateOfReceiptOrCreated)
.ToList();
// ── Batch 2: unpaid balances for credit customers ──────────────────
var creditCustomerIds = credits.Select(c => c.CustomerId).ToList();
var unpaidByCustomer = new Dictionary<int, decimal>();
if (creditCustomerIds.Any())
{
var unpaid = await _fruitBankDbContext.OrderDtos.GetAll(false)
.Where(o => creditCustomerIds.Contains(o.CustomerId)
&& o.OrderStatusId != (int)OrderStatus.Cancelled
&& o.PaymentStatusId < (int)PaymentStatus.Paid)
.Select(o => new { o.CustomerId, o.OrderTotal })
.ToListAsync();
unpaidByCustomer = unpaid
.GroupBy(o => o.CustomerId)
.ToDictionary(g => g.Key, g => g.Sum(o => o.OrderTotal));
}
// ── Customer name helper: orders cache first, then ID fallback ─────
string CustomerName(int customerId)
{
var fromOrder = allOrders.FirstOrDefault(o => o.CustomerId == customerId)?.Customer;
if (fromOrder != null)
{
if (!string.IsNullOrEmpty(fromOrder.Company)) return fromOrder.Company;
if (!string.IsNullOrEmpty(fromOrder.Email)) return fromOrder.Email;
}
return $"#{customerId}";
}
// ── Preorder customer names (may not appear in allOrders) ──────────
var preorderCustomerIds = pendingPreorders
.Select(p => p.CustomerId).Distinct()
.Where(id => allOrders.All(o => o.CustomerId != id))
.ToList();
var preorderCustomerLookup = new Dictionary<int, string>();
if (preorderCustomerIds.Any())
{
var customers = await _preorderDbContext.Customers.Table
.Where(c => preorderCustomerIds.Contains(c.Id))
.Select(c => new { c.Id, c.Company, c.Email })
.ToListAsync();
foreach (var c in customers)
preorderCustomerLookup[c.Id] = !string.IsNullOrEmpty(c.Company) ? c.Company : c.Email;
}
string PreorderCustomerName(int customerId)
=> preorderCustomerLookup.TryGetValue(customerId, out var name)
? name
: CustomerName(customerId);
// ─────────────────────────────────────────────────────────────────
// Section 1: Today's pipeline
// ─────────────────────────────────────────────────────────────────
var todayRows = todaysOrders.Take(20).Select(o =>
{
var hasInnVoice = o.GenericAttributes.Any(a => a.Key == "InnVoiceOrderTableId");
return new
{
id = o.Id,
orderNumber = o.CustomOrderNumber,
company = CustomerName(o.CustomerId),
total = o.OrderTotal,
measuringStatus = o.MeasuringStatus.ToString(),
audited = o.IsAllOrderItemAudited,
hasInnVoice,
orderStatus = o.OrderStatus.ToString(),
dateOfReceipt = o.DateOfReceiptOrCreated.ToString("yyyy.MM.dd HH:mm")
};
}).ToList();
var pipeline = new
{
total = todaysOrders.Count,
measuring = todaysOrders.Count(o => o.MeasuringStatus == MeasuringStatus.Started),
audited = todaysOrders.Count(o => o.IsAllOrderItemAudited),
missingInnVoice = todaysOrders.Count(o =>
o.IsAllOrderItemAudited &&
!o.GenericAttributes.Any(a => a.Key == "InnVoiceOrderTableId")),
completed = todaysOrders.Count(o => o.OrderStatus == OrderStatus.Complete),
rows = todayRows
};
// ─────────────────────────────────────────────────────────────────
// Section 2: Alerts
// ─────────────────────────────────────────────────────────────────
var alerts = new List<object>();
// Audited today but InnVoice sync missing and not yet completed
foreach (var o in todaysOrders.Where(o =>
o.IsAllOrderItemAudited &&
o.OrderStatus != OrderStatus.Complete &&
!o.GenericAttributes.Any(a => a.Key == "InnVoiceOrderTableId")))
{
alerts.Add(new
{
type = "missing_innvoice",
orderId = o.Id,
orderNumber = o.CustomOrderNumber,
company = CustomerName(o.CustomerId),
message = "Auditálva, InnVoice szinkron hiányzik"
});
}
// Measuring started but DateOfReceipt is past (stale, stuck in progress)
foreach (var o in allOrders.Where(o =>
o.MeasuringStatus == MeasuringStatus.Started &&
o.DateOfReceiptOrCreated.Date < today))
{
alerts.Add(new
{
type = "stale_measuring",
orderId = o.Id,
orderNumber = o.CustomOrderNumber,
company = CustomerName(o.CustomerId),
dateOfReceipt = o.DateOfReceiptOrCreated.ToString("yyyy.MM.dd"),
message = "Régi, befejezetlen mérés"
});
}
// Partners over their credit limit
foreach (var credit in credits)
{
var outstanding = unpaidByCustomer.GetValueOrDefault(credit.CustomerId, 0m);
if (outstanding > credit.CreditLimit)
{
alerts.Add(new
{
type = "credit_exceeded",
customerId = credit.CustomerId,
company = CustomerName(credit.CustomerId),
outstanding,
creditLimit = credit.CreditLimit,
message = "Hitelkeret túllépve"
});
}
}
// Pending preorders older than 7 days
var sevenDaysAgo = DateTime.UtcNow.AddDays(-7);
foreach (var p in pendingPreorders.Where(p =>
p.Status == PreorderStatus.Pending &&
p.CreatedOnUtc < sevenDaysAgo))
{
alerts.Add(new
{
type = "old_preorder",
preorderId = p.Id,
company = PreorderCustomerName(p.CustomerId),
createdAt = p.CreatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd"),
message = "Régi, nyitott előrendelés"
});
}
// ─────────────────────────────────────────────────────────────────
// Section 3: Credit status (only partners with a limit set)
// ─────────────────────────────────────────────────────────────────
var creditRows = credits
.Select(credit =>
{
var outstanding = unpaidByCustomer.GetValueOrDefault(credit.CustomerId, 0m);
var remaining = credit.CreditLimit - outstanding;
var status = outstanding > credit.CreditLimit
? "exceeded"
: credit.CreditLimit > 0 && outstanding > credit.CreditLimit * 0.8m
? "warning"
: "ok";
return new
{
customerId = credit.CustomerId,
company = CustomerName(credit.CustomerId),
creditLimit = credit.CreditLimit,
outstanding,
remaining,
status
};
})
.OrderByDescending(c => c.outstanding)
.ToList();
// ─────────────────────────────────────────────────────────────────
// Section 4: Pending preorders (items joined in memory)
// ─────────────────────────────────────────────────────────────────
var preorderRows = pendingPreorders
.OrderBy(p => p.CreatedOnUtc)
.Select(p =>
{
var items = pendingItems.Where(i => i.PreorderId == p.Id).ToList();
return new
{
id = p.Id,
customerId = p.CustomerId,
company = PreorderCustomerName(p.CustomerId),
status = p.Status.ToString(),
createdAt = p.CreatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd"),
itemCount = items.Count,
fulfilledCount = items.Count(i => i.Status == PreorderItemStatus.Fulfilled)
};
})
.ToList();
// ─────────────────────────────────────────────────────────────────
// Section 5: Unprocessed shipping documents
// ─────────────────────────────────────────────────────────────────
var docRows = unprocessedDocs
.OrderBy(d => d.ShippingDate)
.Select(doc => new
{
id = doc.Id,
partnerName = doc.Partner?.Name ?? "",
shippingDate = doc.ShippingDate.ToString("yyyy.MM.dd"),
totalItems = doc.ShippingItems?.Count ?? 0,
measuredItems = doc.ShippingItems?.Count(si => si.IsMeasured) ?? 0
})
.ToList();
// ─────────────────────────────────────────────────────────────────
return Json(new
{
pipeline,
alerts,
creditStatus = creditRows,
pendingPreorders = preorderRows,
unprocessedDocs = docRows
});
}
#endregion
}
}

View File

@ -9,6 +9,7 @@ using FruitBank.Common.Dtos;
using FruitBank.Common.Entities;
using FruitBank.Common.Enums;
using FruitBank.Common.Interfaces;
using FruitBank.Common.Server;
using FruitBank.Common.Server.Interfaces;
using FruitBank.Common.Server.Services.SignalRs;
using FruitBank.Common.SignalRs;
@ -72,7 +73,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
private readonly CustomOrderModelFactory _orderModelFactory;
private readonly ICustomOrderSignalREndpointServer _customOrderSignalREndpoint;
private readonly IPermissionService _permissionService;
private readonly IGenericAttributeService _genericAttributeService;
//private readonly IGenericAttributeService _genericAttributeService;
private readonly FruitBankAttributeService _fruitBankAttributeService;
private readonly INotificationService _notificationService;
private readonly ICustomerService _customerService;
private readonly IProductService _productService;
@ -89,6 +91,9 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
protected readonly ITaxService _taxService;
protected readonly MeasurementService _measurementService;
protected readonly IWorkflowMessageService _workflowMessageService;
protected readonly FruitBankNotificationService _fruitBankNotificationService;
protected readonly IAddressService _addressService;
private readonly FruitBankOrderItemService _orderItemService;
private static readonly char[] _separator = [','];
// ... other dependencies
@ -121,7 +126,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
IOrderModelFactory orderModelFactory,
ICustomOrderSignalREndpointServer customOrderSignalREndpoint,
IPermissionService permissionService,
IGenericAttributeService genericAttributeService,
//IGenericAttributeService genericAttributeService,
FruitBankAttributeService fruitBankAttributeService,
INotificationService notificationService,
ICustomerService customerService,
IProductService productService,
@ -136,7 +142,11 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
IImportManager importManager,
IDateTimeHelper dateTimeHelper,
ITaxService taxService,
MeasurementService measurementService, IWorkflowMessageService workflowMessageService)
MeasurementService measurementService,
IWorkflowMessageService workflowMessageService,
FruitBankNotificationService fruitBankNotificationService,
IAddressService addressService,
FruitBankOrderItemService orderItemService)
{
_logger = new Logger<CustomOrderController>(logWriters.ToArray());
@ -147,7 +157,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
_orderModelFactory = orderModelFactory as CustomOrderModelFactory;
_customOrderSignalREndpoint = customOrderSignalREndpoint;
_permissionService = permissionService;
_genericAttributeService = genericAttributeService;
//_genericAttributeService = genericAttributeService;
_fruitBankAttributeService = fruitBankAttributeService;
_notificationService = notificationService;
_customerService = customerService;
_productService = productService;
@ -165,7 +176,12 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
_taxService = taxService;
_measurementService = measurementService;
_workflowMessageService = workflowMessageService;
_fruitBankNotificationService = fruitBankNotificationService;
_addressService = addressService;
_orderItemService = orderItemService;
// ... initialize other deps
}
#region CustomOrderSignalREndpoint
@ -247,7 +263,77 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Order/List.cshtml", model);
}
[HttpPost]
[HttpPost]
public virtual async Task<IActionResult> AdminQuickCreateOrder(int customerId, string orderProductsJson, string deliveryDateTime)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE))
return Json(new { success = false, error = "Hozzáférés megtagadva" });
try
{
var customer = await _customerService.GetCustomerByIdAsync(customerId);
if (customer == null) return Json(new { success = false, error = "Az ügyfél nem található" });
var billingAddress = await _customerService.GetCustomerBillingAddressAsync(customer);
if (billingAddress == null)
{
var addresses = await _customerService.GetAddressesByCustomerIdAsync(customer.Id);
if (addresses?.Count > 0) { billingAddress = addresses[0]; customer.BillingAddressId = billingAddress.Id; await _customerService.UpdateCustomerAsync(customer); }
else return Json(new { success = false, error = "Az ügyfélnek nincs számlázási címe" });
}
var orderProducts = string.IsNullOrEmpty(orderProductsJson)
? new List<OrderProductItem>()
: Newtonsoft.Json.JsonConvert.DeserializeObject<List<OrderProductItem>>(orderProductsJson);
var store = await _storeContext.GetCurrentStoreAsync();
var admin = await _workContext.GetCurrentCustomerAsync();
var order = new Order
{
OrderGuid = Guid.NewGuid(), CustomOrderNumber = "", CustomerId = customerId,
CustomerLanguageId = customer.LanguageId ?? 2,
CustomerTaxDisplayType = Nop.Core.Domain.Tax.TaxDisplayType.IncludingTax,
CustomerIp = string.Empty,
OrderStatus = Nop.Core.Domain.Orders.OrderStatus.Pending,
PaymentStatus = Nop.Core.Domain.Payments.PaymentStatus.Pending,
ShippingStatus = Nop.Core.Domain.Shipping.ShippingStatus.ShippingNotRequired,
CreatedOnUtc = DateTime.UtcNow,
BillingAddressId = customer.BillingAddressId ?? 0,
ShippingAddressId = customer.ShippingAddressId,
PaymentMethodSystemName = "Payments.CheckMoneyOrder",
CustomerCurrencyCode = "HUF", CurrencyRate = 1,
OrderTotal = 0, OrderSubtotalInclTax = 0, OrderSubtotalExclTax = 0,
OrderSubTotalDiscountInclTax = 0, OrderSubTotalDiscountExclTax = 0,
};
var ok = await _dbContext.TransactionSafeAsync(async _ =>
{
await _orderService.InsertOrderAsync(order);
order.CustomOrderNumber = order.Id.ToString();
await AddOrderItemsThenUpdateOrder(order, orderProducts, true, customer, store, admin);
return true;
});
if (!ok) return Json(new { success = false, error = "Rendelés létrehozása meghiúsult" });
if (!string.IsNullOrWhiteSpace(deliveryDateTime) && DateTime.TryParse(deliveryDateTime, out var deliveryDate))
{
var formatted = deliveryDate.ToString("MM/dd/yyyy HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture);
await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync<Order, string>(
order.Id, nameof(IOrderDto.DateOfReceipt), formatted, store.Id);
}
_logger.Info($"[AdminQuickCreateOrder] Order #{order.Id} for customer #{customerId}");
return Json(new { success = true, orderId = order.Id });
}
catch (Exception ex)
{
_logger.Error($"[AdminQuickCreateOrder] {ex.Message}", ex);
return Json(new { success = false, error = ex.Message });
}
}
[HttpPost]
[CheckPermission(StandardPermission.Orders.ORDERS_VIEW)]
public async Task<IActionResult> OrderList(OrderSearchModelExtended searchModel)
{
@ -434,7 +520,15 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
// store attributes in GenericAttribute table
//await _genericAttributeService.SaveAttributeAsync(order, nameof(IMeasurable.IsMeasurable), model.IsMeasurable, _storeContext.GetCurrentStore().Id);
await _genericAttributeService.SaveAttributeAsync(order, nameof(IOrderDto.DateOfReceipt), model.DateOfReceipt, _storeContext.GetCurrentStore().Id);
//await _genericAttributeService.SaveAttributeAsync(order, nameof(IOrderDto.DateOfReceipt), model.DateOfReceipt, _storeContext.GetCurrentStore().Id);
await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync<Order, string>(
order.Id,
nameof(IOrderDto.DateOfReceipt),
model.DateOfReceipt.HasValue
? model.DateOfReceipt.Value.ToString("MM/dd/yyyy HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture)
: null,
_storeContext.GetCurrentStore().Id);
var orderDto = await _dbContext.OrderDtos.GetByIdAsync(model.OrderId, true);
await _sendToClient.SendOrderChanged(orderDto);
@ -494,6 +588,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
{
//no address at all, cannot create order
_logger.Error($"Cannot create order for customer {customer.Id}, no billing address found.");
_notificationService.ErrorNotification("Cannot create order for customer, no billing address found. Please create a billing address for the customer first.");
return RedirectToAction("List");
}
}
@ -547,7 +642,28 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
//var orderDto = await _dbContext.OrderDtos.GetByIdAsync(order.Id, true);
//await _sendToClient.SendMeasuringNotification("Módosult a rendelés, mérjétek újra!", orderDto);
//var updatedOrder = await _orderService.GetOrderByIdAsync(order.Id);
await _workflowMessageService.SendOrderPlacedCustomerNotificationAsync(order, order.CustomerLanguageId);
//await _workflowMessageService.SendOrderPlacedCustomerNotificationAsync(order, order.CustomerLanguageId);
if(customer.BillingAddressId.HasValue)
{
//var billingAddress = await _addressService.GetAddressByIdAsync((int)customer.BillingAddressId);
if (billingAddress.Email != null)
{
if (!billingAddress.Email.EndsWith("inval.id"))
{
var messageResult = await _fruitBankNotificationService.SendOrderPlacedCustomerNotificationAsync(order, order.CustomerLanguageId);
if (messageResult.First() != -1)
{
_notificationService.SuccessNotification("Order placed email sent to customer.");
}
else
{
_logger.Warning($"Order placed email was not sent to customer {customer.Id} because of invalid email address: {billingAddress.Email}");
_notificationService.WarningNotification("Order placed email was not sent to customer because of invalid email address.");
}
}
}
}
return RedirectToAction("Edit", "Order", new { id = order.Id });
}
@ -591,6 +707,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,27 +719,32 @@ 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 unitPriceInclTaxValue = priceCalculation.finalPrice;
// Use the order item values directly — these already reflect any manual
// price override from CreateOrderItem, unlike a fresh GetFinalPriceAsync call.
order.OrderSubtotalInclTax += orderItem.UnitPriceInclTax * orderItem.Quantity;
order.OrderSubtotalExclTax += orderItem.UnitPriceExclTax * orderItem.Quantity;
var (unitPriceExclTaxValue, _) = await _taxService.GetProductPriceAsync(product, unitPriceInclTaxValue, false, customer);
// Discount only applies when price was NOT manually overridden.
if (unitPricesIncludeDiscounts)
{
var priceCalculation = await _priceCalculationService.GetFinalPriceAsync(product, customer, store, includeDiscounts: true);
var appliedDiscounts = priceCalculation.appliedDiscountAmount;
order.OrderSubTotalDiscountInclTax += appliedDiscounts * orderItem.Quantity;
order.OrderSubTotalDiscountExclTax += appliedDiscounts * orderItem.Quantity;
}
order.OrderSubtotalInclTax += unitPriceInclTaxValue * orderItem.Quantity;
order.OrderSubtotalExclTax += unitPriceExclTaxValue * orderItem.Quantity;
order.OrderSubTotalDiscountInclTax += order.OrderSubtotalInclTax - orderItem.PriceInclTax;
order.OrderSubTotalDiscountExclTax += order.OrderSubtotalExclTax - orderItem.PriceExclTax;
//order.OrderTax
//order.TaxRates
order.OrderTotal += orderItem.PriceInclTax + order.OrderShippingInclTax + order.PaymentMethodAdditionalFeeInclTax;
// OrderTotal: add item price only. Shipping and payment fees are NOT added
// per item — they are already in the order total from creation and should
// not be multiplied by the number of items being added.
order.OrderTotal += orderItem.PriceInclTax;
}
await _orderService.UpdateOrderAsync(order);
@ -686,15 +809,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
};
}
public interface IOrderProductItemBase
{
/// <summary>
/// ProductId
/// </summary>
public int Id { get; set; }
public int Quantity { get; set; }
public decimal Price { get; set; }
}
// IOrderProductItemBase is defined in Models/Orders/IOrderProductItemBase.cs
// and used as the shared contract across CustomOrderController and FruitBankOrderItemService.
public class OrderProductItem : IOrderProductItemBase
{
@ -944,12 +1060,12 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
{
result.Add(new
{
label = $"{product.Name} [RENDELHETŐ: {(product.StockQuantity + productDto.IncomingQuantity)}] [ÁR: {product.Price}]",
label = $"{product.Name} [RENDELHETŐ: {productDto.AvailableQuantity} (R:{productDto.StockQuantity}/K:{productDto.IncomingQuantity})] [ÁR: {product.Price}]",
value = product.Id,
sku = product.Sku,
price = product.Price,
stockQuantity = product.StockQuantity,
incomingQuantity = productDto.IncomingQuantity,
availableQuantity = productDto.AvailableQuantity,
});
}
}
@ -959,6 +1075,79 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
}
[HttpGet]
[CheckPermission(StandardPermission.Catalog.PRODUCTS_VIEW)]
public virtual async Task<IActionResult> PreorderProductSearchAutoComplete(string term)
{
if (string.IsNullOrWhiteSpace(term) || term.Length < 2)
return Json(new List<object>());
const int maxResults = 30;
var today = DateTime.UtcNow.Date;
var store = await _storeContext.GetCurrentStoreAsync();
// Load preorder window attributes in two batch queries
var gaStart = await _dbContext.GenericAttributes.Table
.Where(ga => ga.KeyGroup == nameof(Product)
&& ga.Key == FruitBankConst.PreorderWindowStart
&& ga.StoreId == store.Id)
.ToListAsync();
var gaEnd = await _dbContext.GenericAttributes.Table
.Where(ga => ga.KeyGroup == nameof(Product)
&& ga.Key == FruitBankConst.PreorderWindowEnd
&& ga.StoreId == store.Id)
.ToListAsync();
var startById = gaStart.ToDictionary(g => g.EntityId, g => g.Value);
var endById = gaEnd.ToDictionary(g => g.EntityId, g => g.Value);
// Product IDs currently in the preorder window
var availableIds = startById.Keys
.Intersect(endById.Keys)
.Where(id =>
{
DateTime.TryParse(startById[id], out var ws);
DateTime.TryParse(endById[id], out var we);
return ws.Date <= today && today <= we.Date;
})
.ToHashSet();
if (!availableIds.Any())
return Json(new List<object>());
// Search within available products only
var products = await _productService.SearchProductsAsync(
keywords: term,
pageIndex: 0,
pageSize: maxResults);
var inWindow = products.Where(p => availableIds.Contains(p.Id)).ToList();
if (!inWindow.Any())
return Json(new List<object>());
var productDtosById = await _dbContext.ProductDtos
.GetAllByIds(inWindow.Select(p => p.Id))
.ToDictionaryAsync(k => k.Id, v => v);
var result = new List<object>();
foreach (var product in inWindow)
{
productDtosById.TryGetValue(product.Id, out var dto);
result.Add(new
{
label = $"{product.Name} [KÉSZLET: {(product.StockQuantity + (dto?.IncomingQuantity ?? 0))}] [ÁR: {product.Price}]",
value = product.Id,
sku = product.Sku,
price = product.Price,
stockQuantity = product.StockQuantity,
incomingQuantity = dto?.IncomingQuantity ?? 0
});
}
return Json(result);
}
[HttpGet]
[CheckPermission(StandardPermission.Catalog.PRODUCTS_VIEW)]
public virtual async Task<IActionResult> ProductSearchUnfilteredAutoComplete(string term)
{
@ -967,7 +1156,6 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
const int maxResults = 30;
// Search products by name or SKU
var products = await _productService.SearchProductsAsync(
keywords: term,
pageIndex: 0,
@ -981,24 +1169,21 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
var productDto = productDtosById[product.Id];
if (productDto != null)
{
result.Add(new
{
label = $"{product.Name} [RENDELHETŐ: {(product.StockQuantity + productDto.IncomingQuantity)}] [ÁR: {product.Price}]",
value = product.Id,
sku = product.Sku,
price = product.Price,
stockQuantity = product.StockQuantity,
label = $"{product.Name} [RENDELHETŐ: {(product.StockQuantity + productDto.IncomingQuantity)}] [ÁR: {product.Price}]",
value = product.Id,
sku = product.Sku,
price = product.Price,
stockQuantity = product.StockQuantity,
incomingQuantity = productDto.IncomingQuantity,
});
}
}
return Json(result);
}
//[HttpPost]
//public async Task<IActionResult> CreateInvoice(int orderId)
//{
// try
@ -1396,14 +1581,38 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
[HttpPost]
//[IgnoreAntiforgeryToken]
[ValidateAntiForgeryToken]
public async Task<IActionResult> FruitBankAddProductToOrder(int orderId, string productsJson)
[ValidateAntiForgeryToken]
[CheckPermission(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE)]
public async Task<IActionResult> SendOrderEmailToCustomer(int orderId)
{
try
{
try
{
_logger.Info($"AddProductToOrder - OrderId: {orderId}, ProductsJson: {productsJson}");
var order = await _orderService.GetOrderByIdAsync(orderId);
if (order == null)
return Json(new { success = false, message = "Rendelés nem található" });
var sentIds = await _fruitBankNotificationService.SendOrderInfoEmailAsync(order);
var sentCount = sentIds?.Count(id => id > 0) ?? 0;
if (sentCount > 0)
return Json(new { success = true, message = $"Email sikeresen elküldve ({sentCount} címzett)" });
return Json(new { success = false, message = "Az email nem került elküldésre. Ellenőrizze az email sablont és az ügyfél email címét." });
}
catch (Exception ex)
{
_logger.Error($"SendOrderEmailToCustomer error orderId={orderId}: {ex.Message}", ex);
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
}
}
[HttpPost]
[ValidateAntiForgeryToken]
[CheckPermission(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE)]
public async Task<IActionResult> FruitBankAddProductToOrder(int orderId, string productsJson)
{
try {
_logger.Info($"AddProductToOrder - OrderId: {orderId}, ProductsJson: {productsJson}");
if (!await _permissionService.AuthorizeAsync(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE))
return Json(new { success = false, message = "Access denied" });
@ -1751,6 +1960,371 @@ 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()
{
var swTotal = System.Diagnostics.Stopwatch.StartNew();
var sw = System.Diagnostics.Stopwatch.StartNew();
// ── 1. Parse DataTables protocol params ────────────────────────
_ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1);
_ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0);
_ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 50 : Math.Min(length, 500);
// Sort column
_ = int.TryParse(Request.Form["order[0][column]"].FirstOrDefault(), out int sortColIdx);
var sortDir = Request.Form["order[0][dir]"].FirstOrDefault() ?? "desc";
var sortColName = Request.Form[$"columns[{sortColIdx}][data]"].FirstOrDefault() ?? "Id";
// Per-column search values keyed by column data-field name
var colSearch = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (int ci = 0; Request.Form.ContainsKey($"columns[{ci}][data]"); ci++)
{
var cData = Request.Form[$"columns[{ci}][data]"].FirstOrDefault();
var cVal = Request.Form[$"columns[{ci}][search][value]"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(cData) && !string.IsNullOrWhiteSpace(cVal))
colSearch[cData] = cVal.Trim();
}
// ── 2. Parse custom filter params ─────────────────────────────
DateTime? startDate = null, endDate = null;
if (DateTime.TryParse(Request.Form["StartDate"].FirstOrDefault(), out var sd)) startDate = sd;
if (DateTime.TryParse(Request.Form["EndDate"].FirstOrDefault(), out var ed)) endDate = ed;
var orderStatusIds = Request.Form["OrderStatusIds"]
.Where(v => int.TryParse(v, out _)).Select(int.Parse).Where(id => id > 0).ToList();
var paymentStatusIds = Request.Form["PaymentStatusIds"]
.Where(v => int.TryParse(v, out _)).Select(int.Parse).Where(id => id > 0).ToList();
var shippingStatusIds = Request.Form["ShippingStatusIds"]
.Where(v => int.TryParse(v, out _)).Select(int.Parse).Where(id => id > 0).ToList();
var billingCompany = Request.Form["BillingCompany"].FirstOrDefault();
bool? isMeasurableFilter = null;
var imStr = Request.Form["IsMeasurable"].FirstOrDefault();
if (imStr == "true") isMeasurableFilter = true;
if (imStr == "false") isMeasurableFilter = false;
bool? hasInnvoiceFilter = null;
var hiStr = Request.Form["HasInnvoiceTechId"].FirstOrDefault();
if (hiStr == "true") hasInnvoiceFilter = true;
if (hiStr == "false") hasInnvoiceFilter = false;
_logger.Info($"[PERF] FruitBankOrderList params parsed in {sw.ElapsedMilliseconds} ms");
sw.Restart();
// ── 3. Direct lean query bypasses the factory N+1 problem ───────
// OrderDtos already has all FruitBank fields + Customer + GenericAttributes.
// LinqToDB LoadWith batches relations into 1 query each 3 queries total
// regardless of row count, vs the factorys ~5 queries per row.
int? filterCustomerId = int.TryParse(billingCompany, out var cid) && cid > 0 ? cid : null;
// UTC conversion for date filters (same logic as base factory)
var currentTz = await _dateTimeHelper.GetCurrentTimeZoneAsync();
DateTime? startUtc = startDate.HasValue ? (DateTime?)_dateTimeHelper.ConvertToUtcTime(startDate.Value, currentTz) : null;
DateTime? endUtc = endDate.HasValue ? (DateTime?)_dateTimeHelper.ConvertToUtcTime(endDate.Value, currentTz).AddDays(1) : null;
var query = _dbContext.OrderDtos
.GetAll(true) // loads GenericAttributes in 1 batch query
.Where(o => !o.Deleted);
if (startUtc.HasValue) query = query.Where(o => o.CreatedOnUtc >= startUtc.Value);
if (endUtc.HasValue) query = query.Where(o => o.CreatedOnUtc <= endUtc.Value);
if (filterCustomerId.HasValue) query = query.Where(o => o.CustomerId == filterCustomerId.Value);
if (orderStatusIds.Any()) query = query.Where(o => orderStatusIds.Contains(o.OrderStatusId));
if (paymentStatusIds.Any()) query = query.Where(o => paymentStatusIds.Contains(o.PaymentStatusId));
if (shippingStatusIds.Any()) query = query.Where(o => shippingStatusIds.Contains(o.ShippingStatusId));
// Apply sort at DB level
bool asc = sortDir == "asc";
query = sortColName.ToLowerInvariant() switch
{
"customordernumber" => asc ? query.OrderBy(o => o.CustomOrderNumber) : query.OrderByDescending(o => o.CustomOrderNumber),
"createdon" => asc ? query.OrderBy(o => o.CreatedOnUtc) : query.OrderByDescending(o => o.CreatedOnUtc),
"dateofreceipt" => asc ? query.OrderBy(o => o.DateOfReceipt) : query.OrderByDescending(o => o.DateOfReceipt),
"orderstatusid" => asc ? query.OrderBy(o => o.OrderStatusId) : query.OrderByDescending(o => o.OrderStatusId),
"measuringstatus" => asc ? query.OrderBy(o => o.MeasuringStatus) : query.OrderByDescending(o => o.MeasuringStatus),
"customercompany" => asc ? query.OrderBy(o => o.CustomerId) : query.OrderByDescending(o => o.CustomerId),
_ => query.OrderByDescending(o => o.Id)
};
// Per-column DB-mappable filters
if (colSearch.TryGetValue("CustomOrderNumber", out var coNum) && !string.IsNullOrEmpty(coNum))
query = query.Where(o => o.CustomOrderNumber.Contains(coNum));
if (colSearch.TryGetValue("OrderStatusId", out var osColStr) && int.TryParse(osColStr, out var osColId))
query = query.Where(o => o.OrderStatusId == osColId);
if (colSearch.TryGetValue("MeasuringStatus", out var msColStr) && int.TryParse(msColStr, out var msColId))
query = query.Where(o => (int)o.MeasuringStatus == msColId);
// IsMeasurable: computed from OrderItemDtos pre-query the OrderItem table
// to get order IDs where any item belongs to a measurable product, then filter SQL
var isMeasurableColVal = colSearch.TryGetValue("IsMeasurable", out var imcs) ? imcs : null;
bool? effectiveIsMeasurable = isMeasurableFilter;
if (isMeasurableColVal != null && bool.TryParse(isMeasurableColVal, out var imcb))
effectiveIsMeasurable = imcb;
if (effectiveIsMeasurable.HasValue)
{
// Get all order IDs where any item has a measurable product
var measurableOrderIds = await _dbContext.OrderItemDtos
.GetAll(false)
.Where(oi => oi.ProductDto != null && oi.ProductDto.IsMeasurable)
.Select(oi => oi.OrderId)
.Distinct()
.ToListAsync();
query = effectiveIsMeasurable.Value
? query.Where(o => measurableOrderIds.Contains(o.Id))
: query.Where(o => !measurableOrderIds.Contains(o.Id));
_logger.Info($"[PERF] FruitBankOrderList IsMeasurable pre-query: {measurableOrderIds.Count} measurable order IDs");
}
// CustomerCompany column search: pre-query Customer table for matching IDs
if (colSearch.TryGetValue("CustomerCompany", out var ccColVal) && !string.IsNullOrEmpty(ccColVal))
{
var matchingCustomerIds = await _dbContext.Customers.Table
.Where(c => c.Company.Contains(ccColVal) ||
(c.FirstName + " " + c.LastName).Contains(ccColVal))
.Select(c => c.Id)
.ToListAsync();
query = query.Where(o => matchingCustomerIds.Contains(o.CustomerId));
_logger.Info($"[PERF] FruitBankOrderList CustomerCompany pre-query: {matchingCustomerIds.Count} matching customers");
}
// COUNT runs as a simple SELECT COUNT(*) against the filtered set
int total;
try { total = await query.CountAsync(); }
catch (Exception ex)
{
_logger.Error($"FruitBankOrderList count error: {ex.Message}", ex);
return Json(new { draw, recordsTotal = 0, recordsFiltered = 0, data = Array.Empty<object>() });
}
_logger.Info($"[PERF] FruitBankOrderList COUNT {sw.ElapsedMilliseconds} ms | total: {total}");
sw.Restart();
// Step 1: get just the IDs for the current page (plain SQL, no relations)
List<int> pageIds;
try
{
pageIds = await query.Skip(start).Take(length).Select(o => o.Id).ToListAsync();
}
catch (Exception ex)
{
_logger.Error($"FruitBankOrderList page IDs query error: {ex.Message}", ex);
return Json(new { draw, recordsTotal = 0, recordsFiltered = 0, data = Array.Empty<object>() });
}
_logger.Info($"[PERF] FruitBankOrderList page IDs (Skip+Take) {sw.ElapsedMilliseconds} ms | ids: {pageIds.Count}");
sw.Restart();
// Step 2: reload those ~50 rows with only the relations we need.
// LoadWith works here because its applied to the base table query, not a filtered IQueryable.
List<OrderDto> rows;
try
{
// GetAllByIds(ids, false) uses GetAll(false) which has LoadWith(GenericAttributes) baked in.
// LoadWith on a chained IQueryable is not supported by LinqToDB.
rows = await _dbContext.OrderDtos
.GetAllByIds(pageIds, true)
.ToListAsync();
// Re-sort to match the original query order (IN clause doesnt guarantee order)
rows = pageIds
.Select(id => rows.FirstOrDefault(r => r.Id == id))
.Where(r => r != null)
.ToList();
}
catch (Exception ex)
{
_logger.Error($"FruitBankOrderList relations query error: {ex.Message}", ex);
return Json(new { draw, recordsTotal = 0, recordsFiltered = 0, data = Array.Empty<object>() });
}
_logger.Info($"[PERF] FruitBankOrderList relations load (GetAll+LoadWith) {sw.ElapsedMilliseconds} ms | rows: {rows.Count}");
sw.Restart();
var userTz = currentTz;
// ── 4. Map to lightweight DTO ──────────────────────────────────
static string MeasuringStatusLabel(MeasuringStatus s) => s switch
{
MeasuringStatus.NotStarted => "Nincs elindítva",
MeasuringStatus.Started => "Folyamatban",
MeasuringStatus.Finnished => "Mérve",
MeasuringStatus.Audited => "Lezárva",
_ => s.ToString()
};
static string OrderStatusLabel(int id) => id switch
{
10 => "Függőben",
20 => "Feldolgozás",
30 => "Teljesítve",
40 => "Törölve",
_ => id.ToString()
};
static string PaymentStatusLabel(int id) => id switch
{
10 => "Fizetésre vár",
20 => "Félig fizetve",
30 => "Fizetve",
35 => "Túlfizetve",
40 => "Visszatérítve",
_ => id.ToString()
};
static string ShippingStatusLabel(int id) => id switch
{
10 => "Szállítás nincs",
20 => "Nincs kiszállítva",
25 => "Részben kiszállítva",
30 => "Kiszállítva",
_ => id.ToString()
};
var dtos = rows.Select(o =>
{
var ga = o.GenericAttributes;
var dateOfReceipt = ga?.FirstOrDefault(a => a.Key == "DateOfReceipt")?.Value is string dv && DateTime.TryParse(dv, out var dp) ? dp : (DateTime?)null;
var innvoiceTechId = ga?.FirstOrDefault(a => a.Key == "InnVoiceOrderTechId")?.Value;
var company = o.Customer != null
? $"{o.Customer.Company} {o.Customer.FirstName}_{o.Customer.LastName}".Trim()
: string.Empty;
return new Nop.Plugin.Misc.FruitBankPlugin.Models.Orders.FruitBankOrderRowDto
{
Id = o.Id,
CustomOrderNumber = o.CustomOrderNumber,
CustomerCompany = company,
CustomerId = o.CustomerId,
InnvoiceTechId = innvoiceTechId,
IsAllOrderItemAvgWeightValid = o.IsAllOrderItemAvgWeightValid,
IsMeasurable = o.IsMeasurable,
MeasuringStatus = (int)o.MeasuringStatus,
MeasuringStatusString = MeasuringStatusLabel(o.MeasuringStatus),
DateOfReceipt = dateOfReceipt,
OrderStatusId = o.OrderStatusId,
OrderStatus = OrderStatusLabel(o.OrderStatusId),
PaymentStatusId = o.PaymentStatusId,
PaymentStatus = PaymentStatusLabel(o.PaymentStatusId),
ShippingStatusId = o.ShippingStatusId,
ShippingStatus = ShippingStatusLabel(o.ShippingStatusId),
StoreName = string.Empty, // not needed in grid
CreatedOn = TimeZoneInfo.ConvertTimeFromUtc(o.CreatedOnUtc, userTz),
OrderTotal = !o.IsComplete && o.IsMeasurable
? "kalkuláció alatt..."
: $"{o.OrderTotal:N0} Ft"
};
}).ToList();
_logger.Info($"[PERF] FruitBankOrderList DTO mapping {sw.ElapsedMilliseconds} ms");
sw.Restart();
// InnVoice filter is post-query (its in GenericAttributes, not a plain column)
if (hasInnvoiceFilter.HasValue)
dtos = hasInnvoiceFilter.Value
? dtos.Where(o => !string.IsNullOrEmpty(o.InnvoiceTechId)).ToList()
: dtos.Where(o => string.IsNullOrEmpty(o.InnvoiceTechId)).ToList();
// InnVoice column-header filter (post-query: stored in GenericAttributes)
if (colSearch.TryGetValue("InnvoiceTechId", out var innColVal))
dtos = innColVal == "has" ? dtos.Where(o => !string.IsNullOrEmpty(o.InnvoiceTechId)).ToList()
: innColVal == "none" ? dtos.Where(o => string.IsNullOrEmpty(o.InnvoiceTechId)).ToList()
: dtos;
var result = Json(new { draw, recordsTotal = total, recordsFiltered = total, data = dtos });
_logger.Info($"[PERF] FruitBankOrderList JSON serialize {sw.ElapsedMilliseconds} ms");
_logger.Info($"[PERF] FruitBankOrderList TOTAL {swTotal.ElapsedMilliseconds} ms | page: {dtos.Count}");
return result;
}
/// <summary>
/// Inline-edit save endpoint. Currently supports DateOfReceipt.
/// </summary>
[HttpPost]
[CheckPermission(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE)]
public async Task<IActionResult> UpdateOrderField(int orderId, string field, string value)
{
try
{
var order = await _orderService.GetOrderByIdAsync(orderId);
if (order == null)
return Json(new { success = false, error = "Rendelés nem található" });
switch (field?.ToUpperInvariant())
{
case "DATEOFRECEIPT":
var dateOdReceiptDateTime = DateTime.TryParse(value, out var dp);
if (string.IsNullOrWhiteSpace(value))
{
//await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync<Order, string>(order.Id, nameof(IOrderDto.DateOfReceipt), null, _storeContext.GetCurrentStore().Id);
await _fruitBankAttributeService.DeleteGenericAttributeAsync<Order>(order.Id, nameof(IOrderDto.DateOfReceipt));
//await _genericAttributeService.SaveAttributeAsync<DateTime?>(order, "DateOfReceipt", null);
return Json(new { success = true, displayValue = (string)null });
}
if (DateTime.TryParse(value, out var newDate))
{
// Store in the same format that NopCommerce's SaveAttributeAsync<DateTime?> uses (MM/dd/yyyy HH:mm:ss invariant)
// so OrderDto deserialization in the Blazor app doesn't break.
var formattedValue = newDate.ToString("MM/dd/yyyy HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture);
await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync<Order, string>(order.Id, nameof(IOrderDto.DateOfReceipt), formattedValue, _storeContext.GetCurrentStore().Id);
return Json(new { success = true, displayValue = newDate.ToString("yyyy. MM. dd. HH:mm") });
}
return Json(new { success = false, error = "Érvénytelen dátum formátum" });
default:
return Json(new { success = false, error = $"Ismeretlen mező: {field}" });
}
}
catch (Exception ex)
{
_logger.Error($"UpdateOrderField error orderId={orderId} field={field}: {ex.Message}", ex);
return Json(new { success = false, error = ex.Message });
}
}
}
}

View File

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

View File

@ -41,6 +41,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
private readonly FileStorageService _fileStorageService;
private readonly FruitBankAttributeService _fruitBankAttributeService;
private readonly IStoreContext _storeContext;
private readonly PreorderConversionService _preorderConversionService;
public FileManagerController(
IPermissionService permissionService,
@ -53,7 +54,8 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
IWorkContext workContext,
FileStorageService fileStorageService,
FruitBankAttributeService fruitBankAttributeService,
IStoreContext storeContext)
IStoreContext storeContext,
PreorderConversionService preorderConversionService)
{
_permissionService = permissionService;
_aiApiService = aiApiService;
@ -66,6 +68,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
_fileStorageService = fileStorageService;
_fruitBankAttributeService = fruitBankAttributeService;
_storeContext = storeContext;
_preorderConversionService = preorderConversionService;
}
/// <summary>
@ -1120,6 +1123,32 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
newIncomingQuantity, _storeContext.GetCurrentStore().Id
);
}
// ── Step 3: Convert pending preorders that cover these products ──────────
var productIdsWithIncoming = shippingDocument.ShippingItems
.Where(x => x.ProductId != null)
.Select(x => x.ProductId!.Value)
.Distinct()
.ToList();
if (productIdsWithIncoming.Any())
{
// Fire-and-forget with error isolation so a conversion failure
// never blocks the shipping document save response
_ = Task.Run(async () =>
{
try
{
await _preorderConversionService
.ConvertPreordersForProductsAsync(productIdsWithIncoming, shippingDocument.Id);
}
catch (Exception convEx)
{
Console.Error.WriteLine(
$"[PreorderConversion] Error during conversion for document #{shippingDocument.Id}: {convEx.Message}");
}
});
}
return Json(new

View File

@ -34,7 +34,9 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
ApiBaseUrl = _settings.ApiBaseUrl,
MaxTokens = _settings.MaxTokens,
Temperature = _settings.Temperature,
RequestTimeoutSeconds = _settings.RequestTimeoutSeconds
RequestTimeoutSeconds = _settings.RequestTimeoutSeconds,
ZaiApiKey = _settings.ZaiApiKey,
ZaiModel = _settings.ZaiModel
};
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Configure/Configure.cshtml", model);
}
@ -58,6 +60,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
_settings.MaxTokens = model.MaxTokens;
_settings.Temperature = model.Temperature;
_settings.RequestTimeoutSeconds = model.RequestTimeoutSeconds;
_settings.ZaiApiKey = model.ZaiApiKey ?? string.Empty;
_settings.ZaiModel = model.ZaiModel ?? "glm-ocr";
// Save settings
await _settingService.SaveSettingAsync(_settings);

View File

@ -0,0 +1,452 @@
using FruitBank.Common.Entities;
using FruitBank.Common.Enums;
using Microsoft.AspNetCore.Mvc;
using Nop.Core.Domain.Customers;
using Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Plugin.Misc.FruitBankPlugin.Services;
using Nop.Services.Customers;
using Nop.Services.Security;
using Nop.Web.Framework;
using Nop.Web.Framework.Controllers;
using Nop.Web.Framework.Mvc.Filters;
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers;
[AuthorizeAdmin]
[Area(AreaNames.ADMIN)]
[AutoValidateAntiforgeryToken]
public class PreorderAdminController : BasePluginController
{
private readonly IPermissionService _permissionService;
private readonly PreorderDbContext _preorderDbContext;
private readonly FruitBankDbContext _dbContext;
private readonly ICustomerService _customerService;
private readonly PreorderConversionService _preorderConversionService;
private static readonly Dictionary<PreorderStatus, string> StatusLabels = new()
{
{ PreorderStatus.Pending, "Függőben" },
{ PreorderStatus.Confirmed, "Megerősítve" },
{ PreorderStatus.PartiallyFulfilled, "Részben teljesítve" },
{ PreorderStatus.Cancelled, "Törölve" }
};
private static readonly Dictionary<PreorderItemStatus, string> ItemStatusLabels = new()
{
{ PreorderItemStatus.Pending, "Függőben" },
{ PreorderItemStatus.Fulfilled, "Teljesítve" },
{ PreorderItemStatus.PartiallyFulfilled, "Részben" },
{ PreorderItemStatus.Dropped, "Ejtve" }
};
public PreorderAdminController(
IPermissionService permissionService,
PreorderDbContext preorderDbContext,
FruitBankDbContext dbContext,
ICustomerService customerService,
PreorderConversionService preorderConversionService)
{
_permissionService = permissionService;
_preorderDbContext = preorderDbContext;
_dbContext = dbContext;
_customerService = customerService;
_preorderConversionService = preorderConversionService;
}
// ── LIST PAGE ─────────────────────────────────────────────────────────────
[HttpGet]
[Route("Admin/Preorders")]
public async Task<IActionResult> List()
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return AccessDeniedView();
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Preorder/List.cshtml");
}
// ── DATATABLES SERVER-SIDE ────────────────────────────────────────────────
[HttpPost]
[Route("Admin/Preorders/PreorderList")]
public async Task<IActionResult> PreorderList()
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Forbid();
_ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1);
_ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0);
_ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 25 : Math.Min(length, 500);
_ = int.TryParse(Request.Form["order[0][column]"].FirstOrDefault(), out int sortColIdx);
var sortDir = Request.Form["order[0][dir]"].FirstOrDefault() ?? "desc";
var sortColName = Request.Form[$"columns[{sortColIdx}][name]"].FirstOrDefault() ?? "CreatedOnUtc";
var globalSearch = Request.Form["search[value]"].FirstOrDefault()?.Trim() ?? "";
var statusFilter = Request.Form["statusFilter"].FirstOrDefault()?.Trim() ?? "";
// 1. All preorders with items — two queries
var preorders = await _preorderDbContext.Preorders.GetAll(false).ToListAsync();
var allItems = await _preorderDbContext.PreorderItems.GetAll().ToListAsync();
var itemsByPreorder = allItems
.GroupBy(i => i.PreorderId)
.ToDictionary(g => g.Key, g => g.ToList());
// 2. Customers — batch
var customerIds = preorders.Select(p => p.CustomerId).Distinct().ToList();
var customers = await _dbContext.Customers.Table
.Where(c => customerIds.Contains(c.Id))
.Select(c => new { c.Id, c.Email, c.FirstName, c.LastName })
.ToListAsync();
var customerById = customers.ToDictionary(c => c.Id);
// 3. Linked orders — find orders created from preorders via CustomOrderNumber lookup
// We store the preorder id in the order note, but the simplest link is checking
// OrderNotes for "előrendelésből" text matching preorderId.
// For now we surface the link on the detail page only.
// 4. Build rows — derive status from quantities, not enum (LinqToDB enum reads unreliable)
var rows = preorders.Select(p =>
{
customerById.TryGetValue(p.CustomerId, out var c);
var items = itemsByPreorder.TryGetValue(p.Id, out var its) ? its : new();
// Derive status from quantities rather than relying on the enum read
var fulfilledCount = items.Count(i => i.FulfilledQuantity > 0);
var allFulfilled = items.Any() && items.All(i => i.FulfilledQuantity >= i.RequestedQuantity);
var anyFulfilled = items.Any(i => i.FulfilledQuantity > 0);
var hasOrderId = p.OrderId.HasValue;
// Derive a display status: use the DB enum if it looks valid (non-zero),
// otherwise infer from quantities
var effectiveStatus = (int)p.Status != 0
? p.Status
: allFulfilled ? PreorderStatus.Confirmed
: anyFulfilled ? PreorderStatus.PartiallyFulfilled
: PreorderStatus.Pending;
return new PreorderListRow
{
PreorderId = p.Id,
CustomerId = p.CustomerId,
CustomerName = c != null ? $"{c.FirstName} {c.LastName}".Trim() : $"#{p.CustomerId}",
CustomerEmail = c?.Email ?? string.Empty,
DateOfReceipt = p.DateOfReceipt.ToLocalTime().ToString("yyyy.MM.dd HH:mm"),
CreatedOnUtc = p.CreatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd HH:mm"),
Status = effectiveStatus,
StatusLabel = StatusLabels.TryGetValue(effectiveStatus, out var sl) ? sl : effectiveStatus.ToString(),
ItemCount = items.Count,
FulfilledCount = fulfilledCount,
OrderId = p.OrderId
};
}).ToList();
int recordsTotal = rows.Count;
// 5. Filter by status
if (!string.IsNullOrWhiteSpace(statusFilter) && Enum.TryParse<PreorderStatus>(statusFilter, out var statusEnum))
rows = rows.Where(r => r.Status == statusEnum).ToList();
// 6. Global search
if (!string.IsNullOrWhiteSpace(globalSearch))
rows = rows.Where(r =>
r.CustomerName.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) ||
r.CustomerEmail.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) ||
r.PreorderId.ToString().Contains(globalSearch)
).ToList();
int recordsFiltered = rows.Count;
// 7. Sort
bool asc = sortDir == "asc";
rows = sortColName switch
{
"CustomerName" => asc ? rows.OrderBy(r => r.CustomerName).ToList() : rows.OrderByDescending(r => r.CustomerName).ToList(),
"DateOfReceipt" => asc ? rows.OrderBy(r => r.DateOfReceipt).ToList() : rows.OrderByDescending(r => r.DateOfReceipt).ToList(),
"Status" => asc ? rows.OrderBy(r => r.Status).ToList() : rows.OrderByDescending(r => r.Status).ToList(),
_ => asc ? rows.OrderBy(r => r.CreatedOnUtc).ToList() : rows.OrderByDescending(r => r.CreatedOnUtc).ToList()
};
// 8. Paginate
var page = rows.Skip(start).Take(length).ToList();
return Json(new { draw, recordsTotal, recordsFiltered, data = page });
}
// ── DETAIL PAGE ───────────────────────────────────────────────────────────
[HttpGet]
[Route("Admin/Preorders/Detail/{id:int}")]
public async Task<IActionResult> Detail(int id)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return AccessDeniedView();
var preorder = await _preorderDbContext.Preorders.GetByIdAsync(id, loadRelations: false);
if (preorder == null) return NotFound();
var items = await _preorderDbContext.PreorderItems
.GetAllByPreorderIdAsync(id)
.ToListAsync();
var customer = await _customerService.GetCustomerByIdAsync(preorder.CustomerId);
// Resolve product names in one batch
var productIds = items.Select(i => i.ProductId).Distinct().ToList();
var productDtos = await _dbContext.ProductDtos
.GetAll(false)
.Where(p => productIds.Contains(p.Id))
.ToListAsync();
var productById = productDtos.ToDictionary(p => p.Id);
// Use preorder.OrderId directly — stored on the entity at conversion time
int? linkedOrderId = preorder.OrderId;
var model = new PreorderDetailModel
{
PreorderId = preorder.Id,
CustomerId = preorder.CustomerId,
CustomerName = customer != null ? $"{customer.FirstName} {customer.LastName}".Trim() : $"#{preorder.CustomerId}",
CustomerEmail = customer?.Email ?? string.Empty,
DateOfReceipt = preorder.DateOfReceipt.ToLocalTime().ToString("yyyy.MM.dd HH:mm"),
CreatedOnUtc = preorder.CreatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd HH:mm"),
UpdatedOnUtc = preorder.UpdatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd HH:mm"),
Status = preorder.Status,
CustomerNote = preorder.CustomerNote,
OrderId = linkedOrderId,
Items = items.Select(i =>
{
productById.TryGetValue(i.ProductId, out var dto);
// Derive item status from quantities — enum reads unreliable in LinqToDB
var derivedStatus = i.FulfilledQuantity == 0
? PreorderItemStatus.Pending
: i.FulfilledQuantity >= i.RequestedQuantity
? PreorderItemStatus.Fulfilled
: PreorderItemStatus.PartiallyFulfilled;
// If DB enum read as non-zero, prefer it; otherwise use derived
var effectiveItemStatus = (int)i.Status != 0 ? i.Status : derivedStatus;
return new PreorderDetailItemRow
{
ItemId = i.Id,
ProductId = i.ProductId,
ProductName = dto?.Name ?? $"Product #{i.ProductId}",
IsMeasurable = dto?.IsMeasurable ?? false,
RequestedQuantity = i.RequestedQuantity,
FulfilledQuantity = i.FulfilledQuantity,
UnitPriceInclTax = i.UnitPriceInclTax,
Status = effectiveItemStatus,
StatusLabel = ItemStatusLabels.TryGetValue(effectiveItemStatus, out var isl) ? isl : effectiveItemStatus.ToString()
};
}).ToList()
};
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Preorder/Detail.cshtml", model);
}
// ── CREATE (admin phone order) ───────────────────────────────────────────
[HttpPost]
[Route("Admin/Preorders/CreatePreorder")]
public async Task<IActionResult> CreatePreorder(
int customerId,
string deliveryDateTime,
string? customerNote,
string productsJson)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, error = "Hozzáférés megtagadva" });
try
{
// Validate customer
var customer = await _customerService.GetCustomerByIdAsync(customerId);
if (customer == null)
return Json(new { success = false, error = "Az ügyfél nem található" });
// Validate delivery date
if (!DateTime.TryParse(deliveryDateTime, out var deliveryDate))
return Json(new { success = false, error = "Érvénytelen szállítási dátum" });
// Parse products
if (string.IsNullOrWhiteSpace(productsJson))
return Json(new { success = false, error = "Nincs termék megadva" });
var productItems = System.Text.Json.JsonSerializer.Deserialize<List<ProductItemRequest>>(productsJson);
if (productItems == null || !productItems.Any())
return Json(new { success = false, error = "Nincs érvényes termék" });
// Get store
var storeId = (await _dbContext.Shippings.GetAll().Select(s => s.Id).FirstOrDefaultAsync() > 0)
? 1 : 1; // fallback to store 1
// Use first available store from generic attributes context
var gaStore = await _dbContext.GenericAttributes.Table
.Select(g => g.StoreId).FirstOrDefaultAsync();
storeId = gaStore > 0 ? gaStore : 1;
var preorder = new Preorder
{
CustomerId = customerId,
StoreId = storeId,
DateOfReceipt = deliveryDate,
CustomerNote = customerNote?.Trim()
};
var items = new List<PreorderItem>();
foreach (var pi in productItems.Where(p => p.quantity > 0))
{
var product = await _dbContext.Products.GetByIdAsync(pi.id);
if (product == null || product.Deleted || !product.Published) continue;
items.Add(new PreorderItem
{
ProductId = pi.id,
RequestedQuantity = pi.quantity,
UnitPriceInclTax = (decimal)pi.price
});
}
if (!items.Any())
return Json(new { success = false, error = "Nincs érvényes termék az előrendelésben" });
var saved = await _preorderDbContext.InsertPreorderAsync(preorder, items);
Console.WriteLine($"[Admin] Created preorder #{saved.Id} for customer #{customerId} " +
$"by admin, {items.Count} items, delivery {deliveryDate:u}");
// Immediately check if any items can be fulfilled from current stock —
// same inline conversion as the customer-facing PlacePreorder endpoint.
var productIds = items.Select(i => i.ProductId).Distinct().ToList();
await _preorderConversionService.ConvertPreordersForProductsAsync(productIds, 0);
// Re-read to pick up OrderId if conversion created a real order
var refreshed = await _preorderDbContext.Preorders.GetByIdAsync(saved.Id);
return Json(new { success = true, preorderId = saved.Id, orderId = refreshed?.OrderId });
}
catch (Exception ex)
{
return Json(new { success = false, error = ex.Message });
}
}
private class ProductItemRequest
{
public int id { get; set; }
public string? name { get; set; }
public int quantity { get; set; }
public double price { get; set; }
}
// ── CANCEL ────────────────────────────────────────────────────────────────
// ── CANCEL ───────────────────────────────────────────────────────────
[HttpPost]
[Route("Admin/Preorders/Cancel/{id:int}")]
public async Task<IActionResult> Cancel(int id)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, error = "Access denied" });
var preorder = await _preorderDbContext.Preorders.GetByIdAsync(id);
if (preorder == null)
return Json(new { success = false, error = "Preorder not found" });
if (preorder.Status != PreorderStatus.Pending)
return Json(new { success = false, error = "Only pending preorders can be cancelled" });
await _preorderDbContext.CancelPreorderAsync(id);
return Json(new { success = true });
}
// ── DEMAND LIST ───────────────────────────────────────────────────────────
[HttpPost]
[Route("Admin/Preorders/DemandList")]
public async Task<IActionResult> DemandList(bool openOnly = true)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Forbid();
_ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1);
_ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0);
_ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 25 : Math.Min(length, 500);
var openOnlyParam = Request.Form["openOnly"].FirstOrDefault();
openOnly = openOnlyParam != "false";
// Fetch all preorder items + preorders in two queries
var allItems = await _preorderDbContext.PreorderItems.GetAll().ToListAsync();
var allPreorders = await _preorderDbContext.Preorders.GetAll(false).ToListAsync();
// For "open only": include only items from preorders that still have
// unfulfilled demand (FulfilledQuantity < RequestedQuantity).
// We use quantities rather than Status enum (enum reads unreliable).
IEnumerable<PreorderItem> items = allItems;
if (openOnly)
{
// Open preorders: those where at least one item still needs fulfillment
var openPreorderIds = allPreorders
.Where(p => allItems
.Where(i => i.PreorderId == p.Id)
.Any(i => i.FulfilledQuantity < i.RequestedQuantity))
.Select(p => p.Id)
.ToHashSet();
items = allItems.Where(i => openPreorderIds.Contains(i.PreorderId));
}
// Group by product
var grouped = items
.GroupBy(i => i.ProductId)
.Select(g => new
{
ProductId = g.Key,
TotalRequested = g.Sum(i => i.RequestedQuantity),
TotalFulfilled = g.Sum(i => i.FulfilledQuantity),
TotalUnfulfilled = g.Sum(i => i.RequestedQuantity - i.FulfilledQuantity),
PreorderCount = g.Select(i => i.PreorderId).Distinct().Count(),
AvgUnitPrice = g.Where(i => i.UnitPriceInclTax > 0).Any()
? g.Where(i => i.UnitPriceInclTax > 0).Average(i => i.UnitPriceInclTax)
: 0m
})
.OrderByDescending(g => g.TotalUnfulfilled)
.ThenByDescending(g => g.TotalRequested)
.ToList();
// Resolve product names in one batch
var productIds = grouped.Select(g => g.ProductId).Distinct().ToList();
var productDtos = await _dbContext.ProductDtos
.GetAll(false)
.Where(p => productIds.Contains(p.Id))
.ToListAsync();
var productById = productDtos.ToDictionary(p => p.Id);
var rows = grouped.Select(g =>
{
productById.TryGetValue(g.ProductId, out var dto);
return new PreorderDemandRow
{
ProductId = g.ProductId,
ProductName = dto?.Name ?? $"Product #{g.ProductId}",
Sku = dto?.Id.ToString(),
IsMeasurable = dto?.IsMeasurable ?? false,
TotalRequested = g.TotalRequested,
TotalFulfilled = g.TotalFulfilled,
TotalUnfulfilled = g.TotalUnfulfilled,
PreorderCount = g.PreorderCount,
AvgUnitPrice = Math.Round(g.AvgUnitPrice, 0)
};
}).ToList();
int recordsTotal = rows.Count;
int recordsFiltered = rows.Count;
var page = rows.Skip(start).Take(length).ToList();
return Json(new { draw, recordsTotal, recordsFiltered, data = page });
}
}

View File

@ -0,0 +1,258 @@
using FruitBank.Common.Server;
using LinqToDB;
using Microsoft.AspNetCore.Mvc;
using Nop.Core;
using Nop.Core.Domain.Catalog;
using Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Plugin.Misc.FruitBankPlugin.Services;
using Nop.Services.Security;
using Nop.Web.Framework;
using Nop.Web.Framework.Controllers;
using Nop.Web.Framework.Mvc.Filters;
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers;
[AuthorizeAdmin]
[Area(AreaNames.ADMIN)]
[AutoValidateAntiforgeryToken]
public class PreorderAvailabilityController : BasePluginController
{
private readonly IPermissionService _permissionService;
private readonly FruitBankDbContext _dbContext;
private readonly FruitBankAttributeService _fruitBankAttributeService;
private readonly IStoreContext _storeContext;
public PreorderAvailabilityController(
IPermissionService permissionService,
FruitBankDbContext dbContext,
FruitBankAttributeService fruitBankAttributeService,
IStoreContext storeContext)
{
_permissionService = permissionService;
_dbContext = dbContext;
_fruitBankAttributeService = fruitBankAttributeService;
_storeContext = storeContext;
}
// ── INDEX ─────────────────────────────────────────────────────────────────
[HttpGet]
[Route("Admin/PreorderAvailability")]
public async Task<IActionResult> Index()
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return AccessDeniedView();
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/PreorderAvailability/Index.cshtml");
}
// ── ALL PRODUCTS — DataTables server-side ─────────────────────────────────
[HttpPost]
[Route("Admin/PreorderAvailability/ProductList")]
public async Task<IActionResult> ProductList()
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Forbid();
_ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1);
_ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0);
_ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 25 : Math.Min(length, 500);
var globalSearch = Request.Form["search[value]"].FirstOrDefault()?.Trim() ?? "";
var storeId = (await _storeContext.GetCurrentStoreAsync()).Id;
// 1. All published products
var products = await _dbContext.Products.Table
.Where(p => !p.Deleted && p.Published)
.OrderBy(p => p.Name)
.Select(p => new { p.Id, p.Name, p.Sku })
.ToListAsync();
// 2. All preorder window generic attributes — two queries, no N+1
var gaStart = await _dbContext.GenericAttributes.Table
.Where(ga => ga.KeyGroup == nameof(Product)
&& ga.Key == FruitBankConst.PreorderWindowStart
&& ga.StoreId == storeId)
.ToListAsync();
var gaEnd = await _dbContext.GenericAttributes.Table
.Where(ga => ga.KeyGroup == nameof(Product)
&& ga.Key == FruitBankConst.PreorderWindowEnd
&& ga.StoreId == storeId)
.ToListAsync();
var startByProduct = gaStart.ToDictionary(g => g.EntityId, g => g.Value);
var endByProduct = gaEnd.ToDictionary(g => g.EntityId, g => g.Value);
var today = DateTime.UtcNow.Date;
// 3. Build rows
var rows = products.Select(p =>
{
DateTime.TryParse(startByProduct.GetValueOrDefault(p.Id), out var ws);
DateTime.TryParse(endByProduct.GetValueOrDefault(p.Id), out var we);
var hasStart = startByProduct.ContainsKey(p.Id);
var hasEnd = endByProduct.ContainsKey(p.Id);
return new PreorderAvailabilityRow
{
ProductId = p.Id,
ProductName = p.Name,
Sku = p.Sku,
WindowStart = hasStart ? ws.ToString("yyyy-MM-dd") : null,
WindowEnd = hasEnd ? we.ToString("yyyy-MM-dd") : null,
IsAvailableToday = hasStart && hasEnd && ws.Date <= today && today <= we.Date
};
}).ToList();
int recordsTotal = rows.Count;
// 4. Global search
if (!string.IsNullOrWhiteSpace(globalSearch))
{
rows = rows.Where(r =>
r.ProductName.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) ||
(r.Sku?.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) ?? false)
).ToList();
}
int recordsFiltered = rows.Count;
// 5. Paginate
var page = rows.Skip(start).Take(length).ToList();
return Json(new { draw, recordsTotal, recordsFiltered, data = page });
}
// ── AVAILABLE TODAY — DataTables server-side ──────────────────────────────
[HttpPost]
[Route("Admin/PreorderAvailability/AvailableTodayList")]
public async Task<IActionResult> AvailableTodayList()
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Forbid();
_ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1);
_ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0);
_ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 25 : Math.Min(length, 500);
var storeId = (await _storeContext.GetCurrentStoreAsync()).Id;
var today = DateTime.UtcNow.Date;
// Reuse same build logic — filter to available today only
var products = await _dbContext.Products.Table
.Where(p => !p.Deleted && p.Published)
.OrderBy(p => p.Name)
.Select(p => new { p.Id, p.Name, p.Sku })
.ToListAsync();
var gaStart = await _dbContext.GenericAttributes.Table
.Where(ga => ga.KeyGroup == nameof(Product)
&& ga.Key == FruitBankConst.PreorderWindowStart
&& ga.StoreId == storeId)
.ToListAsync();
var gaEnd = await _dbContext.GenericAttributes.Table
.Where(ga => ga.KeyGroup == nameof(Product)
&& ga.Key == FruitBankConst.PreorderWindowEnd
&& ga.StoreId == storeId)
.ToListAsync();
var startByProduct = gaStart.ToDictionary(g => g.EntityId, g => g.Value);
var endByProduct = gaEnd.ToDictionary(g => g.EntityId, g => g.Value);
var rows = products
.Where(p =>
{
if (!startByProduct.TryGetValue(p.Id, out var sRaw)) return false;
if (!endByProduct.TryGetValue(p.Id, out var eRaw)) return false;
if (!DateTime.TryParse(sRaw, out var ws)) return false;
if (!DateTime.TryParse(eRaw, out var we)) return false;
return ws.Date <= today && today <= we.Date;
})
.Select(p =>
{
DateTime.TryParse(startByProduct[p.Id], out var ws);
DateTime.TryParse(endByProduct[p.Id], out var we);
return new PreorderAvailabilityRow
{
ProductId = p.Id,
ProductName = p.Name,
Sku = p.Sku,
WindowStart = ws.ToString("yyyy-MM-dd"),
WindowEnd = we.ToString("yyyy-MM-dd"),
IsAvailableToday = true
};
})
.ToList();
int recordsTotal = rows.Count;
int recordsFiltered = rows.Count;
var page = rows.Skip(start).Take(length).ToList();
return Json(new { draw, recordsTotal, recordsFiltered, data = page });
}
// ── SAVE WINDOW DATES for a product ───────────────────────────────────────
[HttpPost]
[Route("Admin/PreorderAvailability/SaveWindow")]
public async Task<IActionResult> SaveWindow(int productId, string? windowStart, string? windowEnd)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, error = "Access denied" });
try
{
var storeId = (await _storeContext.GetCurrentStoreAsync()).Id;
// WindowStart
if (string.IsNullOrWhiteSpace(windowStart))
{
await _fruitBankAttributeService
.DeleteGenericAttributeAsync<Product>(productId, FruitBankConst.PreorderWindowStart, storeId);
}
else if (DateTime.TryParse(windowStart, out var ws))
{
await _fruitBankAttributeService
.InsertOrUpdateGenericAttributeAsync<Product, DateTime>(
productId, FruitBankConst.PreorderWindowStart, ws.Date, storeId);
}
else return Json(new { success = false, error = $"Invalid start date: {windowStart}" });
// WindowEnd
if (string.IsNullOrWhiteSpace(windowEnd))
{
await _fruitBankAttributeService
.DeleteGenericAttributeAsync<Product>(productId, FruitBankConst.PreorderWindowEnd, storeId);
}
else if (DateTime.TryParse(windowEnd, out var we))
{
await _fruitBankAttributeService
.InsertOrUpdateGenericAttributeAsync<Product, DateTime>(
productId, FruitBankConst.PreorderWindowEnd, we.Date, storeId);
}
else return Json(new { success = false, error = $"Invalid end date: {windowEnd}" });
// Return the new availability state
var today = DateTime.UtcNow.Date;
DateTime.TryParse(windowStart, out var startParsed);
DateTime.TryParse(windowEnd, out var endParsed);
bool isAvailableToday = !string.IsNullOrWhiteSpace(windowStart)
&& !string.IsNullOrWhiteSpace(windowEnd)
&& startParsed.Date <= today
&& today <= endParsed.Date;
return Json(new { success = true, isAvailableToday });
}
catch (Exception ex)
{
return Json(new { success = false, error = ex.Message });
}
}
}

View File

@ -9,25 +9,25 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models
{
public record ConfigureModel
{
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ApiKey")]
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.CerebrasApiKey")]
public string ApiKey { get; set; } = string.Empty;
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ModelName")]
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.CerebrasModelName")]
public string ModelName { get; set; } = string.Empty;
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ApiKey")]
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.OpenAIApiKey")]
public string OpenAIApiKey { get; set; } = string.Empty;
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ModelName")]
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.OpenAIModelName")]
public string OpenAIModelName { get; set; } = string.Empty;
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.IsEnabled")]
public bool IsEnabled { get; set; }
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ApiBaseUrl")]
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.CerebrasApiBaseUrl")]
public string ApiBaseUrl { get; set; } = string.Empty;
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ApiBaseUrl")]
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.OpenAIApiBaseUrl")]
public string OpenAIApiBaseUrl { get; set; } = string.Empty;
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.MaxTokens")]
@ -38,6 +38,14 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.RequestTimeoutSeconds")]
public int RequestTimeoutSeconds { get; set; }
// ── Z.ai GLM-OCR ──────────────────────────────────────────────────────────────
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ZaiApiKey")]
public string ZaiApiKey { get; set; } = string.Empty;
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ZaiModel")]
public string ZaiModel { get; set; } = "glm-ocr";
}
}

View File

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

View File

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

View File

@ -0,0 +1,59 @@
using FruitBank.Common.Enums;
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models;
public class PreorderListRow
{
public int PreorderId { get; set; }
public int CustomerId { get; set; }
public string CustomerName { get; set; } = string.Empty;
public string CustomerEmail { get; set; } = string.Empty;
public string DateOfReceipt { get; set; } = string.Empty; // formatted
public string CreatedOnUtc { get; set; } = string.Empty; // formatted
public PreorderStatus Status { get; set; }
public string StatusLabel { get; set; } = string.Empty;
public int ItemCount { get; set; }
public int FulfilledCount { get; set; }
public int? OrderId { get; set; } // linked real order, if created
}
public class PreorderDetailModel
{
public int PreorderId { get; set; }
public int CustomerId { get; set; }
public string CustomerName { get; set; } = string.Empty;
public string CustomerEmail { get; set; } = string.Empty;
public string DateOfReceipt { get; set; } = string.Empty;
public string CreatedOnUtc { get; set; } = string.Empty;
public string UpdatedOnUtc { get; set; } = string.Empty;
public PreorderStatus Status { get; set; }
public string? CustomerNote { get; set; }
public int? OrderId { get; set; }
public List<PreorderDetailItemRow> Items { get; set; } = new();
}
public class PreorderDetailItemRow
{
public int ItemId { get; set; }
public int ProductId { get; set; }
public string ProductName { get; set; } = string.Empty;
public bool IsMeasurable { get; set; }
public int RequestedQuantity { get; set; }
public int FulfilledQuantity { get; set; }
public decimal UnitPriceInclTax { get; set; }
public PreorderItemStatus Status { get; set; }
public string StatusLabel { get; set; } = string.Empty;
}
public class PreorderDemandRow
{
public int ProductId { get; set; }
public string ProductName { get; set; } = string.Empty;
public string? Sku { get; set; }
public bool IsMeasurable { get; set; }
public int TotalRequested { get; set; } // sum of RequestedQuantity
public int TotalFulfilled { get; set; } // sum of FulfilledQuantity
public int TotalUnfulfilled { get; set; } // TotalRequested - TotalFulfilled
public int PreorderCount { get; set; } // distinct preorders containing this product
public decimal AvgUnitPrice { get; set; } // average snapshot price
}

View File

@ -0,0 +1,11 @@
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models;
public class PreorderAvailabilityRow
{
public int ProductId { get; set; }
public string ProductName { get; set; } = string.Empty;
public string? Sku { get; set; }
public string? WindowStart { get; set; } // ISO date string "yyyy-MM-dd" or null
public string? WindowEnd { get; set; } // ISO date string "yyyy-MM-dd" or null
public bool IsAvailableToday { get; set; }
}

View File

@ -23,40 +23,42 @@
<label asp-for="ApiKey"></label>
<input asp-for="ApiKey" class="form-control" type="password" placeholder="Adja meg az AI API kulcsot" />
<span asp-validation-for="ApiKey" class="text-danger"></span>
<small class="form-text text-muted">A Cerebras API kulcs</small>
</div>
<div class="form-group">
<label asp-for="ModelName"></label>
<input asp-for="ModelName" class="form-control" placeholder="pl. gpt-3.5-turbo, gpt-4" />
<span asp-validation-for="ModelName" class="text-danger"></span>
<small class="form-text text-muted">Az AI modell neve (pl. gpt-3.5-turbo, gpt-4, claude-3-sonnet)</small>
<small class="form-text text-muted">A Cerebras AI modell neve (pl. gpt-3.5-turbo, gpt-4, claude-3-sonnet)</small>
</div>
<div class="form-group">
<label asp-for="OpenAIApiKey"></label>
<input asp-for="OpenAIApiKey" class="form-control" type="password" placeholder="Adja meg az OpenAI API kulcsot" />
<span asp-validation-for="OpenAIApiKey" class="text-danger"></span>
<small class="form-text text-muted">Az OpenAI API kulcs</small>
</div>
<div class="form-group">
<label asp-for="OpenAIModelName"></label>
<input asp-for="OpenAIModelName" class="form-control" placeholder="pl. gpt-3.5-turbo, gpt-4" />
<span asp-validation-for="OpenAIModelName" class="text-danger"></span>
<small class="form-text text-muted">Az AI modell neve (pl. gpt-3.5-turbo, gpt-4)</small>
<small class="form-text text-muted">Az OpenAI AI modell neve (pl. gpt-3.5-turbo, gpt-4)</small>
</div>
<div class="form-group">
<label asp-for="ApiBaseUrl"></label>
<input asp-for="ApiBaseUrl" class="form-control" placeholder="https://api.openai.com/v1" />
<span asp-validation-for="ApiBaseUrl" class="text-danger"></span>
<small class="form-text text-muted">Az API alapcíme (OpenAI, Azure OpenAI, stb.)</small>
<small class="form-text text-muted">A Cerebras API alapcíme (OpenAI, Azure OpenAI, stb.)</small>
</div>
<div class="form-group">
<label asp-for="OpenAIApiBaseUrl"></label>
<input asp-for="OpenAIApiBaseUrl" class="form-control" placeholder="https://api.openai.com/v1" />
<span asp-validation-for="OpenAIApiBaseUrl" class="text-danger"></span>
<small class="form-text text-muted">Az API alapcíme (OpenAI, Azure OpenAI, stb.)</small>
<small class="form-text text-muted">Az OpenAI API alapcíme (OpenAI, Azure OpenAI, stb.)</small>
</div>
<div class="row">
@ -88,6 +90,28 @@
</div>
</div>
<hr class="my-4" />
<h5 class="mb-3"><i class="fas fa-file-alt me-2"></i>Z.ai GLM-OCR — Dokumentumfeldolgozás</h5>
<p class="text-muted small mb-3">
A GLM-OCR multimodális modell szállítólevelek és rendelési dokumentumok (kép, PDF) strukturált szövegkinyerésére.
Táblázatokat HTML formátumban ad vissza, amit közvetlenül LLM promptba lehet illeszteni.
API kulcs igénylése: <a href="https://bigmodel.cn" target="_blank">bigmodel.cn</a> — ingyenes tier elérhető.
</p>
<div class="form-group">
<label asp-for="ZaiApiKey"></label>
<input asp-for="ZaiApiKey" class="form-control" type="password" placeholder="Adja meg a Z.ai API kulcsot" />
<span asp-validation-for="ZaiApiKey" class="text-danger"></span>
<small class="form-text text-muted">Z.ai API kulcs (bigmodel.cn). Üres hagyva a GLM-OCR funkció nem érhető el.</small>
</div>
<div class="form-group">
<label asp-for="ZaiModel"></label>
<input asp-for="ZaiModel" class="form-control" placeholder="glm-ocr" />
<span asp-validation-for="ZaiModel" class="text-danger"></span>
<small class="form-text text-muted">GLM-OCR modell neve. Alapesetben: <code>glm-ocr</code></small>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Mentés

View File

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

View File

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

View File

@ -41,17 +41,15 @@
@await Html.PartialAsync("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Components/_WelcomeMessage.cshtml")
</div>
</div>
}
@await Component.InvokeAsync(typeof(AdminWidgetViewComponent), new { widgetZone = AdminWidgetZones.DashboardNewsAfter, additionalData = Model })
@if (!Model.IsLoggedInAsVendor && canManageOrders && canManageCustomers && canManageProducts && canManageReturnRequests)
{
<div class="row">
<div class="col-md-12">
@await Component.InvokeAsync(typeof(CommonStatisticsViewComponent))
@await Html.PartialAsync("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Components/_FruitBankDashboard.cshtml")
</div>
</div>
}
@await Component.InvokeAsync(typeof(AdminWidgetViewComponent), new { widgetZone = AdminWidgetZones.DashboardNewsAfter, additionalData = Model })
@* CommonStatisticsViewComponent removed — runs GetLowStockProductsAsync full table scan, causes SQL timeout *@
@await Component.InvokeAsync(typeof(AdminWidgetViewComponent), new { widgetZone = AdminWidgetZones.DashboardCommonstatisticsAfter, additionalData = Model })
@if (!Model.IsLoggedInAsVendor && (canManageOrders || canManageCustomers))
{
@ -83,35 +81,16 @@
</div>
}
@await Component.InvokeAsync(typeof(AdminWidgetViewComponent), new { widgetZone = AdminWidgetZones.DashboardOrderreportsAfter, additionalData = Model })
@if (!Model.IsLoggedInAsVendor && (canManageOrders || canManageProducts))
@if (!Model.IsLoggedInAsVendor && canManageOrders)
{
<div class="row">
@if (canManageOrders)
{
<div class="col-md-8">
@await Html.PartialAsync("~/Areas/Admin/Views/Home/_LatestOrders.cshtml")
</div>
}
<div class="col-md-4">
@if (canManageProducts)
{
@await Html.PartialAsync("~/Areas/Admin/Views/Home/_PopularSearchTermsReport.cshtml")
}
<div class="col-md-12">
@await Html.PartialAsync("~/Areas/Admin/Views/Home/_LatestOrders.cshtml")
</div>
</div>
}
@await Component.InvokeAsync(typeof(AdminWidgetViewComponent), new { widgetZone = AdminWidgetZones.DashboardLatestordersSearchtermsAfter, additionalData = Model })
@if (canManageOrders)
{
<div class="row">
<div class="col-md-6">
@await Html.PartialAsync("~/Areas/Admin/Views/Home/_BestsellersBriefReportByQuantity.cshtml")
</div>
<div class="col-md-6">
@await Html.PartialAsync("~/Areas/Admin/Views/Home/_BestsellersBriefReportByAmount.cshtml")
</div>
</div>
}
@* PopularSearchTermsReport and BestsellersBriefReports removed — not relevant to FruitBank warehouse workflow *@
@await Component.InvokeAsync(typeof(AdminWidgetViewComponent), new { widgetZone = AdminWidgetZones.DashboardBottom, additionalData = Model })
</div>
</div>

View File

@ -29,7 +29,7 @@
@T("Admin.Orders.EditOrderDetails") - @Model.CustomOrderNumber
<small>
<i class="fas fa-arrow-circle-left"></i>
<a asp-action="List">@T("Admin.Orders.BackToList")</a>
<a asp-controller="CustomOrder" asp-action="NewList">@T("Admin.Orders.BackToList")</a>
</small>
</h1>
<div class="float-right">

View File

@ -0,0 +1,642 @@
@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">&nbsp;</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">&nbsp;</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">&nbsp;</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 table-responsive" 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érendő</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>&times;</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, maxQuantity: item.availableQuantity || 9999 });
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) {
var maxAttr = p.maxQuantity < 9999 ? ' max="' + p.maxQuantity + '"' : '';
var maxHint = p.maxQuantity < 9999 ? '<br><small class="text-muted">max: ' + p.maxQuantity + ' db</small>' : '';
$body.append(
'<tr>' +
'<td><strong>' + p.name + '</strong></td>' +
'<td><input type="number" class="form-control form-control-sm" min="1"' + maxAttr + ' value="' + p.quantity + '" data-idx="' + i + '" onchange="window._fbUpdateQty(this)">' + maxHint + '</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) {
var idx = +el.dataset.idx, val = +el.value, max = createProducts[idx].maxQuantity || 9999;
if (val > max) { val = max; el.value = max; }
if (val < 1) { val = 1; el.value = 1; }
createProducts[idx].quantity = val;
$('#create-order-products-json').val(JSON.stringify(createProducts));
};
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>

View File

@ -0,0 +1,229 @@
@model Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models.PreorderDetailModel
@using FruitBank.Common.Enums
@{
ViewBag.PageTitle = $"Előrendelés #{Model.PreorderId}";
Layout = "~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/_FruitBankAdminLayout.cshtml";
var statusClass = Model.Status switch
{
PreorderStatus.Confirmed => "po-status-confirmed",
PreorderStatus.PartiallyFulfilled => "po-status-partial",
PreorderStatus.Cancelled => "po-status-cancelled",
_ => "po-status-pending"
};
var statusLabel = Model.Status switch
{
PreorderStatus.Confirmed => "Megerősítve",
PreorderStatus.PartiallyFulfilled => "Részben teljesítve",
PreorderStatus.Cancelled => "Törölve",
_ => "Függőben"
};
}
@Html.AntiForgeryToken()
<style>
.po-status-pending { background:#fff3cd; color:#856404; border-radius:6px; padding:4px 12px; font-weight:700; display:inline-block; }
.po-status-confirmed { background:#d4edda; color:#155724; border-radius:6px; padding:4px 12px; font-weight:700; display:inline-block; }
.po-status-partial { background:#fff8ee; color:#c87500; border-radius:6px; padding:4px 12px; font-weight:700; display:inline-block; }
.po-status-cancelled { background:#f8d7da; color:#721c24; border-radius:6px; padding:4px 12px; font-weight:700; display:inline-block; }
.po-meta-card { background:#fff; border:1px solid #dde8da; border-radius:8px; padding:16px 20px; margin-bottom:20px; }
.po-meta-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(200px,1fr)); gap:16px; }
.po-meta-item .label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.5px; color:#6b7c6e; margin-bottom:4px; }
.po-meta-item .value { font-size:15px; color:#1a3c22; font-weight:600; }
.item-fulfilled { background:#eaf7ee; }
.item-partial { background:#fffbf0; }
.item-dropped { background:#fdf0f0; color:#888; }
.item-pending { }
.qty-bar-wrap { width:100px; display:inline-block; vertical-align:middle; }
.qty-bar { height:6px; background:#dde8da; border-radius:3px; overflow:hidden; display:inline-block; width:100%; }
.qty-bar-fill { height:100%; border-radius:3px; }
</style>
<!-- Back link -->
<a href="/Admin/Preorders" class="btn btn-default btn-sm mb-3">
<i class="fas fa-arrow-left"></i> Vissza a listához
</a>
<div class="content-header clearfix">
<h1 class="float-left">
<i class="fas fa-calendar-plus" style="color:#2d7a3a;"></i>
Előrendelés <strong>#@Model.PreorderId</strong>
<span class="@statusClass ml-2">@statusLabel</span>
</h1>
<div class="float-right">
@if (Model.OrderId.HasValue)
{
<a href="/Admin/Order/Edit/@Model.OrderId" class="btn btn-success btn-sm" target="_blank">
<i class="fas fa-external-link-alt"></i> Rendelés #@Model.OrderId
</a>
}
@if (Model.Status == PreorderStatus.Pending)
{
<button id="cancelBtn" class="btn btn-danger btn-sm ml-2">
<i class="fas fa-times"></i> Visszavonás
</button>
}
</div>
</div>
<section class="content">
<div class="container-fluid">
<!-- ── Meta cards ──────────────────────────────────────────────────── -->
<div class="po-meta-card">
<div class="po-meta-grid">
<div class="po-meta-item">
<div class="label">Ügyfél</div>
<div class="value">
<a href="/Admin/Customer/Edit/@Model.CustomerId">@Model.CustomerName</a>
</div>
<small class="text-muted">@Model.CustomerEmail</small>
</div>
<div class="po-meta-item">
<div class="label">Kért szállítási időpont</div>
<div class="value"><i class="fas fa-calendar-day text-muted mr-1"></i>@Model.DateOfReceipt</div>
</div>
<div class="po-meta-item">
<div class="label">Leadva</div>
<div class="value">@Model.CreatedOnUtc</div>
</div>
<div class="po-meta-item">
<div class="label">Utoljára frissítve</div>
<div class="value">@Model.UpdatedOnUtc</div>
</div>
@if (!string.IsNullOrWhiteSpace(Model.CustomerNote))
{
<div class="po-meta-item" style="grid-column:1/-1;">
<div class="label">Ügyfél megjegyzése</div>
<div class="value" style="font-weight:400;font-size:14px;color:#444;">@Model.CustomerNote</div>
</div>
}
</div>
</div>
<!-- ── Items table ─────────────────────────────────────────────────── -->
<div class="card card-default">
<div class="card-header">
<strong>Tételek (@Model.Items.Count)</strong>
@{
var fulfilled = Model.Items.Count(i => i.Status == PreorderItemStatus.Fulfilled);
var partial = Model.Items.Count(i => i.Status == PreorderItemStatus.PartiallyFulfilled);
var dropped = Model.Items.Count(i => i.Status == PreorderItemStatus.Dropped);
var pending = Model.Items.Count(i => i.Status == PreorderItemStatus.Pending);
}
<span class="ml-2 text-muted" style="font-size:13px;">
@if (fulfilled > 0) { <span class="badge badge-success">@fulfilled teljesítve</span> }
@if (partial > 0) { <span class="badge badge-warning ml-1">@partial részben</span> }
@if (dropped > 0) { <span class="badge badge-danger ml-1">@dropped ejtve</span> }
@if (pending > 0) { <span class="badge badge-secondary ml-1">@pending függőben</span> }
</span>
</div>
<div class="card-body p-0">
<table class="table table-bordered table-hover table-sm m-0">
<thead>
<tr>
<th>Termék</th>
<th width="80" class="text-center">Kérve</th>
<th width="80" class="text-center">Teljesítve</th>
<th width="160">Teljesítés</th>
<th width="130" class="text-right">Egységár</th>
<th width="130" class="text-right">Becsült ár</th>
<th width="110" class="text-center">Állapot</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.Items)
{
var rowClass = item.Status switch
{
PreorderItemStatus.Fulfilled => "item-fulfilled",
PreorderItemStatus.PartiallyFulfilled => "item-partial",
PreorderItemStatus.Dropped => "item-dropped",
_ => "item-pending"
};
var pct = item.RequestedQuantity > 0
? (int)Math.Round((double)item.FulfilledQuantity / item.RequestedQuantity * 100)
: 0;
var barColor = pct == 100 ? "#2d7a3a" : pct > 0 ? "#f4a236" : "#dc3545";
var estimatedPrice = item.IsMeasurable
? "—"
: (item.UnitPriceInclTax * item.FulfilledQuantity).ToString("N0") + " Ft";
var unitPrice = item.IsMeasurable ? "súlymérés" : item.UnitPriceInclTax.ToString("N0") + " Ft";
<tr class="@rowClass">
<td>
<a href="/Admin/Product/Edit/@item.ProductId" target="_blank">@item.ProductName</a>
@if (item.IsMeasurable)
{
<span class="badge badge-light ml-1" title="Súlymérést igényel">⚖️</span>
}
</td>
<td class="text-center">@item.RequestedQuantity db</td>
<td class="text-center">
<strong>@item.FulfilledQuantity db</strong>
</td>
<td>
<div class="qty-bar-wrap">
<div class="qty-bar">
<div class="qty-bar-fill" style="width:@pct%;background:@barColor;"></div>
</div>
</div>
<small class="ml-1">@pct%</small>
</td>
<td class="text-right">@unitPrice</td>
<td class="text-right">@estimatedPrice</td>
<td class="text-center">
<span class="po-status-@item.Status.ToString().ToLower()" style="font-size:11px;padding:2px 6px;">
@item.StatusLabel
</span>
</td>
</tr>
}
</tbody>
@{
var totalEstimated = Model.Items
.Where(i => !i.IsMeasurable && (i.Status == PreorderItemStatus.Fulfilled || i.Status == PreorderItemStatus.PartiallyFulfilled))
.Sum(i => i.UnitPriceInclTax * i.FulfilledQuantity);
}
<tfoot>
<tr>
<td colspan="5" class="text-right"><strong>Becsült összeg:</strong></td>
<td class="text-right"><strong>@totalEstimated.ToString("N0") Ft</strong></td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</section>
@if (Model.Status == PreorderStatus.Pending)
{
<script>
$(function () {
$('#cancelBtn').click(function () {
if (!confirm('Biztosan visszavonod ezt az előrendelést? Ez a művelet nem visszafordítható.')) return;
$.ajax({
url : '/Admin/Preorders/Cancel/@Model.PreorderId',
type : 'POST',
data : { __RequestVerificationToken: $('input[name="__RequestVerificationToken"]').val() },
success: function (res) {
if (res.success) {
location.href = '/Admin/Preorders';
} else {
alert('Hiba: ' + (res.error || 'Ismeretlen hiba'));
}
}
});
});
});
</script>
}

View File

@ -0,0 +1,514 @@
@{
ViewBag.PageTitle = "Előrendelések";
NopHtml.SetActiveMenuItemSystemName("Preorders.List");
Layout = "~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/_FruitBankAdminLayout.cshtml";
}
@Html.AntiForgeryToken()
<div class="content-header clearfix">
<h1 class="float-left">
<i class="fas fa-calendar-plus" style="color:#2d7a3a;"></i>
Előrendelések
</h1>
<div class="float-right">
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#create-preorder-window">
<i class="fas fa-plus"></i> Rendelés / Előrendelés rögzítése
</button>
</div>
</div>
<section class="content">
<div class="container-fluid">
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="tab-list-link" data-toggle="tab" href="#tab-list" role="tab">
<i class="fas fa-list"></i> Előrendelések
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-demand-link" data-toggle="tab" href="#tab-demand" role="tab">
<i class="fas fa-chart-bar"></i> Kereslet
<span id="demandBadge" class="badge badge-warning ml-1" style="display:none;"></span>
</a>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="tab-list" role="tabpanel">
<div class="card card-default mb-3">
<div class="card-body py-2">
<div class="d-flex align-items-center" style="gap:8px; flex-wrap:wrap;">
<span class="text-muted" style="font-size:13px;">Szűrő:</span>
<button class="btn btn-sm btn-outline-secondary po-filter active" data-status="">Összes</button>
<button class="btn btn-sm btn-outline-warning po-filter" data-status="0">Függőben</button>
<button class="btn btn-sm btn-outline-success po-filter" data-status="10">Megerősítve</button>
<button class="btn btn-sm po-filter" style="border-color:#f4a236;color:#f4a236;" data-status="20">Részben teljesítve</button>
<button class="btn btn-sm btn-outline-danger po-filter" data-status="30">Törölve</button>
</div>
</div>
</div>
<div class="card card-default">
<div class="card-body p-0">
<table id="po-grid" class="table table-bordered table-hover table-sm m-0" style="width:100%">
<thead>
<tr>
<th width="60">#</th>
<th>Ügyfél</th>
<th width="180" name="DateOfReceipt">Kért szállítás</th>
<th width="160" name="CreatedOnUtc">Leadva</th>
<th width="120" name="Status">Állapot</th>
<th width="100" class="text-center">Tételek</th>
<th width="70" class="text-center"></th>
</tr>
</thead>
</table>
</div>
</div>
</div>
<div class="tab-pane fade" id="tab-demand" role="tabpanel">
<div class="card card-default mb-3">
<div class="card-body py-2">
<div class="d-flex align-items-center" style="gap:8px;">
<span class="text-muted" style="font-size:13px;">Nézet:</span>
<button class="btn btn-sm btn-warning demand-scope active" data-open="true">
<i class="fas fa-clock"></i> Nyitott előrendelések
</button>
<button class="btn btn-sm btn-outline-secondary demand-scope" data-open="false">
<i class="fas fa-history"></i> Összes idő
</button>
<small class="text-muted ml-3" id="demandScopeLabel">Termékek amelyekre még van teljesítetlen igény</small>
</div>
</div>
</div>
<div class="card card-default">
<div class="card-body p-0">
<table id="demand-grid" class="table table-bordered table-hover table-sm m-0" style="width:100%">
<thead>
<tr>
<th>Termék</th>
<th width="80">SKU</th>
<th width="110" class="text-center">Igényelt</th>
<th width="110" class="text-center">Teljesített</th>
<th width="130" class="text-center">Hiány ▼</th>
<th width="90" class="text-center">Rendelések</th>
<th width="120" class="text-right">Átlagár</th>
</tr>
</thead>
</table>
</div>
</div>
</div>
</div>
</div>
</section>
<style>
.ui-autocomplete { z-index:1060 !important; max-height:220px; overflow-y:auto; overflow-x:hidden; }
.po-status-pending { background:#fff3cd; color:#856404; border-radius:4px; padding:2px 8px; font-size:12px; font-weight:600; }
.po-status-confirmed { background:#d4edda; color:#155724; border-radius:4px; padding:2px 8px; font-size:12px; font-weight:600; }
.po-status-partial { background:#fff8ee; color:#c87500; border-radius:4px; padding:2px 8px; font-size:12px; font-weight:600; }
.po-status-cancelled { background:#f8d7da; color:#721c24; border-radius:4px; padding:2px 8px; font-size:12px; font-weight:600; }
.po-filter.active { font-weight:700; }
.demand-scope.active { font-weight:700; }
.demand-unfulfilled-high { color:#dc3545; font-weight:700; }
.demand-unfulfilled-mid { color:#c87500; font-weight:600; }
.demand-unfulfilled-ok { color:#6b7c6e; }
/* ── Urgency flag ─────────────────────────────────────────── */
.po-urgent-row td { background:#fff8e1 !important; }
.po-urgent-badge {
display:inline-block; background:#dc3545; color:#fff;
border-radius:4px; padding:1px 7px; font-size:11px;
font-weight:700; margin-left:6px; vertical-align:middle;
animation: urgentPulse 1.5s ease-in-out infinite;
}
@@keyframes urgentPulse {
0%,100% { opacity:1; }
50% { opacity:.55; }
}
/* ── Modal mode banners ───────────────────────────────────── */
.cp-mode-order {
background:#d4edda; border:2px solid #28a745; border-radius:8px;
padding:14px 18px; margin-bottom:16px;
display:flex; align-items:center; gap:14px;
font-size:14px; color:#155724;
}
.cp-mode-preorder {
background:#fff8ee; border:2px solid #f4a236; border-radius:8px;
padding:14px 18px; margin-bottom:16px;
display:flex; align-items:center; gap:14px;
font-size:14px; color:#7a4200;
}
.cp-mode-icon { font-size:26px; flex-shrink:0; }
.cp-mode-title { font-size:16px; font-weight:800; display:block; margin-bottom:3px; }
.cp-mode-desc { font-size:12px; line-height:1.5; opacity:.9; }
</style>
<script>
$(function () {
var _token = $('input[name="__RequestVerificationToken"]').val();
var activeStatus = '';
var demandOpenOnly = true;
function statusBadge(row) {
switch (row.Status) {
case 0: return '<span class="po-status-pending">' + row.StatusLabel + '</span>';
case 10: return '<span class="po-status-confirmed">' + row.StatusLabel + '</span>';
case 20: return '<span class="po-status-partial">' + row.StatusLabel + '</span>';
case 30: return '<span class="po-status-cancelled">' + row.StatusLabel + '</span>';
default: return row.StatusLabel;
}
}
function itemProgress(row) {
var total = row.ItemCount, done = row.FulfilledCount;
if (!total) return '—';
var pct = Math.round(done / total * 100);
var cls = pct === 100 ? 'bg-success' : pct > 0 ? 'bg-warning' : 'bg-danger';
return '<div style="min-width:80px"><div class="progress" style="height:6px;margin-bottom:3px;">' +
'<div class="progress-bar ' + cls + '" style="width:' + pct + '%"></div></div>' +
'<small>' + done + '/' + total + ' tétel</small></div>';
}
function fmtQty(n) { return n.toLocaleString('hu-HU') + ' db'; }
function fmtPrice(n) { return n > 0 ? Math.round(n).toLocaleString('hu-HU') + ' Ft' : '—'; }
function unfulfilledCell(n) {
var cls = n > 100 ? 'demand-unfulfilled-high' : n > 20 ? 'demand-unfulfilled-mid' : 'demand-unfulfilled-ok';
return '<span class="' + cls + '">' + fmtQty(n) + '</span>';
}
// Urgency: not fully fulfilled + DateOfReceipt within 4 days
function isUrgentRow(row) {
if (row.Status === 30) return false;
if (row.ItemCount > 0 && row.FulfilledCount >= row.ItemCount) return false;
if (!row.DateOfReceipt) return false;
var m = row.DateOfReceipt.match(/(\d{4})\.(\d{2})\.(\d{2})/);
if (!m) return false;
var delivery = new Date(+m[1], +m[2]-1, +m[3]);
var today = new Date(); today.setHours(0,0,0,0);
var diff = Math.ceil((delivery - today) / 86400000);
return diff >= 0 && diff <= 4;
}
// ── Preorder list grid ──────────────────────────────────────────────────
var poTable = $('#po-grid').DataTable({
serverSide: true, processing: true, pageLength: 25,
lengthMenu: [[25,50,100],[25,50,100]], order: [[3,'desc']],
language: { processing:'Betöltés...', search:'Keresés:', lengthMenu:'_MENU_ sor/oldal',
info:'_START__END_ / _TOTAL_ előrendelés', infoEmpty:'0 előrendelés',
infoFiltered:'(szűrve _MAX_-ból)', emptyTable:'Nincs előrendelés', zeroRecords:'Nincs találat',
paginate:{first:'««',previous:'«',next:'»',last:'»»'} },
ajax: { url:'/Admin/Preorders/PreorderList', type:'POST',
data: function(d){ d.__RequestVerificationToken=_token; d.statusFilter=activeStatus; } },
createdRow: function(row, data) {
if (isUrgentRow(data)) $(row).addClass('po-urgent-row');
},
columns: [
{ data:'PreorderId', name:'PreorderId', render:function(d){ return '<strong>#'+d+'</strong>'; } },
{ data:'CustomerName', name:'CustomerName', render:function(d,t,row){ return '<div>'+d+'</div><small class="text-muted">'+row.CustomerEmail+'</small>'; } },
{ data:'DateOfReceipt',name:'DateOfReceipt',render:function(d,t,row){
var icon = '<i class="fas fa-calendar-day text-muted mr-1"></i>';
var urgent = isUrgentRow(row) ? '<span class="po-urgent-badge">⚠ Azonnali figyelmet igényel</span>' : '';
return icon + d + urgent;
} },
{ data:'CreatedOnUtc', name:'CreatedOnUtc', render:function(d){ return '<small>'+d+'</small>'; } },
{ data:'Status', name:'Status', orderable:false, render:function(d,t,row){ return statusBadge(row); } },
{ data:'ItemCount', orderable:false, className:'text-center', render:function(d,t,row){ return itemProgress(row); } },
{ data:'PreorderId', orderable:false, searchable:false, className:'text-center', width:'60px',
render:function(d){ return '<a href="/Admin/Preorders/Detail/'+d+'" class="btn btn-xs btn-default" title="Részletek"><i class="fas fa-eye"></i></a>'; } }
]
});
$(document).on('click','.po-filter',function(){
$('.po-filter').removeClass('active'); $(this).addClass('active');
activeStatus = $(this).data('status').toString(); poTable.ajax.reload();
});
// ── Demand grid ─────────────────────────────────────────────────────────
var demandTable = $('#demand-grid').DataTable({
serverSide:true, processing:true, pageLength:50,
lengthMenu:[[25,50,100,250],[25,50,100,250]], order:[[4,'desc']],
language:{ processing:'Betöltés...', search:'Keresés:', lengthMenu:'_MENU_ sor/oldal',
info:'_START__END_ / _TOTAL_ termék', infoEmpty:'Nincs adat',
infoFiltered:'(szűrve _MAX_-ból)', emptyTable:'Nincs előrendelési igény', zeroRecords:'Nincs találat',
paginate:{first:'««',previous:'«',next:'»',last:'»»'} },
ajax:{ url:'/Admin/Preorders/DemandList', type:'POST',
data:function(d){ d.__RequestVerificationToken=_token; d.openOnly=demandOpenOnly?'true':'false'; },
dataSrc:function(json){
var n=(json.data||[]).filter(function(r){return r.TotalUnfulfilled>0;}).length;
n>0?$('#demandBadge').text(n).show():$('#demandBadge').hide();
return json.data;
} },
columns:[
{ data:'ProductName', name:'ProductName',
render:function(d,t,row){ var b=row.IsMeasurable?' <span class="badge badge-light" title="Súlymérést igényel">⚖️</span>':'';
return '<a href="/Admin/Product/Edit/'+row.ProductId+'" target="_blank">'+d+'</a>'+b; } },
{ data:'Sku', orderable:false, render:function(d){ return d?'<code>'+d+'</code>':''; } },
{ data:'TotalRequested', orderable:false, className:'text-center', render:function(d){ return fmtQty(d); } },
{ data:'TotalFulfilled', orderable:false, className:'text-center',
render:function(d,t,row){ var pct=row.TotalRequested>0?Math.round(d/row.TotalRequested*100):0;
var cls=pct===100?'bg-success':pct>0?'bg-warning':'bg-secondary';
return '<div>'+fmtQty(d)+'</div><div class="progress mt-1" style="height:4px;"><div class="progress-bar '+cls+'" style="width:'+pct+'%"></div></div>'; } },
{ data:'TotalUnfulfilled',orderable:false, className:'text-center', render:function(d){ return unfulfilledCell(d); } },
{ data:'PreorderCount', orderable:false, className:'text-center', render:function(d){ return '<span class="badge badge-secondary">'+d+'</span>'; } },
{ data:'AvgUnitPrice', orderable:false, className:'text-right', render:function(d){ return fmtPrice(d); } }
]
});
var demandLoaded = false;
$('#tab-demand-link').on('shown.bs.tab',function(){
if (!demandLoaded){ demandTable.ajax.reload(); demandLoaded=true; } else demandTable.ajax.reload();
});
$(document).on('click','.demand-scope',function(){
$('.demand-scope').removeClass('active'); $(this).addClass('active');
demandOpenOnly = $(this).data('open')===true;
$('#demandScopeLabel').text(demandOpenOnly
?'Termékek amelyekre még van teljesítetlen igény'
:'Összesített kereslet az összes előrendelésből');
demandTable.ajax.reload();
});
// ── Create Order / Preorder Modal (mode-aware) ──────────────────────────
var cpProducts = [];
var cpMode = null;
var CP_CUTOFF = 4; // ≤4 days → order, >4 days → preorder
function cpComputeMode(s) {
if (!s) return null;
var d=new Date(s), t=new Date(); t.setHours(0,0,0,0);
return Math.ceil((d-t)/86400000) <= CP_CUTOFF ? 'order' : 'preorder';
}
function cpApplyMode(mode) {
cpMode = mode;
var isOrder = mode==='order';
$('.modal-header','#create-preorder-window').css('background', isOrder ? '#155724' : '#2d7a3a');
if (isOrder) {
$('#cp-mode-banner').attr('class','cp-mode-order')
.html('<div class="cp-mode-icon">🛒</div><div>' +
'<span class="cp-mode-title">RENDELÉS</span>' +
'<span class="cp-mode-desc">4 napon belül — csak raktáron lévő termékek. Rendelés azonnal létrejön.</span></div>').show();
$('#cp-product-search-hint').text('Csak az elérhető készlettel rendelkező termékek');
$('#cp-submit-btn').html('<i class="fas fa-shopping-cart"></i> Rendelés létrehozása')
.removeClass('btn-primary').addClass('btn-success');
} else {
$('#cp-mode-banner').attr('class','cp-mode-preorder')
.html('<div class="cp-mode-icon">📋</div><div>' +
'<span class="cp-mode-title">ELŐRENDELÉS</span>' +
'<span class="cp-mode-desc">Több mint 4 nap — előrendelési ablak termékei. A szállítmány után automatikusan rendeléssé alakul.</span></div>').show();
$('#cp-product-search-hint').text('Előrendelési ablakban elérhető termékek');
$('#cp-submit-btn').html('<i class="fas fa-calendar-plus"></i> Előrendelés rögzítése')
.removeClass('btn-success').addClass('btn-primary');
}
if ($('#cp-customer-id').val()) $('#cp-product-search-section').slideDown();
}
$('#cp-delivery').on('change input', function () {
var nm = cpComputeMode($(this).val());
if (nm !== cpMode) {
if (cpProducts.length) { cpProducts=[]; renderCpProducts(); $('#cp-product-search-section').hide(); }
if (nm) cpApplyMode(nm); else { $('#cp-mode-banner').hide(); cpMode=null; }
}
});
$('#cp-customer-search').autocomplete({
delay:400, minLength:2,
source:'/Admin/CustomOrder/CustomerSearchAutoComplete',
select:function(e,ui){
$('#cp-customer-id').val(ui.item.value);
$('#cp-customer-name').html('<strong>'+ui.item.label+'</strong>');
$('#cp-customer-search').val('');
$('#cp-customer-error').hide();
if (cpMode) $('#cp-product-search-section').slideDown();
return false;
}
});
$('#cp-product-search').autocomplete({
delay:400, minLength:2,
source:function(req,resp){
if (!cpMode){ resp([]); return; }
$.get(cpMode==='order'
?'/Admin/CustomOrder/ProductSearchAutoComplete'
:'/Admin/CustomOrder/PreorderProductSearchAutoComplete',
{term:req.term}, resp);
},
select:function(e,ui){ addCpProduct(ui.item); $('#cp-product-search').val(''); return false; }
});
function addCpProduct(item) {
if (cpProducts.find(function(p){ return p.id===item.value; })) return;
cpProducts.push({
id: item.value,
name: item.label,
sku: item.sku || '',
quantity: 1,
price: item.price || 0,
maxQuantity: item.availableQuantity || 9999
});
renderCpProducts();
}
function renderCpProducts() {
var $b=$('#cp-products-body').empty();
if (!cpProducts.length){
$('#cp-products-section').hide();
$('#cp-submit-btn').prop('disabled', true);
return;
}
$('#cp-products-section').show();
$('#cp-submit-btn').prop('disabled', false);
cpProducts.forEach(function(p,i){
var maxAttr = p.maxQuantity < 9999 ? ' max="'+p.maxQuantity+'"' : '';
var maxHint = p.maxQuantity < 9999 ? '<br><small class="text-muted">max: '+p.maxQuantity+' db</small>' : '';
$b.append('<tr>' +
'<td><strong>'+p.name+'</strong>'+(p.sku?'<br><small class="text-muted">'+p.sku+'</small>':'')+'</td>'+
'<td><input type="number" class="form-control form-control-sm" min="1"'+maxAttr+' value="'+p.quantity+'" data-idx="'+i+'" onchange="window._cpUpdateQty(this)">'+maxHint+'</td>'+
'<td><input type="text" class="form-control form-control-sm" value="'+p.price+'" data-idx="'+i+'" onchange="window._cpUpdatePrice(this)"></td>'+
'<td class="text-center"><button type="button" class="btn btn-danger btn-xs" onclick="window._cpRemove('+i+')"><i class="fas fa-trash"></i></button></td>'+
'</tr>');
});
$('#cp-products-json').val(JSON.stringify(cpProducts));
}
window._cpUpdateQty = function(el) {
var idx=+el.dataset.idx, val=+el.value, max=cpProducts[idx].maxQuantity||9999;
if (val>max){val=max;el.value=max;} if (val<1){val=1;el.value=1;}
cpProducts[idx].quantity=val; $('#cp-products-json').val(JSON.stringify(cpProducts));
};
window._cpUpdatePrice = function(el){ cpProducts[+el.dataset.idx].price=+el.value; $('#cp-products-json').val(JSON.stringify(cpProducts)); };
window._cpRemove = function(i) { cpProducts.splice(i,1); renderCpProducts(); };
$('#cp-form').on('submit', function(e){
e.preventDefault();
if (!cpMode) { alert('Válassz szállítási időpontot!'); return; }
if (!$('#cp-customer-id').val()) { $('#cp-customer-error').show(); return; }
if (!cpProducts.length) { alert('Legalább egy terméket adj hozzá!'); return; }
var $btn=$('#cp-submit-btn').prop('disabled',true).html('<i class="fas fa-spinner fa-spin"></i> Mentés...');
if (cpMode==='order') {
$.post('/Admin/CustomOrder/AdminQuickCreateOrder',{
customerId: $('#cp-customer-id').val(),
orderProductsJson:$('#cp-products-json').val(),
deliveryDateTime: $('#cp-delivery').val(),
__RequestVerificationToken:_token
},function(r){
if (r&&(r.success||r.orderId)){
$('#create-preorder-window').modal('hide');
alert('Rendelés létrehozva: #'+(r.orderId||r.id||''));
} else {
alert('Hiba: '+((r&&(r.error||r.message))||'Ismeretlen hiba'));
$btn.prop('disabled',false).html('<i class="fas fa-shopping-cart"></i> Rendelés létrehozása');
}
});
} else {
$.ajax({url:'/Admin/Preorders/CreatePreorder', type:'POST',
data:{ customerId:$('#cp-customer-id').val(), deliveryDateTime:$('#cp-delivery').val(),
customerNote:$('#cp-note').val().trim(), productsJson:$('#cp-products-json').val(),
__RequestVerificationToken:_token },
success:function(r){
if (r.success){
$('#create-preorder-window').modal('hide');
poTable.ajax.reload(); demandTable.ajax.reload();
alert('Előrendelés rögzítve (#'+r.preorderId+').'+
(r.orderId?' Rendelés is készült: #'+r.orderId:''));
} else {
alert('Hiba: '+(r.error||'Ismeretlen hiba'));
$btn.prop('disabled',false).html('<i class="fas fa-calendar-plus"></i> Előrendelés rögzítése');
}
},
error:function(){ $btn.prop('disabled',false).html('<i class="fas fa-calendar-plus"></i> Előrendelés rögzítése'); }
});
}
});
$('#create-preorder-window').on('hidden.bs.modal', function(){
$('#cp-customer-search').val(''); $('#cp-customer-id').val(''); $('#cp-customer-name').html('');
$('#cp-customer-error').hide(); $('#cp-mode-banner').hide();
$('#cp-product-search-section,#cp-products-section').hide();
$('#cp-delivery,#cp-note').val('');
cpProducts=[]; cpMode=null; renderCpProducts();
$('.modal-header','#create-preorder-window').css('background','#2d7a3a');
$('#cp-submit-btn').html('<i class="fas fa-save"></i> Mentés')
.removeClass('btn-success btn-primary').addClass('btn-secondary').prop('disabled',false);
});
});
</script>
@* ── Modal ─────────────────────────────────────────────────────────────── *@
<div id="create-preorder-window" class="modal fade" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header" style="background:#2d7a3a;color:#fff;">
<h4 class="modal-title"><i class="fas fa-receipt"></i> Rendelés / Előrendelés rögzítése</h4>
<button type="button" class="close" data-dismiss="modal" style="color:#fff;"><span>&times;</span></button>
</div>
<form id="cp-form">
<div class="modal-body">
<div class="form-group row">
<label class="col-md-3 col-form-label">Ügyfél</label>
<div class="col-md-9">
<input type="text" id="cp-customer-search" autocomplete="off" class="form-control"
placeholder="Ügyfél neve, e-mail vagy cég neve..." />
<span id="cp-customer-name" class="mt-1 d-block text-success"></span>
<input type="hidden" id="cp-customer-id" />
<span class="field-validation-error" id="cp-customer-error" style="display:none;">Kérjük válasszon ügyfelet</span>
</div>
</div>
<div class="form-group row">
<label class="col-md-3 col-form-label">Szállítási időpont</label>
<div class="col-md-9">
<input type="datetime-local" id="cp-delivery" class="form-control" />
<small class="text-muted">
≤4 nap → <strong>Rendelés</strong> &nbsp;|&nbsp; &gt;4 nap → <strong>Előrendelés</strong>
</small>
</div>
</div>
<div id="cp-mode-banner" style="display:none;"></div>
<div class="form-group row" id="cp-product-search-section" style="display:none;">
<label class="col-md-3 col-form-label">Termék hozzáadása</label>
<div class="col-md-9">
<input type="text" id="cp-product-search" autocomplete="off" class="form-control"
placeholder="Termék neve vagy SKU..." />
<small class="text-muted" id="cp-product-search-hint"></small>
</div>
</div>
<div id="cp-products-section" style="display:none;">
<table class="table table-sm table-bordered">
<thead>
<tr>
<th>Termék</th>
<th style="width:120px">Mennyiség</th>
<th style="width:120px">Egységár</th>
<th style="width:40px"></th>
</tr>
</thead>
<tbody id="cp-products-body"></tbody>
</table>
<input type="hidden" id="cp-products-json" />
</div>
<div class="form-group row">
<label class="col-md-3 col-form-label">Megjegyzés <small class="text-muted">(nem köt.)</small></label>
<div class="col-md-9">
<textarea id="cp-note" class="form-control" rows="2" maxlength="1000"
placeholder="Esetleges megjegyzés..."></textarea>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Mégse</button>
<button type="submit" id="cp-submit-btn" class="btn btn-secondary" disabled>
<i class="fas fa-save"></i> Mentés
</button>
</div>
</form>
</div>
</div>
</div>

View File

@ -0,0 +1,282 @@
@{
ViewBag.PageTitle = "Előrendelés — termékelérhetőség";
NopHtml.SetActiveMenuItemSystemName("PreorderAvailability");
Layout = "~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/_FruitBankAdminLayout.cshtml";
}
@Html.AntiForgeryToken()
<div class="content-header clearfix">
<h1 class="float-left">
<i class="fas fa-calendar-check" style="color:#2d7a3a;"></i>
Előrendelés — termékelérhetőség
</h1>
</div>
<section class="content">
<div class="container-fluid">
<!-- ── Tabs ─────────────────────────────────────────────────────── -->
<ul class="nav nav-tabs mb-3" id="paTabs" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="tab-all-link" data-toggle="tab" href="#tab-all" role="tab">
<i class="fas fa-list"></i> Összes termék
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-today-link" data-toggle="tab" href="#tab-today" role="tab">
<i class="fas fa-calendar-day"></i> Elérhető ma
<span id="todayBadge" class="badge badge-success ml-1" style="display:none;"></span>
</a>
</li>
</ul>
<div class="tab-content">
<!-- ── TAB 1: All products ──────────────────────────────────── -->
<div class="tab-pane fade show active" id="tab-all" role="tabpanel">
<div class="card card-default">
<div class="card-header">
<div class="d-flex align-items-center gap-2" style="gap:10px;">
<span class="text-muted" style="font-size:13px;">
Kattints a dátum cellákra a szerkesztéshez.
Törléshez hagyd üresen és nyomj Entert.
</span>
<button id="btnSaveAll" class="btn btn-sm btn-success ml-auto" style="display:none;">
<i class="fas fa-save"></i> Összes módosítás mentése
</button>
</div>
</div>
<div class="card-body p-0">
<table id="pa-grid" class="table table-bordered table-hover table-sm m-0" style="width:100%">
<thead>
<tr>
<th>Termék neve</th>
<th width="100">SKU</th>
<th width="160" title="Kattints a szerkesztéshez">Elérhetőség kezdete ✏️</th>
<th width="160" title="Kattints a szerkesztéshez">Elérhetőség vége ✏️</th>
<th width="110" class="text-center">Ma elérhető?</th>
</tr>
</thead>
</table>
</div>
</div>
</div>
<!-- ── TAB 2: Available today ───────────────────────────────── -->
<div class="tab-pane fade" id="tab-today" role="tabpanel">
<div class="card card-default">
<div class="card-header">
<span class="text-muted" style="font-size:13px;">
Termékek, amelyek ma meg tudják rendelni az ügyfelek előrendelésként.
</span>
</div>
<div class="card-body p-0">
<table id="today-grid" class="table table-bordered table-hover table-sm m-0" style="width:100%">
<thead>
<tr>
<th>Termék neve</th>
<th width="100">SKU</th>
<th width="160">Elérhetőség kezdete</th>
<th width="160">Elérhetőség vége</th>
</tr>
</thead>
</table>
</div>
</div>
</div>
</div><!-- /.tab-content -->
</div>
</section>
<style>
/* ── Editable date cells ──────────────────────────────────────── */
#pa-grid tbody td.pa-date {
cursor: pointer;
min-width: 130px;
}
#pa-grid tbody td.pa-date:hover {
background-color: #fff8e1;
}
#pa-grid tbody td.pa-date input[type="date"] {
width: 135px;
font-size: 13px;
padding: 2px 4px;
border: 1px solid #80bdff;
border-radius: 3px;
color: #333;
}
.pa-available-yes { color: #2d7a3a; font-weight: 600; }
.pa-available-no { color: #999; }
.pa-date-set { color: #1a3c22; }
.pa-date-empty { color: #bbb; font-style: italic; }
</style>
<script>
$(function () {
var _token = $('input[name="__RequestVerificationToken"]').val();
function fmtDate(val) {
if (!val) return '<span class="pa-date-empty">—</span>';
// "2026-04-21" → "2026. 04. 21."
var p = val.split('-');
return '<span class="pa-date-set">' + p[0] + '. ' + p[1] + '. ' + p[2] + '.</span>';
}
function fmtAvailable(row) {
return row.IsAvailableToday
? '<span class="pa-available-yes"><i class="fas fa-check-circle"></i> Igen</span>'
: '<span class="pa-available-no">—</span>';
}
// ── ALL PRODUCTS TABLE ───────────────────────────────────────────────────
var paTable = $('#pa-grid').DataTable({
serverSide : true,
processing : true,
pageLength : 50,
lengthMenu : [[25, 50, 100, 250], [25, 50, 100, 250]],
order : [[0, 'asc']],
language : {
processing : 'Betöltés...',
search : 'Keresés:',
lengthMenu : '_MENU_ sor/oldal',
info : '_START__END_ / _TOTAL_ termék',
infoEmpty : '0 termék',
infoFiltered : '(szűrve _MAX_-ból)',
paginate : { first: '««', previous: '«', next: '»', last: '»»' },
emptyTable : 'Nincs termék',
zeroRecords : 'Nincs találat'
},
ajax: {
url : '/Admin/PreorderAvailability/ProductList',
type: 'POST',
data: function (d) { d.__RequestVerificationToken = _token; }
},
columns: [
/* 0 */ { data: 'ProductName', name: 'ProductName' },
/* 1 */ { data: 'Sku', name: 'Sku', orderable: false,
render: function (d) { return d ? '<code>' + d + '</code>' : ''; } },
/* 2 */ { data: 'WindowStart', name: 'WindowStart', className: 'pa-date', orderable: false,
render: function (d) { return fmtDate(d); } },
/* 3 */ { data: 'WindowEnd', name: 'WindowEnd', className: 'pa-date', orderable: false,
render: function (d) { return fmtDate(d); } },
/* 4 */ { data: 'IsAvailableToday', orderable: false, className: 'text-center',
render: function (d, t, row) { return fmtAvailable(row); } }
]
});
// ── INLINE DATE EDITING ──────────────────────────────────────────────────
$(document).on('click', '#pa-grid tbody td.pa-date', function () {
var $td = $(this);
if ($td.find('input').length) return; // already open
var $row = $td.closest('tr');
var rowData = paTable.row($row).data();
if (!rowData) return;
var colIdx = paTable.cell($td).index().column;
var isStart = colIdx === 2;
var current = isStart ? (rowData.WindowStart || '') : (rowData.WindowEnd || '');
var savedHtml = $td.html();
var $inp = $('<input type="date">').val(current);
$td.html('').append($inp);
$inp.focus();
function restore() { $td.html(savedHtml); }
function persist() {
var newVal = $inp.val().trim(); // "yyyy-MM-dd" or ""
var oldVal = current;
if (newVal === oldVal) { restore(); return; }
// Optimistically update local row data
if (isStart) rowData.WindowStart = newVal || null;
else rowData.WindowEnd = newVal || null;
$.ajax({
url : '/Admin/PreorderAvailability/SaveWindow',
type : 'POST',
data : {
__RequestVerificationToken : _token,
productId : rowData.ProductId,
windowStart : rowData.WindowStart || '',
windowEnd : rowData.WindowEnd || ''
},
success: function (res) {
if (res.success) {
rowData.IsAvailableToday = res.isAvailableToday;
paTable.row($row).data(rowData).invalidate().draw(false);
} else {
alert('Mentési hiba: ' + (res.error || 'Ismeretlen hiba'));
restore();
}
},
error: function () { restore(); }
});
}
$inp.on('blur', function () { persist(); });
$inp.on('keydown', function (e) {
if (e.key === 'Enter') { $inp.off('blur'); persist(); }
if (e.key === 'Escape') { $inp.off('blur'); restore(); }
});
});
// ── AVAILABLE TODAY TABLE ────────────────────────────────────────────────
var todayTable = $('#today-grid').DataTable({
serverSide : true,
processing : true,
pageLength : 50,
order : [[0, 'asc']],
language : {
processing : 'Betöltés...',
search : 'Keresés:',
lengthMenu : '_MENU_ sor/oldal',
info : '_START__END_ / _TOTAL_ termék',
infoEmpty : 'Egy termék sincs ma elérhető előrendelésre.',
infoFiltered : '(szűrve _MAX_-ból)',
paginate : { first: '««', previous: '«', next: '»', last: '»»' },
emptyTable : 'Egy termék sincs ma elérhető előrendelésre.',
zeroRecords : 'Nincs találat'
},
ajax: {
url : '/Admin/PreorderAvailability/AvailableTodayList',
type: 'POST',
data: function (d) { d.__RequestVerificationToken = _token; },
dataSrc: function (json) {
// Update the badge on the tab
var count = json.recordsTotal;
if (count > 0) {
$('#todayBadge').text(count).show();
} else {
$('#todayBadge').hide();
}
return json.data;
}
},
columns: [
/* 0 */ { data: 'ProductName', name: 'ProductName' },
/* 1 */ { data: 'Sku', orderable: false,
render: function (d) { return d ? '<code>' + d + '</code>' : ''; } },
/* 2 */ { data: 'WindowStart', orderable: false,
render: function (d) { return fmtDate(d); } },
/* 3 */ { data: 'WindowEnd', orderable: false,
render: function (d) { return fmtDate(d); } }
]
});
// Load today table when that tab is first clicked
var todayLoaded = false;
$('#tab-today-link').on('shown.bs.tab', function () {
if (!todayLoaded) { todayTable.ajax.reload(); todayLoaded = true; }
});
// Always reload today table when switching to it (data may have changed)
$('#tab-today-link').on('show.bs.tab', function () {
if (todayLoaded) todayTable.ajax.reload();
});
});
</script>

View File

@ -0,0 +1,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);
}
}

View File

@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Mvc;
using Nop.Web.Framework.Components;
namespace Nop.Plugin.Misc.FruitBankPlugin.Components;
public class CustomerPreorderNavViewComponent : NopViewComponent
{
public IViewComponentResult Invoke(string widgetZone, object additionalData)
{
return View("~/Plugins/Misc.FruitBankPlugin/Views/CustomerPreorder/NavItem.cshtml");
}
}

View File

@ -0,0 +1,123 @@
using FruitBank.Common.Enums;
using LinqToDB;
using Microsoft.AspNetCore.Mvc;
using Nop.Core;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Services.Customers;
using Nop.Web.Framework.Controllers;
namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers;
public class CustomerPreorderController : BasePluginController
{
private readonly IWorkContext _workContext;
private readonly ICustomerService _customerService;
private readonly PreorderDbContext _preorderDbContext;
private readonly FruitBankDbContext _dbContext;
public CustomerPreorderController(
IWorkContext workContext,
ICustomerService customerService,
PreorderDbContext preorderDbContext,
FruitBankDbContext dbContext)
{
_workContext = workContext;
_customerService = customerService;
_preorderDbContext = preorderDbContext;
_dbContext = dbContext;
}
[HttpGet]
public async Task<IActionResult> List()
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Challenge();
// Load this customer's preorders, newest first
var preorders = await _preorderDbContext.Preorders
.GetAllByCustomerIdAsync(customer.Id, false)
.OrderByDescending(p => p.CreatedOnUtc)
.ToListAsync();
var allItems = await _preorderDbContext.PreorderItems.GetAll()
.Where(i => preorders.Select(p => p.Id).Contains(i.PreorderId))
.ToListAsync();
// Resolve product names
var productIds = allItems.Select(i => i.ProductId).Distinct().ToList();
var productDtos = await _dbContext.ProductDtos
.GetAll(false)
.Where(p => productIds.Contains(p.Id))
.ToListAsync();
var productById = productDtos.ToDictionary(p => p.Id);
var rows = preorders.Select(p =>
{
var items = allItems.Where(i => i.PreorderId == p.Id).ToList();
// Derive status from quantities (enum reads unreliable in LinqToDB)
var allFulfilled = items.Any() && items.All(i => i.FulfilledQuantity >= i.RequestedQuantity);
var anyFulfilled = items.Any(i => i.FulfilledQuantity > 0);
var allDropped = items.Any() && items.All(i => i.FulfilledQuantity == 0 &&
i.RequestedQuantity > 0);
var effectiveStatus = (int)p.Status != 0 ? p.Status
: allFulfilled ? PreorderStatus.Confirmed
: anyFulfilled ? PreorderStatus.PartiallyFulfilled
: PreorderStatus.Pending;
return new CustomerPreorderRow
{
PreorderId = p.Id,
OrderId = p.OrderId,
DateOfReceipt = p.DateOfReceipt,
CreatedOnUtc = p.CreatedOnUtc,
Status = effectiveStatus,
CustomerNote = p.CustomerNote,
Items = items.Select(i =>
{
productById.TryGetValue(i.ProductId, out var dto);
return new CustomerPreorderItemRow
{
ProductName = dto?.Name ?? $"Termék #{i.ProductId}",
IsMeasurable = dto?.IsMeasurable ?? false,
RequestedQuantity = i.RequestedQuantity,
FulfilledQuantity = i.FulfilledQuantity,
UnitPriceInclTax = i.UnitPriceInclTax,
Status = i.FulfilledQuantity == 0
? PreorderItemStatus.Pending
: i.FulfilledQuantity >= i.RequestedQuantity
? PreorderItemStatus.Fulfilled
: PreorderItemStatus.PartiallyFulfilled
};
}).ToList()
};
}).ToList();
return View("~/Plugins/Misc.FruitBankPlugin/Views/CustomerPreorder/List.cshtml", rows);
}
// ── Inner models ──────────────────────────────────────────────────────────
public class CustomerPreorderRow
{
public int PreorderId { get; set; }
public int? OrderId { get; set; }
public DateTime DateOfReceipt { get; set; }
public DateTime CreatedOnUtc { get; set; }
public PreorderStatus Status { get; set; }
public string? CustomerNote { get; set; }
public List<CustomerPreorderItemRow> Items { get; set; } = new();
}
public class CustomerPreorderItemRow
{
public string ProductName { get; set; } = string.Empty;
public bool IsMeasurable { get; set; }
public int RequestedQuantity { get; set; }
public int FulfilledQuantity { get; set; }
public decimal UnitPriceInclTax { get; set; }
public PreorderItemStatus Status { get; set; }
}
}

View File

@ -39,6 +39,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
ICustomerService customerService,
ICustomerRegistrationService customerRegistrationService,
ILocalizationService localizationService,
PreorderConversionService preorderConversionService,
IEnumerable<IAcLogWriterBase> logWriters)
: BasePluginController, IFruitBankDataControllerServer
{
@ -241,6 +242,12 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
_logger.Detail($"AddShippingItem invoked; id: {shippingItem.Id}");
if (!await ctx.AddShippingItemAsync(shippingItem)) return null;
// Update IncomingQuantity — EventConsumer handles conversion separately
if (shippingItem.ProductId.HasValue && shippingItem.QuantityOnDocument > 0)
await preorderConversionService.SyncIncomingQuantityAsync(
shippingItem.ProductId.Value, 0, shippingItem.QuantityOnDocument);
return await ctx.ShippingItems.GetByIdAsync(shippingItem.Id, true);
}
@ -251,7 +258,40 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
_logger.Detail($"UpdateShippingItem invoked; id: {shippingItem.Id}");
// Load BEFORE the update to capture previous ProductId and QuantityOnDocument
var oldItem = await ctx.ShippingItems.GetByIdAsync(shippingItem.Id, false);
if (!await ctx.UpdateShippingItemSafeAsync(shippingItem)) return null;
if (oldItem != null)
{
var productChanged = oldItem.ProductId != shippingItem.ProductId;
var quantityChanged = oldItem.QuantityOnDocument != shippingItem.QuantityOnDocument;
if (productChanged && shippingItem.ProductId.HasValue)
{
// Full replacement: swap stock, order items, preorder items
await preorderConversionService.ReplaceShippingItemProductAsync(
shippingItem.Id, shippingItem.ProductId.Value, oldItem);
}
else if (quantityChanged && shippingItem.ProductId.HasValue)
{
// Only quantity changed: sync IncomingQuantity delta
await preorderConversionService.SyncIncomingQuantityAsync(
shippingItem.ProductId.Value,
oldItem.QuantityOnDocument,
shippingItem.QuantityOnDocument);
// If quantity increased, trigger conversion
// (EventConsumer also fires this, double-call is idempotent)
if (shippingItem.QuantityOnDocument > oldItem.QuantityOnDocument)
_ = Task.Run(async () => await preorderConversionService
.ConvertPreordersForProductsAsync(
new List<int> { shippingItem.ProductId.Value },
shippingItem.ShippingDocumentId));
}
}
return await ctx.ShippingItems.GetByIdAsync(shippingItem.Id, shippingItem.ShippingDocument != null);
}

View File

@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Mvc;
using Nop.Web.Framework.Controllers;
namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers;
public class HelpController : BasePluginController
{
[HttpGet]
public IActionResult Index()
{
return View("~/Plugins/Misc.FruitBankPlugin/Views/Help/Index.cshtml");
}
}

View File

@ -0,0 +1,630 @@
using FruitBank.Common.Entities;
using FruitBank.Common.Server;
using Microsoft.AspNetCore.Mvc;
using Nop.Core;
using Nop.Core.Domain.Catalog;
using Nop.Core.Domain.Orders;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Plugin.Misc.FruitBankPlugin.Services;
using Nop.Services.Catalog;
using Nop.Services.Customers;
using Nop.Services.Localization;
using Nop.Services.Orders;
using Nop.Web.Framework.Controllers;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers;
[AutoValidateAntiforgeryToken]
public class OrderController : BasePluginController
{
private readonly IWorkContext _workContext;
private readonly IStoreContext _storeContext;
private readonly ICustomerService _customerService;
private readonly ILocalizationService _localizationService;
private readonly FruitBankDbContext _dbContext;
private readonly PreorderDbContext _preorderDbContext;
private readonly FruitBankAttributeService _fruitBankAttributeService;
private readonly CustomPriceCalculationService _customPriceCalculationService;
private readonly IShoppingCartService _shoppingCartService;
private readonly IProductService _productService;
private readonly OpenAIApiService _aiApiService;
private readonly CerebrasAPIService _cerebrasApiService;
private readonly PreorderConversionService _preorderConversionService;
private const string PendingDeliveryKey = "OrderFlowPendingDeliveryDateTime";
public OrderController(
IWorkContext workContext,
IStoreContext storeContext,
ICustomerService customerService,
ILocalizationService localizationService,
FruitBankDbContext dbContext,
PreorderDbContext preorderDbContext,
FruitBankAttributeService fruitBankAttributeService,
IPriceCalculationService priceCalculationService,
IShoppingCartService shoppingCartService,
IProductService productService,
OpenAIApiService aiApiService,
CerebrasAPIService cerebrasApiService,
PreorderConversionService preorderConversionService)
{
_workContext = workContext;
_storeContext = storeContext;
_customerService = customerService;
_localizationService = localizationService;
_dbContext = dbContext;
_preorderDbContext = preorderDbContext;
_fruitBankAttributeService = fruitBankAttributeService;
_customPriceCalculationService = priceCalculationService as CustomPriceCalculationService;
_shoppingCartService = shoppingCartService;
_productService = productService;
_aiApiService = aiApiService;
_cerebrasApiService = cerebrasApiService;
_preorderConversionService = preorderConversionService;
}
// ── INDEX ─────────────────────────────────────────────────────────────────
[HttpGet]
public async Task<IActionResult> Index()
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Challenge();
return View("~/Plugins/Misc.FruitBankPlugin/Views/Order/Index.cshtml");
}
// ── FLOW TYPE ─────────────────────────────────────────────────────────────
/// <summary>
/// Mon/Tue/Wed → preorder regardless of delivery date.
/// Thu/Fri/Sat/Sun + delivery this week → quickorder.
/// Thu/Fri/Sat/Sun + delivery next week or later → preorder.
/// </summary>
public static string ComputeFlowType(DateTime deliveryDate)
{
var today = DateTime.Today;
var todayDow = (int)today.DayOfWeek; // 0=Sun 1=Mon … 6=Sat
// This week's Thursday
int daysSinceMon = todayDow == 0 ? 6 : todayDow - 1;
var weekStart = today.AddDays(-daysSinceMon); // Monday
var thisThursday = weekStart.AddDays(3); // Thursday
var weekEnd = weekStart.AddDays(6); // Sunday
bool deliveryBeforeThursday = deliveryDate.Date < thisThursday;
bool isLateWeek = todayDow == 0 || todayDow >= 4; // Thu-Sun
bool deliveryThisWeek = deliveryDate.Date >= weekStart && deliveryDate.Date <= weekEnd;
// Quick Order: delivery needs current stock (before Thursday)
// OR goods already arrived (Thu-Sun) and delivery still this week
// Preorder: delivery is Thursday+ but today is still Mon/Tue/Wed (goods not yet here)
return (deliveryBeforeThursday || (isLateWeek && deliveryThisWeek))
? "quickorder"
: "preorder";
}
// ── GET / SET DELIVERY DATETIME ───────────────────────────────────────────
[HttpGet]
public async Task<IActionResult> GetDeliveryDateTime()
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Json(new { success = false });
var store = await _storeContext.GetCurrentStoreAsync();
var saved = await _fruitBankAttributeService
.GetGenericAttributeValueAsync<Nop.Core.Domain.Customers.Customer, DateTime?>(
customer.Id, PendingDeliveryKey, store.Id);
if (!saved.HasValue)
return Json(new { success = true, hasValue = false });
var flowType = ComputeFlowType(saved.Value);
return Json(new
{
success = true,
hasValue = true,
date = saved.Value.ToString("yyyy-MM-dd"),
time = saved.Value.ToString("HH:mm"),
iso = saved.Value.ToString("yyyy-MM-ddTHH:mm"),
flowType
});
}
[HttpPost]
public async Task<IActionResult> SetDeliveryDateTime(string deliveryDateTime)
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Json(new { success = false, message = "Nincs bejelentkezve" });
if (string.IsNullOrWhiteSpace(deliveryDateTime) ||
!DateTime.TryParse(deliveryDateTime, out var parsed))
return Json(new { success = false, message = "Érvénytelen dátum/idő formátum" });
var store = await _storeContext.GetCurrentStoreAsync();
await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync<Nop.Core.Domain.Customers.Customer, DateTime>(
customer.Id, PendingDeliveryKey, parsed, store.Id);
var flowType = ComputeFlowType(parsed);
Console.WriteLine($"[OrderFlow] SetDeliveryDateTime — customer #{customer.Id}, {parsed:u}, flowType={flowType}");
return Json(new { success = true, flowType });
}
// ── PRODUCTS — Quick Order flow (all available stock) ─────────────────────
[HttpGet]
public async Task<IActionResult> GetAllProducts(string deliveryDate = null, string deliveryTime = null)
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Json(new { success = false, message = "Nincs bejelentkezve" });
try
{
var store = await _storeContext.GetCurrentStoreAsync();
var allProductDtos = (await _dbContext.ProductDtos.GetAll(true).ToListAsync())
.Where(pd => pd.AvailableQuantity > 0);
var result = new List<object>();
foreach (var product in allProductDtos)
{
var availableQty = product.StockQuantity + product.IncomingQuantity;
if (availableQty <= 0) continue;
decimal? unitPrice = null;
if (!product.IsMeasurable && _customPriceCalculationService != null)
{
var tproduct = await _productService.GetProductByIdAsync(product.Id);
if (tproduct != null)
{
var pr = await _customPriceCalculationService.GetFinalPriceAsync(
tproduct, customer, store, null, 0, true, 1, null, null);
unitPrice = pr.finalPrice;
}
}
result.Add(new
{
id = product.Id,
name = product.Name,
quantity = 1,
unitPrice,
stockQuantity = availableQty,
searchTerm = (string)null,
isQuantityReduced = false,
isMeasurable = product.IsMeasurable
});
}
return Json(new { success = true, products = result });
}
catch (Exception ex)
{
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
}
}
// ── PRODUCTS — Preorder flow (curated window list) ────────────────────────
[HttpGet]
public async Task<IActionResult> GetPreorderProducts()
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Json(new { success = false, message = "Nincs bejelentkezve" });
try
{
var store = await _storeContext.GetCurrentStoreAsync();
var today = DateTime.UtcNow.Date;
var gaStart = await _dbContext.GenericAttributes.Table
.Where(ga => ga.KeyGroup == nameof(Product)
&& ga.Key == FruitBankConst.PreorderWindowStart
&& ga.StoreId == store.Id)
.ToListAsync();
var gaEnd = await _dbContext.GenericAttributes.Table
.Where(ga => ga.KeyGroup == nameof(Product)
&& ga.Key == FruitBankConst.PreorderWindowEnd
&& ga.StoreId == store.Id)
.ToListAsync();
var startById = gaStart.ToDictionary(g => g.EntityId, g => g.Value);
var endById = gaEnd.ToDictionary(g => g.EntityId, g => g.Value);
var availableIds = startById.Keys.Intersect(endById.Keys)
.Where(id =>
{
DateTime.TryParse(startById[id], out var ws);
DateTime.TryParse(endById[id], out var we);
return ws.Date <= today && today <= we.Date;
})
.ToHashSet();
if (!availableIds.Any())
return Json(new { success = true, products = Array.Empty<object>() });
var productDtos = await _dbContext.ProductDtos
.GetAll(true)
.Where(p => availableIds.Contains(p.Id))
.ToListAsync();
var result = new List<object>();
foreach (var dto in productDtos.OrderBy(p => p.Name))
{
decimal? unitPrice = null;
if (!dto.IsMeasurable && _customPriceCalculationService != null)
{
var product = await _dbContext.Products.GetByIdAsync(dto.Id);
if (product != null)
{
var pr = await _customPriceCalculationService.GetFinalPriceAsync(
product, customer, store, null, 0, true, 1, null, null);
unitPrice = pr.finalPrice;
}
}
result.Add(new
{
id = dto.Id,
name = dto.Name,
isMeasurable = dto.IsMeasurable,
unitPrice,
stockQuantity = dto.AvailableQuantity
});
}
return Json(new { success = true, products = result });
}
catch (Exception ex)
{
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
}
}
// ── SEARCH (Quick Order flow) ─────────────────────────────────────────────
[HttpPost]
public async Task<IActionResult> SearchProducts(string text, string deliveryDate = null, string deliveryTime = null)
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Json(new { success = false, message = "Nincs bejelentkezve" });
if (string.IsNullOrWhiteSpace(text))
return Json(new { success = false, message = "Nincs szöveg megadva" });
try
{
var parsedProducts = await ParseProductsFromText(text);
if (parsedProducts == null || !parsedProducts.Any())
return Json(new { success = false, message = "Nem sikerült termékeket azonosítani", transcription = text });
var store = await _storeContext.GetCurrentStoreAsync();
var enriched = await EnrichProductData(parsedProducts, customer, store);
return Json(new { success = true, transcription = text, products = enriched });
}
catch (Exception ex)
{
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
}
}
// ── VOICE (Quick Order flow) ──────────────────────────────────────────────
[HttpPost]
public async Task<IActionResult> TranscribeAndSearch(
Microsoft.AspNetCore.Http.IFormFile audioFile,
string deliveryDate = null, string deliveryTime = null)
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Json(new { success = false, message = "Nincs bejelentkezve" });
if (audioFile == null || audioFile.Length == 0)
return Json(new { success = false, message = "Nem érkezett hangfájl" });
try
{
var text = await TranscribeAudioFile(audioFile, "hu");
if (string.IsNullOrEmpty(text))
return Json(new { success = false, message = "Nem sikerült a hangfelismerés" });
var parsedProducts = await ParseProductsFromText(text);
if (parsedProducts == null || !parsedProducts.Any())
return Json(new { success = false, message = "Nem sikerült termékeket azonosítani", transcription = text });
var store = await _storeContext.GetCurrentStoreAsync();
var enriched = await EnrichProductData(parsedProducts, customer, store);
return Json(new { success = true, transcription = text, products = enriched });
}
catch (Exception ex)
{
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
}
}
// ── ADD TO CART (Quick Order flow) ────────────────────────────────────────
[HttpPost]
public async Task<IActionResult> AddToCart(int productId, int quantity)
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Json(new { success = false, message = "Nincs bejelentkezve" });
if (productId <= 0 || quantity <= 0)
return Json(new { success = false, message = "Érvénytelen termék vagy mennyiség" });
try
{
var product = await _productService.GetProductByIdAsync(productId);
if (product == null || product.Deleted || !product.Published)
return Json(new { success = false, message = "A termék nem elérhető" });
var store = await _storeContext.GetCurrentStoreAsync();
var warnings = await _shoppingCartService.AddToCartAsync(
customer, product, ShoppingCartType.ShoppingCart, store.Id, quantity: quantity);
if (warnings.Any())
return Json(new { success = false, message = string.Join("; ", warnings) });
var cartItems = await GetCartItemsJson(customer, store);
return Json(new { success = true, cartItems });
}
catch (Exception ex)
{
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
}
}
// ── GET CART (Quick Order flow) ───────────────────────────────────────────
[HttpGet]
public async Task<IActionResult> GetCartItems()
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Json(new { success = false });
var store = await _storeContext.GetCurrentStoreAsync();
return Json(new { success = true, cartItems = await GetCartItemsJson(customer, store) });
}
// ── PLACE PREORDER (Preorder flow) ────────────────────────────────────────
[HttpPost]
public async Task<IActionResult> PlacePreorder([FromBody] PlacePreorderRequest request)
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Json(new { success = false, message = "Nincs bejelentkezve" });
if (request?.Items == null || !request.Items.Any())
return Json(new { success = false, message = "Nincs kiválasztott termék" });
if (!DateTime.TryParse(request.DeliveryDateTime, out var deliveryDateTime))
return Json(new { success = false, message = "Érvénytelen szállítási dátum/idő" });
try
{
var store = await _storeContext.GetCurrentStoreAsync();
var preorder = new Preorder
{
CustomerId = customer.Id,
StoreId = store.Id,
DateOfReceipt = deliveryDateTime,
CustomerNote = request.CustomerNote?.Trim()
};
var items = new List<PreorderItem>();
foreach (var req in request.Items.Where(i => i.Quantity > 0))
{
var product = await _dbContext.Products.GetByIdAsync(req.ProductId);
if (product == null || product.Deleted || !product.Published) continue;
decimal unitPrice = 0;
if (_customPriceCalculationService != null)
{
var pr = await _customPriceCalculationService.GetFinalPriceAsync(
product, customer, store, null, 0, true, req.Quantity, null, null);
unitPrice = pr.finalPrice;
}
items.Add(new PreorderItem
{
ProductId = req.ProductId,
RequestedQuantity = req.Quantity,
UnitPriceInclTax = unitPrice
});
}
if (!items.Any())
return Json(new { success = false, message = "Nincs érvényes termék az előrendelésben" });
var saved = await _preorderDbContext.InsertPreorderAsync(preorder, items);
// Clean up the pending datetime attribute
await _fruitBankAttributeService
.DeleteGenericAttributeAsync<Nop.Core.Domain.Customers.Customer>(
customer.Id, PendingDeliveryKey, store.Id);
// Immediately check if any items can be fulfilled from current available stock.
// Awaited inline (not fire-and-forget) so we can return the order ID if one is created.
// shippingDocumentId = 0 signals this was triggered at preorder placement, not by a document.
var productIds = items.Select(i => i.ProductId).Distinct().ToList();
await _preorderConversionService.ConvertPreordersForProductsAsync(productIds, 0);
// Re-read to pick up OrderId if conversion created a real order
var refreshed = await _preorderDbContext.Preorders.GetByIdAsync(saved.Id);
Console.WriteLine($"[OrderFlow] PlacePreorder #{saved.Id} — orderId={refreshed?.OrderId}");
return Json(new
{
success = true,
preorderId = saved.Id,
orderId = refreshed?.OrderId
});
}
catch (Exception ex)
{
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
}
}
// ── PRIVATE HELPERS ───────────────────────────────────────────────────────
private async Task<string> TranscribeAudioFile(Microsoft.AspNetCore.Http.IFormFile audioFile, string language)
{
var fileName = $"order_{DateTime.Now:yyyyMMdd_HHmmss}.webm";
var uploadsFolder = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "uploads", "voice");
if (!Directory.Exists(uploadsFolder)) Directory.CreateDirectory(uploadsFolder);
var filePath = Path.Combine(uploadsFolder, fileName);
using (var stream = new FileStream(filePath, FileMode.Create))
await audioFile.CopyToAsync(stream);
string text;
using (var audioStream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
text = await _aiApiService.TranscribeAudioAsync(audioStream, fileName, language, null);
if (!string.IsNullOrEmpty(text) && (text.EndsWith(".") || text.EndsWith("!") || text.EndsWith("?")))
text = text[..^1];
try { System.IO.File.Delete(filePath); } catch { }
return text;
}
private async Task<List<ParsedProduct>> ParseProductsFromText(string text)
{
var systemPrompt = @"You are a product parser for a Hungarian fruit and vegetable wholesale company.
Parse the product names and quantities from the user's input.
CRITICAL RULES:
1. Normalize product names to singular, lowercase
2. Handle Hungarian number words
3. Fix common transcription/typing errors
4. Return ONLY valid JSON array
OUTPUT FORMAT: [{""product"": ""narancs"", ""quantity"": 100}]";
var aiResponse = await _cerebrasApiService.GetSimpleResponseAsync(systemPrompt, $"Parse this: {text}");
var jsonMatch = Regex.Match(aiResponse, @"\[.*\]", RegexOptions.Singleline);
if (!jsonMatch.Success) return new List<ParsedProduct>();
try
{
return System.Text.Json.JsonSerializer.Deserialize<List<ParsedProduct>>(jsonMatch.Value)
?? new List<ParsedProduct>();
}
catch { return new List<ParsedProduct>(); }
}
private async Task<List<object>> EnrichProductData(
List<ParsedProduct> parsedProducts,
Nop.Core.Domain.Customers.Customer customer,
Nop.Core.Domain.Stores.Store store)
{
var enriched = new List<object>();
var allDtos = await _dbContext.ProductDtos.GetAll(true).ToListAsync();
foreach (var parsed in parsedProducts)
{
var dbProducts = await _productService.SearchProductsAsync(
keywords: parsed.Product, pageIndex: 0, pageSize: 20);
foreach (var product in dbProducts)
{
var dto = allDtos.FirstOrDefault(x => x.Id == product.Id);
if (dto == null) continue;
var available = product.StockQuantity + dto.IncomingQuantity;
if (available <= 0) continue;
var finalQty = Math.Min(parsed.Quantity, available);
var isReduced = finalQty < parsed.Quantity;
decimal? price = null;
if (!dto.IsMeasurable && _customPriceCalculationService != null)
{
var pr = await _customPriceCalculationService.GetFinalPriceAsync(
product, customer, store, null, 0, true, finalQty, null, null);
price = pr.finalPrice;
}
enriched.Add(new
{
id = product.Id,
name = product.Name,
quantity = finalQty,
requestedQuantity = parsed.Quantity,
unitPrice = price,
stockQuantity = available,
searchTerm = parsed.Product,
isQuantityReduced = isReduced,
isMeasurable = dto.IsMeasurable
});
}
}
return enriched;
}
private async Task<List<object>> GetCartItemsJson(
Nop.Core.Domain.Customers.Customer customer,
Nop.Core.Domain.Stores.Store store)
{
var cart = await _shoppingCartService.GetShoppingCartAsync(customer, ShoppingCartType.ShoppingCart, store.Id);
var allDtos = await _dbContext.ProductDtos.GetAll(true).ToListAsync();
var result = new List<object>();
foreach (var item in cart)
{
var product = await _productService.GetProductByIdAsync(item.ProductId);
if (product == null) continue;
var dto = allDtos.FirstOrDefault(x => x.Id == product.Id);
var isMeasurable = dto?.IsMeasurable ?? false;
decimal? price = null;
if (!isMeasurable && _customPriceCalculationService != null)
{
var pr = await _customPriceCalculationService.GetFinalPriceAsync(
product, customer, store, null, 0, true, item.Quantity, null, null);
price = pr.finalPrice;
}
result.Add(new { id = item.Id, productId = item.ProductId, name = product.Name,
quantity = item.Quantity, unitPrice = price, isMeasurable });
}
return result;
}
// ── Inner models ──────────────────────────────────────────────────────────
public class PlacePreorderRequest
{
public string? DeliveryDateTime { get; set; }
public string? CustomerNote { get; set; }
public List<PreorderItemRequest> Items { get; set; } = new();
}
public class PreorderItemRequest
{
public int ProductId { get; set; }
public int Quantity { get; set; }
}
private class ParsedProduct
{
[System.Text.Json.Serialization.JsonPropertyName("product")]
public string Product { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("quantity")]
public int Quantity { get; set; }
}
}

View File

@ -0,0 +1,290 @@
using FruitBank.Common.Entities;
using FruitBank.Common.Server;
using LinqToDB;
using Microsoft.AspNetCore.Mvc;
using Nop.Core;
using Nop.Core.Domain.Catalog;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Plugin.Misc.FruitBankPlugin.Services;
using Nop.Services.Catalog;
using Nop.Services.Customers;
using Nop.Services.Localization;
using Nop.Web.Framework.Controllers;
namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers;
[AutoValidateAntiforgeryToken]
public class PreorderController : BasePluginController
{
private readonly IWorkContext _workContext;
private readonly IStoreContext _storeContext;
private readonly ICustomerService _customerService;
private readonly ILocalizationService _localizationService;
private readonly FruitBankDbContext _dbContext;
private readonly PreorderDbContext _preorderDbContext;
private readonly FruitBankAttributeService _fruitBankAttributeService;
private readonly CustomPriceCalculationService _customPriceCalculationService;
private const string PendingDeliveryDateTimeKey = "PreorderPendingDeliveryDateTime";
private const string Prefix = "Plugins.Misc.FruitBankPlugin.Preorder.";
public PreorderController(
IWorkContext workContext,
IStoreContext storeContext,
ICustomerService customerService,
ILocalizationService localizationService,
FruitBankDbContext dbContext,
PreorderDbContext preorderDbContext,
FruitBankAttributeService fruitBankAttributeService,
IPriceCalculationService priceCalculationService)
{
_workContext = workContext;
_storeContext = storeContext;
_customerService = customerService;
_localizationService = localizationService;
_dbContext = dbContext;
_preorderDbContext = preorderDbContext;
_fruitBankAttributeService = fruitBankAttributeService;
_customPriceCalculationService = priceCalculationService as CustomPriceCalculationService;
}
private Task<string> L(string keySuffix)
=> _localizationService.GetResourceAsync(Prefix + keySuffix);
// ── INDEX ─────────────────────────────────────────────────────────────────
[HttpGet]
public async Task<IActionResult> Index()
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Challenge();
return View("~/Plugins/Misc.FruitBankPlugin/Views/Preorder/Index.cshtml");
}
// ── GET SAVED DELIVERY DATETIME (page restore) ────────────────────────────
[HttpGet]
public async Task<IActionResult> GetDeliveryDateTime()
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Json(new { success = false });
var store = await _storeContext.GetCurrentStoreAsync();
var saved = await _fruitBankAttributeService
.GetGenericAttributeValueAsync<Nop.Core.Domain.Customers.Customer, DateTime?>(
customer.Id, PendingDeliveryDateTimeKey, store.Id);
if (!saved.HasValue)
return Json(new { success = true, hasValue = false });
return Json(new
{
success = true,
hasValue = true,
date = saved.Value.ToString("yyyy-MM-dd"),
time = saved.Value.ToString("HH:mm"),
iso = saved.Value.ToString("yyyy-MM-ddTHH:mm")
});
}
// ── SET DELIVERY DATETIME ─────────────────────────────────────────────────
[HttpPost]
public async Task<IActionResult> SetDeliveryDateTime(string deliveryDateTime)
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Json(new { success = false, message = await L("NotLoggedIn") });
if (string.IsNullOrWhiteSpace(deliveryDateTime))
return Json(new { success = false, message = await L("NoDeliveryDateTimeProvided") });
if (!DateTime.TryParse(deliveryDateTime, out var parsed))
return Json(new { success = false, message = await L("InvalidDeliveryDateTime") });
var store = await _storeContext.GetCurrentStoreAsync();
await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync<Nop.Core.Domain.Customers.Customer, DateTime>(
customer.Id, PendingDeliveryDateTimeKey, parsed, store.Id);
return Json(new { success = true });
}
// ── GET AVAILABLE PRODUCTS (filtered by preorder window) ──────────────────
[HttpGet]
public async Task<IActionResult> GetAvailableProducts()
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Json(new { success = false, message = await L("NotLoggedIn") });
try
{
var store = await _storeContext.GetCurrentStoreAsync();
var today = DateTime.UtcNow.Date;
// Load preorder window generic attributes — two queries, no N+1
var gaStart = await _dbContext.GenericAttributes.Table
.Where(ga => ga.KeyGroup == nameof(Product)
&& ga.Key == FruitBankConst.PreorderWindowStart
&& ga.StoreId == store.Id)
.ToListAsync();
var gaEnd = await _dbContext.GenericAttributes.Table
.Where(ga => ga.KeyGroup == nameof(Product)
&& ga.Key == FruitBankConst.PreorderWindowEnd
&& ga.StoreId == store.Id)
.ToListAsync();
var startById = gaStart.ToDictionary(g => g.EntityId, g => g.Value);
var endById = gaEnd.ToDictionary(g => g.EntityId, g => g.Value);
// Product IDs that are available today for preorder
var availableProductIds = startById.Keys
.Intersect(endById.Keys)
.Where(id =>
{
DateTime.TryParse(startById[id], out var ws);
DateTime.TryParse(endById[id], out var we);
return ws.Date <= today && today <= we.Date;
})
.ToHashSet();
if (!availableProductIds.Any())
return Json(new { success = true, products = Array.Empty<object>() });
// Load product DTOs for those IDs
var productDtos = await _dbContext.ProductDtos
.GetAll(true)
.Where(p => availableProductIds.Contains(p.Id))
.ToListAsync();
var result = new List<object>();
foreach (var dto in productDtos.OrderBy(p => p.Name))
{
decimal? unitPrice = null;
if (!dto.IsMeasurable && _customPriceCalculationService != null)
{
var product = await _dbContext.Products.GetByIdAsync(dto.Id);
if (product != null)
{
var priceResult = await _customPriceCalculationService.GetFinalPriceAsync(
product, customer, store, null, 0, true, 1, null, null);
unitPrice = priceResult.finalPrice;
}
}
result.Add(new
{
id = dto.Id,
name = dto.Name,
isMeasurable = dto.IsMeasurable,
unitPrice,
stockQuantity = dto.AvailableQuantity
});
}
return Json(new { success = true, products = result });
}
catch (Exception ex)
{
Console.WriteLine($"[Preorder] GetAvailableProducts error: {ex.Message}");
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
}
}
// ── PLACE PREORDER ────────────────────────────────────────────────────────
[HttpPost]
public async Task<IActionResult> PlacePreorder([FromBody] PlacePreorderRequest request)
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Json(new { success = false, message = await L("NotLoggedIn") });
if (request?.Items == null || !request.Items.Any())
return Json(new { success = false, message = await L("NoItemsSelected") });
if (!DateTime.TryParse(request.DeliveryDateTime, out var deliveryDateTime))
return Json(new { success = false, message = await L("InvalidDeliveryDateTime") });
try
{
var store = await _storeContext.GetCurrentStoreAsync();
var preorder = new Preorder
{
CustomerId = customer.Id,
StoreId = store.Id,
DateOfReceipt = deliveryDateTime,
CustomerNote = request.CustomerNote?.Trim()
};
var items = new List<PreorderItem>();
foreach (var req in request.Items.Where(i => i.Quantity > 0))
{
var product = await _dbContext.Products.GetByIdAsync(req.ProductId);
if (product == null || product.Deleted || !product.Published)
continue;
decimal unitPrice = 0;
if (_customPriceCalculationService != null)
{
var pr = await _customPriceCalculationService.GetFinalPriceAsync(
product, customer, store, null, 0, true, req.Quantity, null, null);
unitPrice = pr.finalPrice;
}
items.Add(new PreorderItem
{
ProductId = req.ProductId,
RequestedQuantity = req.Quantity,
UnitPriceInclTax = unitPrice
});
}
if (!items.Any())
return Json(new { success = false, message = await L("NoValidItems") });
var saved = await _preorderDbContext.InsertPreorderAsync(preorder, items);
// Clean up the pending delivery datetime attribute
await _fruitBankAttributeService
.DeleteGenericAttributeAsync<Nop.Core.Domain.Customers.Customer>(
customer.Id, PendingDeliveryDateTimeKey, store.Id);
Console.WriteLine($"[Preorder] Placed #{saved.Id} — customer #{customer.Id}, {items.Count} items, delivery {deliveryDateTime:u}");
return Json(new
{
success = true,
preorderId = saved.Id,
message = await L("PlacedSuccessfully")
});
}
catch (Exception ex)
{
Console.WriteLine($"[Preorder] PlacePreorder error: {ex.Message}");
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
}
}
// ── INNER MODELS ──────────────────────────────────────────────────────────
public class PlacePreorderRequest
{
public string? DeliveryDateTime { get; set; }
public string? CustomerNote { get; set; }
public List<PreorderItemRequest> Items { get; set; } = new();
}
public class PreorderItemRequest
{
public int ProductId { get; set; }
public int Quantity { get; set; }
}
}

View File

@ -0,0 +1,511 @@
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;
private readonly FruitBankAttributeService _fruitBankAttributeService;
private const string PendingDeliveryDateTimeKey = "QuickOrderPendingDeliveryDateTime";
// 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,
FruitBankAttributeService fruitBankAttributeService)
{
_workContext = workContext;
_storeContext = storeContext;
_productService = productService;
_shoppingCartService = shoppingCartService;
_customerService = customerService;
_localizationService = localizationService;
_customPriceCalculationService = priceCalculationService as CustomPriceCalculationService;
_aiApiService = aiApiService;
_cerebrasApiService = cerebrasApiService;
_dbContext = dbContext;
_fruitBankAttributeService = fruitBankAttributeService;
}
[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 the previously saved delivery datetime for this customer, if any.
/// Used on page load to restore state when the customer revisits or opens a new tab.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetDeliveryDateTime()
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Json(new { success = false });
var store = await _storeContext.GetCurrentStoreAsync();
var saved = await _fruitBankAttributeService
.GetGenericAttributeValueAsync<Nop.Core.Domain.Customers.Customer, DateTime?>(
customer.Id, PendingDeliveryDateTimeKey, store.Id);
if (!saved.HasValue)
return Json(new { success = true, hasValue = false });
return Json(new
{
success = true,
hasValue = true,
date = saved.Value.ToString("yyyy-MM-dd"),
time = saved.Value.ToString("HH:mm"),
iso = saved.Value.ToString("yyyy-MM-ddTHH:mm")
});
}
/// <summary>
/// Return all available products with prices, optionally filtered by delivery date/slot.
/// </summary>
/// <summary>
/// Save the customer's chosen delivery date+time as a generic attribute.
/// The OrderPlacedEvent handler will transfer it to the order as DateOfReceipt.
/// </summary>
[HttpPost]
public async Task<IActionResult> SetDeliveryDateTime(string deliveryDateTime)
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Json(new { success = false, message = await L("NotLoggedIn") });
if (string.IsNullOrWhiteSpace(deliveryDateTime))
return Json(new { success = false, message = await L("NoDeliveryDateTimeProvided") });
if (!DateTime.TryParse(deliveryDateTime, out var parsedDateTime))
return Json(new { success = false, message = await L("InvalidDeliveryDateTime") });
var store = await _storeContext.GetCurrentStoreAsync();
await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync<Nop.Core.Domain.Customers.Customer, DateTime>(
customer.Id, PendingDeliveryDateTimeKey, parsedDateTime, store.Id);
Console.WriteLine($"[QuickOrder] SetDeliveryDateTime customerId={customer.Id}, dateTime={parsedDateTime:u}");
return Json(new { success = true });
}
[HttpGet]
public async Task<IActionResult> GetAllProducts(string deliveryDate = null, string deliveryTime = null)
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Json(new { success = false, message = await L("NotLoggedIn") });
try
{
Console.WriteLine($"[QuickOrder] GetAllProducts deliveryDate={deliveryDate}, time={deliveryTime}");
var store = await _storeContext.GetCurrentStoreAsync();
var allProductDtos = (await _dbContext.ProductDtos.GetAll(true).ToListAsync())
.Where(pd => pd.AvailableQuantity > 0);
// TODO: filter allProductDtos by deliveryDate + deliverySlot once
// availability data model is defined (e.g. scheduled stock, delivery windows).
var result = new List<object>();
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,
/// optionally filtered by delivery date/slot.
/// </summary>
[HttpPost]
public async Task<IActionResult> SearchProducts(string text, string deliveryDate = null, string deliveryTime = null)
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Json(new { success = false, message = await L("NotLoggedIn") });
if (string.IsNullOrWhiteSpace(text))
return Json(new { success = false, message = await L("NoTextProvided") });
Console.WriteLine($"[QuickOrder] SearchProducts deliveryDate={deliveryDate}, time={deliveryTime}");
// TODO: pass deliveryDate + deliverySlot to EnrichProductData when availability filtering is implemented.
try
{
var parsedProducts = await ParseProductsFromText(text);
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,
/// optionally filtered by delivery date/slot.
/// </summary>
[HttpPost]
public async Task<IActionResult> TranscribeAndSearch(IFormFile audioFile, string deliveryDate = null, string deliveryTime = null)
{
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") });
Console.WriteLine($"[QuickOrder] TranscribeAndSearch deliveryDate={deliveryDate}, time={deliveryTime}");
// TODO: pass deliveryDate + deliverySlot to EnrichProductData when availability filtering is implemented.
try
{
var transcribedText = await TranscribeAudioFile(audioFile, "hu");
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
}
}

View File

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

View File

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

View File

@ -0,0 +1,10 @@
using FruitBank.Common.Entities;
using Mango.Nop.Data.Interfaces;
using Nop.Data;
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer.Interfaces;
public interface ICustomerCreditDbSet<TDbTable> : IMgDbTableBase where TDbTable : IRepository<CustomerCredit>
{
public TDbTable CustomerCredits { get; set; }
}

View File

@ -0,0 +1,10 @@
using FruitBank.Common.Entities;
using Mango.Nop.Data.Interfaces;
using Nop.Data;
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer.Interfaces;
public interface IPreorderDbSet<TDbTable> : IMgDbTableBase where TDbTable : IRepository<Preorder>
{
public TDbTable Preorders { get; set; }
}

View File

@ -0,0 +1,10 @@
using FruitBank.Common.Entities;
using Mango.Nop.Data.Interfaces;
using Nop.Data;
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer.Interfaces;
public interface IPreorderItemDbSet<TDbTable> : IMgDbTableBase where TDbTable : IRepository<PreorderItem>
{
public TDbTable PreorderItems { get; set; }
}

View File

@ -0,0 +1,141 @@
using AyCode.Core.Loggers;
using FruitBank.Common.Entities;
using FruitBank.Common.Enums;
using LinqToDB;
using Mango.Nop.Core.Loggers;
using Mango.Nop.Data.Repositories;
using Nop.Core.Caching;
using Nop.Core.Domain.Catalog;
using Nop.Core.Domain.Customers;
using Nop.Core.Domain.Orders;
using Nop.Core.Events;
using Nop.Data;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer.Interfaces;
using Nop.Plugin.Misc.FruitBankPlugin.Services;
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
public class PreorderDbContext :
IPreorderDbSet<PreorderDbTable>,
IPreorderItemDbSet<PreorderItemDbTable>
{
private readonly ILogger _logger;
public PreorderDbTable Preorders { get; set; }
public PreorderItemDbTable PreorderItems { get; set; }
// Read-only access to related NopCommerce repositories needed during conversion
public IRepository<Customer> Customers { get; set; }
public IRepository<Product> Products { get; set; }
public IRepository<Order> Orders { get; set; }
public IRepository<OrderItem> OrderItems { get; set; }
public PreorderDbContext(
PreorderDbTable preorderDbTable,
PreorderItemDbTable preorderItemDbTable,
IRepository<Customer> customerRepository,
IRepository<Product> productRepository,
IRepository<Order> orderRepository,
IRepository<OrderItem> orderItemRepository,
IEnumerable<IAcLogWriterBase> logWriters)
{
Preorders = preorderDbTable;
PreorderItems = preorderItemDbTable;
Customers = customerRepository;
Products = productRepository;
Orders = orderRepository;
OrderItems = orderItemRepository;
_logger = new Logger<PreorderDbContext>(logWriters.ToArray());
}
/// <summary>
/// Insert a complete preorder with all its items in one operation.
/// Returns the saved preorder (with Id populated).
/// </summary>
public async Task<Preorder> InsertPreorderAsync(Preorder preorder, IList<PreorderItem> items)
{
preorder.CreatedOnUtc = DateTime.UtcNow;
preorder.UpdatedOnUtc = DateTime.UtcNow;
preorder.Status = PreorderStatus.Pending;
await Preorders.InsertAsync(preorder);
foreach (var item in items)
{
item.PreorderId = preorder.Id;
item.FulfilledQuantity = 0;
item.Status = PreorderItemStatus.Pending;
await PreorderItems.InsertAsync(item);
}
_logger.Info($"PreorderDbContext: inserted Preorder #{preorder.Id} with {items.Count} items for customer #{preorder.CustomerId}");
return preorder;
}
/// <summary>
/// Returns all pending preorder items for a set of productIds, ordered by PreorderId (FCFS).
/// Used by PreorderConversionService after IncomingQuantity is written.
/// </summary>
public async Task<List<PreorderItem>> GetPendingItemsForProductsAsync(IList<int> productIds)
{
// Fetch all items for these products first, then filter by status in memory
// LinqToDB cannot translate enum comparisons to SQL in this codebase
var all = await PreorderItems.Table
.Where(i => productIds.Contains(i.ProductId))
.OrderBy(i => i.PreorderId)
.ToListAsync();
return all.Where(i =>
i.Status == PreorderItemStatus.Pending ||
i.Status == PreorderItemStatus.PartiallyFulfilled)
.ToList();
}
/// <summary>
/// After conversion: check if all items in a preorder are resolved and update the preorder's status.
/// </summary>
public async Task RefreshPreorderStatusAsync(int preorderId)
{
var preorder = await Preorders.GetByIdAsync(preorderId);
if (preorder == null) return;
var items = await PreorderItems.GetAllByPreorderIdAsync(preorderId).ToListAsync();
var hasDropped = items.Any(i => i.Status == PreorderItemStatus.Dropped);
var hasPartial = items.Any(i => i.Status == PreorderItemStatus.PartiallyFulfilled);
var hasPending = items.Any(i => i.Status == PreorderItemStatus.Pending);
var allFulfilled = items.All(i => i.Status == PreorderItemStatus.Fulfilled);
preorder.Status = (hasDropped || hasPartial) && !hasPending ? PreorderStatus.PartiallyFulfilled
: allFulfilled ? PreorderStatus.Confirmed
: PreorderStatus.Pending;
preorder.UpdatedOnUtc = DateTime.UtcNow;
await Preorders.UpdateAsync(preorder);
_logger.Info($"PreorderDbContext: Preorder #{preorderId} status → {preorder.Status}");
}
/// <summary>
/// Mark a preorder as cancelled (customer or admin action).
/// </summary>
public async Task CancelPreorderAsync(int preorderId)
{
var preorder = await Preorders.GetByIdAsync(preorderId);
if (preorder == null) return;
preorder.Status = PreorderStatus.Cancelled;
preorder.UpdatedOnUtc = DateTime.UtcNow;
await Preorders.UpdateAsync(preorder);
var items = await PreorderItems.GetAllByPreorderIdAsync(preorderId).ToListAsync();
var cancellableStatuses = new[] { PreorderItemStatus.Pending, PreorderItemStatus.PartiallyFulfilled };
foreach (var item in items.Where(i => cancellableStatuses.Contains(i.Status)))
{
item.Status = PreorderItemStatus.Dropped;
await PreorderItems.UpdateAsync(item);
}
_logger.Info($"PreorderDbContext: Preorder #{preorderId} cancelled");
}
}

View File

@ -0,0 +1,44 @@
using FruitBank.Common.Entities;
using FruitBank.Common.Enums;
using LinqToDB;
using Mango.Nop.Core.Loggers;
using Mango.Nop.Data.Repositories;
using Nop.Core.Caching;
using Nop.Core.Configuration;
using Nop.Core.Events;
using Nop.Data;
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
public class PreorderDbTable : MgDbTableBase<Preorder>
{
public PreorderDbTable(
IEventPublisher eventPublisher,
INopDataProvider dataProvider,
IShortTermCacheManager shortTermCacheManager,
IStaticCacheManager staticCacheManager,
AppSettings appSettings)
: base(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings)
{
}
public IQueryable<Preorder> GetAll(bool loadRelations)
{
return loadRelations
? GetAll()
.LoadWith(p => p.PreorderItems)
: GetAll();
}
public Task<Preorder?> GetByIdAsync(int id, bool loadRelations)
=> GetAll(loadRelations).FirstOrDefaultAsync(p => p.Id == id);
public IQueryable<Preorder> GetAllByCustomerIdAsync(int customerId, bool loadRelations)
=> GetAll(loadRelations).Where(p => p.CustomerId == customerId);
public IQueryable<Preorder> GetAllPendingAsync(bool loadRelations)
{
var pendingStatuses = new[] { PreorderStatus.Pending, PreorderStatus.PartiallyFulfilled };
return GetAll(loadRelations).Where(p => pendingStatuses.Contains(p.Status));
}
}

View File

@ -0,0 +1,42 @@
using FruitBank.Common.Entities;
using FruitBank.Common.Enums;
using LinqToDB;
using Mango.Nop.Core.Loggers;
using Mango.Nop.Data.Repositories;
using Nop.Core.Caching;
using Nop.Core.Configuration;
using Nop.Core.Events;
using Nop.Data;
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
public class PreorderItemDbTable : MgDbTableBase<PreorderItem>
{
public PreorderItemDbTable(
IEventPublisher eventPublisher,
INopDataProvider dataProvider,
IShortTermCacheManager shortTermCacheManager,
IStaticCacheManager staticCacheManager,
AppSettings appSettings)
: base(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings)
{
}
public IQueryable<PreorderItem> GetAllByPreorderIdAsync(int preorderId)
=> GetAll().Where(i => i.PreorderId == preorderId);
public IQueryable<PreorderItem> GetAllByProductIdAsync(int productId)
=> GetAll().Where(i => i.ProductId == productId);
/// <summary>
/// All pending/partially-fulfilled items for a product, ordered by their parent preorder's
/// CreatedOnUtc for first-come-first-served allocation.
/// </summary>
public IQueryable<PreorderItem> GetPendingByProductIdOrderedAsync(int productId)
{
var pendingStatuses = new[] { PreorderItemStatus.Pending, PreorderItemStatus.PartiallyFulfilled };
return GetAll()
.Where(i => i.ProductId == productId && pendingStatuses.Contains(i.Status))
.OrderBy(i => i.PreorderId);
}
}

View File

@ -3,6 +3,7 @@ using FruitBank.Common.Entities;
using FruitBank.Common.Interfaces;
using Mango.Nop.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Nop.Core;
using Nop.Core.Domain.Catalog;
using Nop.Core.Events;
@ -37,13 +38,18 @@ public class FruitBankEventConsumer :
private readonly FruitBankDbContext _ctx;
private readonly MeasurementService _measurementService;
private readonly FruitBankAttributeService _fruitBankAttributeService;
private readonly PreorderConversionService _preorderConversionService;
private readonly IServiceScopeFactory _serviceScopeFactory;
public FruitBankEventConsumer(IHttpContextAccessor httpContextAcc, FruitBankDbContext ctx, MeasurementService measurementService,
FruitBankAttributeService fruitBankAttributeService, IEnumerable<IAcLogWriterBase> logWriters) : base(ctx, httpContextAcc, logWriters)
FruitBankAttributeService fruitBankAttributeService, PreorderConversionService preorderConversionService,
IServiceScopeFactory serviceScopeFactory, IEnumerable<IAcLogWriterBase> logWriters) : base(ctx, httpContextAcc, logWriters)
{
_ctx = ctx;
_measurementService = measurementService;
_fruitBankAttributeService = fruitBankAttributeService;
_preorderConversionService = preorderConversionService;
_serviceScopeFactory = serviceScopeFactory;
}
public override async Task HandleEventAsync(EntityUpdatedEvent<Product> eventMessage)
@ -192,6 +198,25 @@ public class FruitBankEventConsumer :
Logger.Info($"HandleEventAsync->EntityInsertedEvent<ShippingItemPallet>; id: {eventMessage.Entity.Id}");
await UpdateShippingDocumentIsAllMeasuredAsync(eventMessage.Entity);
// Trigger preorder conversion if the item has a matched product and a quantity
var item = eventMessage.Entity;
if (item.ProductId != null && item.QuantityOnDocument > 0)
{
_ = Task.Run(async () =>
{
// Suppress the ambient TransactionScope from the parent context —
// TransactionScope flows through async by default and would cause
// MSDTC promotion failures if the background task enlists in it.
using var suppress = new System.Transactions.TransactionScope(
System.Transactions.TransactionScopeOption.Suppress,
System.Transactions.TransactionScopeAsyncFlowOption.Enabled);
using var scope = _serviceScopeFactory.CreateScope();
var conversion = scope.ServiceProvider.GetRequiredService<PreorderConversionService>();
try { await conversion.ConvertPreordersForProductsAsync(new List<int> { item.ProductId.Value }, item.ShippingDocumentId); }
catch (Exception ex) { Logger.Error($"[FruitBankEventConsumer] Preorder conversion failed for ProductId={item.ProductId}: {ex.Message}", ex); }
});
}
}
#region Update
@ -201,15 +226,23 @@ public class FruitBankEventConsumer :
Logger.Info($"HandleEventAsync->EntityUpdatedEvent<ShippingItem>; id: {eventMessage.Entity.Id}");
var shippingItem = eventMessage.Entity;
//var isMeasured = shippingItem.IsValidMeasuringValues();
//if (shippingItem.IsMeasured != isMeasured)
//{
// shippingItem.IsMeasured = isMeasured;
// await ctx.ShippingItems.UpdateAsync(shippingItem, false);
//}
await UpdateShippingDocumentIsAllMeasuredAsync(shippingItem);
// Trigger preorder conversion when quantity or product assignment changes
if (shippingItem.ProductId != null && shippingItem.QuantityOnDocument > 0)
{
_ = Task.Run(async () =>
{
using var suppress = new System.Transactions.TransactionScope(
System.Transactions.TransactionScopeOption.Suppress,
System.Transactions.TransactionScopeAsyncFlowOption.Enabled);
using var scope = _serviceScopeFactory.CreateScope();
var conversion = scope.ServiceProvider.GetRequiredService<PreorderConversionService>();
try { await conversion.ConvertPreordersForProductsAsync(new List<int> { shippingItem.ProductId.Value }, shippingItem.ShippingDocumentId); }
catch (Exception ex) { Logger.Error($"[FruitBankEventConsumer] Preorder conversion failed for ProductId={shippingItem.ProductId}: {ex.Message}", ex); }
});
}
}
private async Task UpdateShippingDocumentIsAllMeasuredAsync(ShippingItem shippingItem)

View File

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

View File

@ -1,4 +1,4 @@
//using AyCode.Core.Consts;
//using AyCode.Core.Consts;
//using Mango.Nop.Core;
namespace Nop.Plugin.Misc.FruitBankPlugin
@ -15,4 +15,15 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
// }
//}
public static class FruitBankPluginConst
{
/// <summary>
/// Preorders whose DateOfReceipt is further than this many days in the future
/// are NOT converted at the current conversion run.
/// Based on the bi-weekly truck cycle (~3-4 days between arrivals):
/// if delivery is more than 4 days away, the next truck will arrive before
/// that delivery date, and its document processing will be the correct trigger.
/// </summary>
public const int PreorderConversionWindowDays = 4;
}
}

View File

@ -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,334 @@ 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);
// Delivery step
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Title", "When do you want to receive your order?", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Title", "Mikor k\u00e9red a rendel\u00e9st?", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Subtitle", "Choose a delivery day and time slot", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Subtitle", "V\u00e1lassz sz\u00e1ll\u00edt\u00e1si napot \u00e9s id\u0151ablakot", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.DayLabel", "Delivery day", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.DayLabel", "Sz\u00e1ll\u00edt\u00e1si nap", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.TimeLabel", "Delivery time", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.TimeLabel", "Sz\u00e1ll\u00edt\u00e1si id\u0151pont", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.TimeHint", "Choose an exact time", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.TimeHint", "V\u00e1lassz pontos id\u0151pontot", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Saving", "Saving...", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Saving", "Ment\u00e9s...", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ConfirmButton", "Show products", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ConfirmButton", "Term\u00e9kek mutat\u00e1sa", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ChangeLabel", "Delivery:", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ChangeLabel", "Sz\u00e1ll\u00edt\u00e1s:", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ChangeButton", "Change", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ChangeButton", "M\u00f3dos\u00edt\u00e1s", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Today", "Today", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Today", "Ma", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Tomorrow", "Tomorrow", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Tomorrow", "Holnap", hu);
// Search bar
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicButtonTitle", "Start voice recording", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicButtonTitle", "Hangfelv\u00e9tel ind\u00edt\u00e1sa", hu);
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);
// Delivery datetime errors
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoDeliveryDateTimeProvided", "No delivery date/time provided", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoDeliveryDateTimeProvided", "Nincs sz\u00e1ll\u00edt\u00e1si id\u0151pont megadva", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.InvalidDeliveryDateTime", "Invalid delivery date/time format", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.InvalidDeliveryDateTime", "\u00c9rv\u00e9nytelen sz\u00e1ll\u00edt\u00e1si d\u00e1tum/id\u0151 form\u00e1tum", hu);
// Controller JSON error messages
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NotLoggedIn", "Not logged in", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NotLoggedIn", "Nincs bejelentkezve", hu);
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);
// ── Preorder page ───────────────────────────────────────────────────
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.PageTitle", "Preorder", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.PageTitle", "El\u0151rendel\u00e9s", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.MenuLabel", "Preorder", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.MenuLabel", "El\u0151rendel\u00e9s", hu);
// Delivery step
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Title", "When do you want to receive your preorder?", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Title", "Mikor k\u00e9red a rendel\u00e9st?", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Subtitle", "Choose a delivery day and time (we\u2019ll confirm availability)", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Subtitle", "V\u00e1lassz sz\u00e1ll\u00edt\u00e1si napot \u00e9s id\u0151pontot (az el\u00e9rhet\u0151s\u00e9get meger\u0151s\u00edtj\u00fck)", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.DayLabel", "Delivery day", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.DayLabel", "K\u00edv\u00e1nt sz\u00e1ll\u00edt\u00e1si nap", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.TimeLabel", "Delivery time", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.TimeLabel", "K\u00edv\u00e1nt id\u0151pont", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.TimeHint", "Choose an exact time", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.TimeHint", "V\u00e1lassz pontos id\u0151pontot", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ConfirmButton", "Show available products", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ConfirmButton", "El\u00e9rhet\u0151 term\u00e9kek mutat\u00e1sa", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ChangeLabel", "Delivery:", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ChangeLabel", "Sz\u00e1ll\u00edt\u00e1s:", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ChangeButton", "Change", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ChangeButton", "M\u00f3dos\u00edt\u00e1s", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Today", "Today", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Today", "Ma", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Tomorrow", "Tomorrow", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Tomorrow", "Holnap", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Saving", "Saving...", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Saving", "Ment\u00e9s...", hu);
// Products
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.InfoBanner", "Preorders are wishes \u2014 we will confirm availability when the shipment arrives and notify you of any changes.", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.InfoBanner", "Az el\u0151rendel\u00e9s egy k\u00edv\u00e1ns\u00e1glista \u2014 az áruk meger\u0151s\u00edt\u00e9se a sz\u00e1ll\u00edtm\u00e1ny be\u00e9rkez\u00e9sekor t\u00f6rt\u00e9nik, \u00e9s az esetleges v\u00e1ltoz\u00e1sokr\u00f3l \u00e9rtes\u00edt\u00fcnk.", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.LoadingProducts", "Loading available products...", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.LoadingProducts", "El\u00e9rhet\u0151 term\u00e9kek bet\u00f6lt\u00e9se...", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.NoProductsAvailable", "No products are currently available for preorder. Please check back later.", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.NoProductsAvailable", "Jelenleg nincs el\u0151rendelhet\u0151 term\u00e9k. K\u00e9rj\u00fck, l\u00e1togass vissza k\u00e9s\u0151bb.", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.ProductsLabel", "Available for preorder \u2014 set quantities:", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.ProductsLabel", "El\u0151rendelhet\u0151 term\u00e9kek \u2014 add meg a mennyis\u00e9geket:", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.MeasurableBadge", "Requires weighing", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.MeasurableBadge", "S\u00falym\u00e9r\u00e9st ig\u00e9nyel", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.PricePerPiece", "Ft/pcs", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.PricePerPiece", "Ft/db", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.PieceUnit", "pcs", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.PieceUnit", "db", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.StockLabel", "Incoming stock:", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.StockLabel", "V\u00e1rhat\u00f3 k\u00e9szlet:", hu);
// Note + submit
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.NoteLabel", "Additional note (optional)", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.NoteLabel", "Megjegyz\u00e9s (nem k\u00f6telez\u0151)", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.NotePlaceholder", "Any special requests or notes for this preorder...", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.NotePlaceholder", "Esetleges megjegyz\u00e9sek az el\u0151rendel\u00e9ssel kapcsolatban...", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.SelectionNone", "No products selected yet", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.SelectionNone", "M\u00e9g nincs kiv\u00e1lasztott term\u00e9k", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.SelectionItems", "product(s) selected", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.SelectionItems", "term\u00e9k kiv\u00e1lasztva", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.SubmitButton", "Place preorder", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.SubmitButton", "El\u0151rendel\u00e9s lead\u00e1sa", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.Submitting", "Placing preorder...", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.Submitting", "El\u0151rendel\u00e9s ment\u00e9se...", hu);
// Summary panel
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.SummaryTitle", "Your preorder", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.SummaryTitle", "El\u0151rendel\u00e9sed", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.SummaryEmpty", "Set quantities above to build your preorder.", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.SummaryEmpty", "Add meg a mennyis\u00e9geket a term\u00e9kekn\u00e9l.", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.SummaryNote", "Prices for weighed items will be finalised after measurement. Preorder quantities may change depending on actual shipment.", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.SummaryNote", "A s\u00falym\u00e9r\u00e9st ig\u00e9nyl\u0151 t\u00e9teleikn\u00e9l az \u00e1r a m\u00e9r\u00e9s ut\u00e1n v\u00e9glegesedik. A mennyis\u00e9gek a t\u00e9nyleges sz\u00e1ll\u00edtm\u00e1nyt\u00f3l f\u00fcgg\u0151en v\u00e1ltozhatnak.", hu);
// Success + errors
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.SuccessTitle", "Preorder placed!", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.SuccessTitle", "El\u0151rendel\u00e9s leadva!", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.SuccessMessage", "Your preorder #{0} has been received. We will notify you when the shipment arrives.", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.SuccessMessage", "#{0} sz\u00e1m\u00fa el\u0151rendel\u00e9sed be\u00e9rkezett. A sz\u00e1ll\u00edtm\u00e1ny meger\u0151s\u00edt\u00e9sekor \u00e9rtes\u00edt\u00fcnk.", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.BackToHome", "Back to home", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.BackToHome", "Vissza a f\u0151oldalra", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.ErrorPrefix", "Error: ", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.ErrorPrefix", "Hiba: ", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.NotLoggedIn", "Not logged in", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.NotLoggedIn", "Nincs bejelentkezve", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.NoItemsSelected", "No items selected", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.NoItemsSelected", "Nincs kiv\u00e1lasztott term\u00e9k", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.NoValidItems", "No valid items in preorder", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.NoValidItems", "Nincs \u00e9rv\u00e9nyes term\u00e9k az el\u0151rendel\u00e9sben", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.NoDeliveryDateTimeProvided", "No delivery date/time provided", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.NoDeliveryDateTimeProvided", "Nincs sz\u00e1ll\u00edt\u00e1si id\u0151pont megadva", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.InvalidDeliveryDateTime", "Invalid delivery date/time format", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.InvalidDeliveryDateTime", "\u00c9rv\u00e9nytelen sz\u00e1ll\u00edt\u00e1si d\u00e1tum/id\u0151 form\u00e1tum", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.PlacedSuccessfully", "Preorder placed successfully", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Preorder.PlacedSuccessfully", "El\u0151rendel\u00e9s sikeresen leadva", hu);
// ── Customer Credit ────────────────────────────────────────────────────
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle", "Customer Credit Management", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle", "\u00dcgyf\u00e9l hitelkeret kezel\u00e9s", hu);
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 +415,21 @@ 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,
PublicWidgetZones.AccountNavigationAfter
});
}
//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 +448,27 @@ 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);
}
else if (widgetZone == PublicWidgetZones.AccountNavigationAfter)
{
return typeof(CustomerPreorderNavViewComponent);
}
}
return null;
}
}
}

View File

@ -54,6 +54,19 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
/// Gets or sets the timeout for API requests in seconds
/// </summary>
public int RequestTimeoutSeconds { get; set; } = 30;
// ── Z.ai GLM-OCR ─────────────────────────────────────────────────────────────
/// <summary>
/// Z.ai API kulcs a GLM-OCR dokumentumfeldolgozóhoz.
/// Igénylés: https://bigmodel.cn
/// </summary>
public string ZaiApiKey { get; set; } = string.Empty;
/// <summary>
/// Z.ai GLM-OCR modell neve (default: "glm-ocr").
/// </summary>
public string ZaiModel { get; set; } = "glm-ocr";
}
}

View File

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

View File

@ -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,10 @@ public class PluginNopStartup : INopStartup
services.AddScoped<StockTakingDbTable>();
services.AddScoped<StockTakingItemDbTable>();
services.AddScoped<StockTakingItemPalletDbTable>();
services.AddScoped<CustomerCreditDbTable>();
services.AddScoped<PreorderDbTable>();
services.AddScoped<PreorderItemDbTable>();
services.AddScoped<PreorderDbContext>();
services.AddScoped<StockTakingDbContext>();
services.AddScoped<FruitBankDbContext>();
@ -90,8 +95,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>();
@ -118,9 +130,16 @@ public class PluginNopStartup : INopStartup
services.AddScoped<OpenAIApiService>();
//services.AddScoped<IAIAPIService, OpenAIApiService>();
services.AddScoped<AICalculationService>();
services.AddScoped<PdfToImageService>();
services.AddScoped<IWorkflowMessageService, WorkflowMessageService>();
// Z.ai GLM-OCR — nagy PDF-eknél 3 perces timeout szükséges (1.86 oldal/mp sebesség)
services.AddHttpClient<ZaiService>(client =>
{
client.Timeout = TimeSpan.FromMinutes(3);
});
services.AddScoped<PdfToImageService>();
services.AddScoped<FruitBankNotificationService>();
services.AddScoped<FruitBankOrderItemService>();
services.AddScoped<PreorderConversionService>();
services.AddSingleton<IFileStorageProvider>(sp =>
new LocalFileStorageProvider() // Uses default wwwroot/uploads
// Or specify custom path:
@ -129,6 +148,7 @@ public class PluginNopStartup : INopStartup
// Register the file storage service
services.AddScoped<FileStorageService>();
services.AddScoped<ICustomerCreditService, CustomerCreditService>();
services.AddControllersWithViews(options =>
{

View File

@ -21,61 +21,60 @@ 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",
pattern: "Admin/Shipping/Create",
defaults: new { controller = "Shipping", action = "Create", area = AreaNames.ADMIN });
name: "Plugin.FruitBank.Admin.Shipping.Create",
pattern: "Admin/Shipping/Create",
defaults: new { controller = "Shipping", action = "Create", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.Shipping.Edit",
pattern: "Admin/Shipping/Edit",
defaults: new { controller = "Shipping", action = "Edit", area = AreaNames.ADMIN });
name: "Plugin.FruitBank.Admin.Shipping.Edit",
pattern: "Admin/Shipping/Edit",
defaults: new { controller = "Shipping", action = "Edit", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.Shipping.UploadFile",
@ -121,7 +120,7 @@ public class RouteProvider : IRouteProvider
name: "Plugin.FruitBank.Admin.Products.List",
pattern: "Admin/Product/List",
defaults: new { controller = "CustomProduct", action = "List", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.Products.ProductList",
pattern: "Admin/Product/ProductList",
@ -153,9 +152,9 @@ public class RouteProvider : IRouteProvider
defaults: new { controller = "CustomOrder", action = "Edit", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.Order.AddProduct",
pattern: "Admin/CustomOrder/FruitBankAddProductToOrder",
defaults: new { controller = "CustomOrder", action = "FruitBankAddProductToOrder", area = AreaNames.ADMIN });
name: "Plugin.FruitBank.Admin.Order.AddProduct",
pattern: "Admin/CustomOrder/FruitBankAddProductToOrder",
defaults: new { controller = "CustomOrder", action = "FruitBankAddProductToOrder", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.ManagementPage.ProcessShippingDocument",
@ -181,10 +180,208 @@ 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 });
// ── Admin: Preorder list ───────────────────────────────────────────
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Preorders.List",
pattern: "Admin/Preorders",
defaults: new { controller = "PreorderAdmin", action = "List", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Preorders.PreorderList",
pattern: "Admin/Preorders/PreorderList",
defaults: new { controller = "PreorderAdmin", action = "PreorderList", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Preorders.Detail",
pattern: "Admin/Preorders/Detail/{id:int}",
defaults: new { controller = "PreorderAdmin", action = "Detail", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Preorders.Cancel",
pattern: "Admin/Preorders/Cancel/{id:int}",
defaults: new { controller = "PreorderAdmin", action = "Cancel", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Preorders.CreatePreorder",
pattern: "Admin/Preorders/CreatePreorder",
defaults: new { controller = "PreorderAdmin", action = "CreatePreorder", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Preorders.DemandList",
pattern: "Admin/Preorders/DemandList",
defaults: new { controller = "PreorderAdmin", action = "DemandList", area = AreaNames.ADMIN });
// ── Admin: Preorder availability ─────────────────────────────────
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.PreorderAvailability.Index",
pattern: "Admin/PreorderAvailability",
defaults: new { controller = "PreorderAvailability", action = "Index", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.PreorderAvailability.ProductList",
pattern: "Admin/PreorderAvailability/ProductList",
defaults: new { controller = "PreorderAvailability", action = "ProductList", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.PreorderAvailability.AvailableTodayList",
pattern: "Admin/PreorderAvailability/AvailableTodayList",
defaults: new { controller = "PreorderAvailability", action = "AvailableTodayList", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.PreorderAvailability.SaveWindow",
pattern: "Admin/PreorderAvailability/SaveWindow",
defaults: new { controller = "PreorderAvailability", action = "SaveWindow", area = AreaNames.ADMIN });
// ── Public: Unified Order flow ─────────────────────────────────────────
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Order.Index",
pattern: "rendeles",
defaults: new { controller = "Order", action = "Index" });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Order.GetDeliveryDateTime",
pattern: "rendeles/szallitas-idopont",
defaults: new { controller = "Order", action = "GetDeliveryDateTime" });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Order.SetDeliveryDateTime",
pattern: "rendeles/szallitas-idopont-beallitas",
defaults: new { controller = "Order", action = "SetDeliveryDateTime" });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Order.GetAllProducts",
pattern: "rendeles/osszes-termek",
defaults: new { controller = "Order", action = "GetAllProducts" });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Order.GetPreorderProducts",
pattern: "rendeles/elozetes-termekek",
defaults: new { controller = "Order", action = "GetPreorderProducts" });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Order.SearchProducts",
pattern: "rendeles/kereses",
defaults: new { controller = "Order", action = "SearchProducts" });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Order.TranscribeAndSearch",
pattern: "rendeles/hang",
defaults: new { controller = "Order", action = "TranscribeAndSearch" });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Order.AddToCart",
pattern: "rendeles/kosarba",
defaults: new { controller = "Order", action = "AddToCart" });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Order.GetCartItems",
pattern: "rendeles/kosar",
defaults: new { controller = "Order", action = "GetCartItems" });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Order.PlacePreorder",
pattern: "rendeles/elozetes-leadás",
defaults: new { controller = "Order", action = "PlacePreorder" });
// ── Public: Help page ───────────────────────────────────────────────────
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Help.Index",
pattern: "segitseg",
defaults: new { controller = "Help", action = "Index" });
// ── Public: Customer preorder list ───────────────────────────────────────────
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.CustomerPreorder.List",
pattern: "fiokom/elorerendeles-aim",
defaults: new { controller = "CustomerPreorder", action = "List" });
// ── Public: Preorder (legacy, kept for backward compat) ───────────────
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Preorder.Index",
pattern: "elozetes-rendeles",
defaults: new { controller = "Preorder", action = "Index" });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Preorder.GetDeliveryDateTime",
pattern: "elozetes-rendeles/szallitas-idopont",
defaults: new { controller = "Preorder", action = "GetDeliveryDateTime" });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Preorder.SetDeliveryDateTime",
pattern: "elozetes-rendeles/szallitas-idopont-beallitas",
defaults: new { controller = "Preorder", action = "SetDeliveryDateTime" });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Preorder.GetAvailableProducts",
pattern: "elozetes-rendeles/termekek",
defaults: new { controller = "Preorder", action = "GetAvailableProducts" });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Preorder.PlacePreorder",
pattern: "elozetes-rendeles/leadás",
defaults: new { controller = "Preorder", action = "PlacePreorder" });
// ── Public: Quick Order ──────────────────────────────────────────────
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.QuickOrder.Index",
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" });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.QuickOrder.SetDeliveryDateTime",
pattern: "gyors-rendeles/szallitas-idopont",
defaults: new { controller = "QuickOrder", action = "SetDeliveryDateTime" });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.QuickOrder.GetDeliveryDateTime",
pattern: "gyors-rendeles/szallitas-idopont-lekerdezes",
defaults: new { controller = "QuickOrder", action = "GetDeliveryDateTime" });
}
/// <summary>
/// Gets a priority of route provider
/// </summary>
public int Priority => 4000;
}
}

View File

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<Language Name="English" IsDefault="false" IsRightToLeft="false">
<!-- ═══════════════════════════════════════════════════════════
Plugin Configuration Page — Plugins.FruitBankPlugin.Fields.*
Import: Admin > Configuration > Languages > [English] > Import resources
═══════════════════════════════════════════════════════════ -->
<!-- General -->
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.IsEnabled">
<Value><![CDATA[Plugin enabled]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.MaxTokens">
<Value><![CDATA[Max tokens]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.Temperature">
<Value><![CDATA[Temperature]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.RequestTimeoutSeconds">
<Value><![CDATA[Request timeout (seconds)]]></Value>
</LocaleResource>
<!-- Cerebras -->
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.CerebrasApiKey">
<Value><![CDATA[Cerebras API key]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.CerebrasModelName">
<Value><![CDATA[Cerebras model]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.CerebrasApiBaseUrl">
<Value><![CDATA[Cerebras API base URL]]></Value>
</LocaleResource>
<!-- OpenAI -->
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.OpenAIApiKey">
<Value><![CDATA[OpenAI API key]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.OpenAIModelName">
<Value><![CDATA[OpenAI model]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.OpenAIApiBaseUrl">
<Value><![CDATA[OpenAI API base URL]]></Value>
</LocaleResource>
<!-- Z.ai GLM-OCR -->
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.ZaiApiKey">
<Value><![CDATA[Z.ai API key]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.ZaiModel">
<Value><![CDATA[Z.ai model]]></Value>
</LocaleResource>
</Language>

View File

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<Language Name="Hungarian" IsDefault="false" IsRightToLeft="false">
<!-- ═══════════════════════════════════════════════════════════
Plugin konfigurációs oldal — Plugins.FruitBankPlugin.Fields.*
Import: Admin > Konfiguráció > Nyelvek > [Magyar] > Erőforrások importálása
═══════════════════════════════════════════════════════════ -->
<!-- Általános -->
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.IsEnabled">
<Value><![CDATA[Plugin engedélyezve]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.MaxTokens">
<Value><![CDATA[Maximum token szám]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.Temperature">
<Value><![CDATA[Kreativitás (temperature)]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.RequestTimeoutSeconds">
<Value><![CDATA[Időtúllépés (másodperc)]]></Value>
</LocaleResource>
<!-- Cerebras -->
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.CerebrasApiKey">
<Value><![CDATA[Cerebras API kulcs]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.CerebrasModelName">
<Value><![CDATA[Cerebras modell]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.CerebrasApiBaseUrl">
<Value><![CDATA[Cerebras API alapcím]]></Value>
</LocaleResource>
<!-- OpenAI -->
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.OpenAIApiKey">
<Value><![CDATA[OpenAI API kulcs]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.OpenAIModelName">
<Value><![CDATA[OpenAI modell]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.OpenAIApiBaseUrl">
<Value><![CDATA[OpenAI API alapcím]]></Value>
</LocaleResource>
<!-- Z.ai GLM-OCR -->
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.ZaiApiKey">
<Value><![CDATA[Z.ai API kulcs]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.ZaiModel">
<Value><![CDATA[Z.ai modell]]></Value>
</LocaleResource>
</Language>

View File

@ -0,0 +1,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>

View File

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

View File

@ -0,0 +1,143 @@
<?xml version="1.0" encoding="utf-8"?>
<Language Name="English" IsDefault="false" IsRightToLeft="false">
<!-- ═══════════════════════════════════════════════════════════
Preorder page — Plugins.Misc.FruitBankPlugin.Preorder.*
Import: Admin > Configuration > Languages > [English] > Import resources
═══════════════════════════════════════════════════════════ -->
<!-- General -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.PageTitle">
<Value><![CDATA[Preorder]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.MenuLabel">
<Value><![CDATA[Preorder]]></Value>
</LocaleResource>
<!-- Delivery step -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Title">
<Value><![CDATA[When do you want to receive your preorder?]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Subtitle">
<Value><![CDATA[Choose a delivery day and time (we'll confirm availability)]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.DayLabel">
<Value><![CDATA[Delivery day]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.TimeLabel">
<Value><![CDATA[Delivery time]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.TimeHint">
<Value><![CDATA[Choose an exact time]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ConfirmButton">
<Value><![CDATA[Show available products]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ChangeLabel">
<Value><![CDATA[Delivery:]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ChangeButton">
<Value><![CDATA[Change]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Today">
<Value><![CDATA[Today]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Tomorrow">
<Value><![CDATA[Tomorrow]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Saving">
<Value><![CDATA[Saving...]]></Value>
</LocaleResource>
<!-- Product list -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.InfoBanner">
<Value><![CDATA[Preorders are wishes — we will confirm availability when the shipment arrives and notify you of any changes.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.LoadingProducts">
<Value><![CDATA[Loading available products...]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NoProductsAvailable">
<Value><![CDATA[No products are currently available for preorder. Please check back later.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.ProductsLabel">
<Value><![CDATA[Available for preorder — set quantities:]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.MeasurableBadge">
<Value><![CDATA[Requires weighing]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.PricePerPiece">
<Value><![CDATA[Ft/pcs]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.PieceUnit">
<Value><![CDATA[pcs]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.StockLabel">
<Value><![CDATA[Incoming stock:]]></Value>
</LocaleResource>
<!-- Note and submit -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NoteLabel">
<Value><![CDATA[Additional note (optional)]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NotePlaceholder">
<Value><![CDATA[Any special requests or notes for this preorder...]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SelectionNone">
<Value><![CDATA[No products selected yet]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SelectionItems">
<Value><![CDATA[product(s) selected]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SubmitButton">
<Value><![CDATA[Place preorder]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.Submitting">
<Value><![CDATA[Placing preorder...]]></Value>
</LocaleResource>
<!-- Summary panel -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SummaryTitle">
<Value><![CDATA[Your preorder]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SummaryEmpty">
<Value><![CDATA[Set quantities above to build your preorder.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SummaryNote">
<Value><![CDATA[Prices for weighed items will be finalised after measurement. Preorder quantities may change depending on actual shipment.]]></Value>
</LocaleResource>
<!-- Success -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SuccessTitle">
<Value><![CDATA[Preorder placed!]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SuccessMessage">
<Value><![CDATA[Your preorder #{0} has been received. We will notify you when the shipment arrives.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.BackToHome">
<Value><![CDATA[Back to home]]></Value>
</LocaleResource>
<!-- Error messages -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.ErrorPrefix">
<Value><![CDATA[Error: ]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NotLoggedIn">
<Value><![CDATA[Not logged in]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NoItemsSelected">
<Value><![CDATA[No items selected]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NoValidItems">
<Value><![CDATA[No valid items in preorder]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NoDeliveryDateTimeProvided">
<Value><![CDATA[No delivery date/time provided]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.InvalidDeliveryDateTime">
<Value><![CDATA[Invalid delivery date/time format]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.PlacedSuccessfully">
<Value><![CDATA[Preorder placed successfully]]></Value>
</LocaleResource>
</Language>

View File

@ -0,0 +1,143 @@
<?xml version="1.0" encoding="utf-8"?>
<Language Name="Hungarian" IsDefault="false" IsRightToLeft="false">
<!-- ═══════════════════════════════════════════════════════════
Előrendelés oldal — Plugins.Misc.FruitBankPlugin.Preorder.*
Import: Admin > Konfiguráció > Nyelvek > [Magyar] > Erőforrások importálása
═══════════════════════════════════════════════════════════ -->
<!-- Általános -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.PageTitle">
<Value><![CDATA[Előrendelés]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.MenuLabel">
<Value><![CDATA[Előrendelés]]></Value>
</LocaleResource>
<!-- Szállítási időpont lépés -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Title">
<Value><![CDATA[Mikor kéred a rendelést?]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Subtitle">
<Value><![CDATA[Válassz szállítási napot és időpontot (az elérhetőséget megerősítjük)]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.DayLabel">
<Value><![CDATA[Kívánt szállítási nap]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.TimeLabel">
<Value><![CDATA[Kívánt időpont]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.TimeHint">
<Value><![CDATA[Válassz pontos időpontot]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ConfirmButton">
<Value><![CDATA[Elérhető termékek mutatása]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ChangeLabel">
<Value><![CDATA[Szállítás:]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ChangeButton">
<Value><![CDATA[Módosítás]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Today">
<Value><![CDATA[Ma]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Tomorrow">
<Value><![CDATA[Holnap]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Saving">
<Value><![CDATA[Mentés...]]></Value>
</LocaleResource>
<!-- Terméklista -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.InfoBanner">
<Value><![CDATA[Az előrendelés egy kívánságlista — az áruk megerősítése a szállítmány beérkezésekor történik, és az esetleges változásokról értesítünk.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.LoadingProducts">
<Value><![CDATA[Elérhető termékek betöltése...]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NoProductsAvailable">
<Value><![CDATA[Jelenleg nincs előrendelhető termék. Kérjük, látogass vissza később.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.ProductsLabel">
<Value><![CDATA[Előrendelhető termékek — add meg a mennyiségeket:]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.MeasurableBadge">
<Value><![CDATA[Súlymérést igényel]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.PricePerPiece">
<Value><![CDATA[Ft/db]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.PieceUnit">
<Value><![CDATA[db]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.StockLabel">
<Value><![CDATA[Várható készlet:]]></Value>
</LocaleResource>
<!-- Megjegyzés és leadás -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NoteLabel">
<Value><![CDATA[Megjegyzés (nem kötelező)]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NotePlaceholder">
<Value><![CDATA[Esetleges megjegyzések az előrendeléssel kapcsolatban...]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SelectionNone">
<Value><![CDATA[Még nincs kiválasztott termék]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SelectionItems">
<Value><![CDATA[termék kiválasztva]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SubmitButton">
<Value><![CDATA[Előrendelés leadása]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.Submitting">
<Value><![CDATA[Előrendelés mentése...]]></Value>
</LocaleResource>
<!-- Összefoglaló panel -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SummaryTitle">
<Value><![CDATA[Előrendelésed]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SummaryEmpty">
<Value><![CDATA[Add meg a mennyiségeket a termékeknél.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SummaryNote">
<Value><![CDATA[A súlymérést igénylő tételeknél az ár a mérés után véglegesedik. A mennyiségek a tényleges szállítmánytól függően változhatnak.]]></Value>
</LocaleResource>
<!-- Sikeres leadás -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SuccessTitle">
<Value><![CDATA[Előrendelés leadva!]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SuccessMessage">
<Value><![CDATA[#{0} számú előrendelésed beérkezett. A szállítmány megerősítésekor értesítünk.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.BackToHome">
<Value><![CDATA[Vissza a főoldalra]]></Value>
</LocaleResource>
<!-- Hibaüzenetek -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.ErrorPrefix">
<Value><![CDATA[Hiba: ]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NotLoggedIn">
<Value><![CDATA[Nincs bejelentkezve]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NoItemsSelected">
<Value><![CDATA[Nincs kiválasztott termék]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NoValidItems">
<Value><![CDATA[Nincs érvényes termék az előrendelésben]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NoDeliveryDateTimeProvided">
<Value><![CDATA[Nincs szállítási időpont megadva]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.InvalidDeliveryDateTime">
<Value><![CDATA[Érvénytelen szállítási dátum/idő formátum]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.PlacedSuccessfully">
<Value><![CDATA[Előrendelés sikeresen leadva]]></Value>
</LocaleResource>
</Language>

View File

@ -0,0 +1,212 @@
<?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>
<!-- Delivery step -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Title">
<Value><![CDATA[When do you want to receive your order?]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Subtitle">
<Value><![CDATA[Choose a delivery day and time slot]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.DayLabel">
<Value><![CDATA[Delivery day]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.TimeLabel">
<Value><![CDATA[Delivery time]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.TimeHint">
<Value><![CDATA[Choose an exact time]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Saving">
<Value><![CDATA[Saving...]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ConfirmButton">
<Value><![CDATA[Show products]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ChangeLabel">
<Value><![CDATA[Delivery:]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ChangeButton">
<Value><![CDATA[Change]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Today">
<Value><![CDATA[Today]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Tomorrow">
<Value><![CDATA[Tomorrow]]></Value>
</LocaleResource>
<!-- Search bar -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.MicButtonTitle">
<Value><![CDATA[Start voice recording]]></Value>
</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>

View File

@ -0,0 +1,212 @@
<?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>
<!-- Szállítási időpont lépés -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Title">
<Value><![CDATA[Mikor kéred a rendelést?]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Subtitle">
<Value><![CDATA[Válassz szállítási napot és időablakot]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.DayLabel">
<Value><![CDATA[Szállítási nap]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.TimeLabel">
<Value><![CDATA[Szállítási időpont]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.TimeHint">
<Value><![CDATA[Válassz pontos időpontot]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Saving">
<Value><![CDATA[Mentés...]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ConfirmButton">
<Value><![CDATA[Termékek mutatása]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ChangeLabel">
<Value><![CDATA[Szállítás:]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ChangeButton">
<Value><![CDATA[Módosítás]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Today">
<Value><![CDATA[Ma]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Tomorrow">
<Value><![CDATA[Holnap]]></Value>
</LocaleResource>
<!-- Keresősáv -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.MicButtonTitle">
<Value><![CDATA[Hangfelvétel indítása]]></Value>
</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>

View File

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

View File

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

View File

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

View File

@ -0,0 +1,14 @@
namespace Nop.Plugin.Misc.FruitBankPlugin.Models.Orders;
/// <summary>
/// Minimal contract for adding a product to an order.
/// Implemented by CustomOrderController.OrderProductItem, AddProductModel,
/// and any other DTO that needs to be passed to FruitBankOrderItemService.
/// </summary>
public interface IOrderProductItemBase
{
/// <summary>ProductId</summary>
int Id { get; set; }
int Quantity { get; set; }
decimal Price { get; set; }
}

View File

@ -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>
@ -166,9 +170,18 @@
</ItemGroup>
<ItemGroup>
<None Update="Areas\Admin\Components\_FruitBankDashboard.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<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 +194,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>
@ -208,6 +224,15 @@
<None Update="Areas\Admin\Views\Order\List.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\PreorderAvailability\Index.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\Preorder\Detail.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\Preorder\List.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\Product\List.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
@ -442,6 +467,9 @@
<None Update="css\devextreme\icons\dxiconsmaterial.woff2">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="css\preorder.css">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="js\devextreme\aspnet\dx.aspnet.data.js">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
@ -646,6 +674,24 @@
<None Update="Views\Checkout\PendingMeasurementWarning.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Views\CustomerCreditWidget.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Views\CustomerPreorder\List.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Views\CustomerPreorder\NavItem.cshtml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Views\Help\Index.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Views\Order\Index.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Views\Preorder\Index.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Views\ProductAIListWidget.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
@ -661,6 +707,9 @@
<None Update="Views\ProductAIWidget.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Views\QuickOrder\Index.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>

View File

@ -0,0 +1,352 @@
# 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 |
|---|---|
| LinqToDB table name for custom entities | **Never** rely on `[Table]` attribute. Register the mapping in the plugin's `Mapping/NameCompatibility.cs` file: `{ typeof(MyEntity), "fbMyTable" }`. This is the only place LinqToDB reads the table name from in this codebase. |
| `ICustomerAttributeService` does not exist | Use direct `XDocument.Parse` on the XML stored in `GenericAttribute.Value` |
| `ParseAttributeValuesAsync` returns empty for free-text attributes | It's designed for predefined selection attributes (ID lookup). For free-text: parse XML directly: `<Attributes><CustomerAttribute ID="1"><CustomerAttributeValue><Value>...</Value>...` |
| `TransactionSafeAsync` + async = deadlock | `TaskHelper.ToThreadPoolTask` inside it causes context switching deadlocks in ASP.NET; remove transaction wrapper for affected code |
| 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)*

View File

@ -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)
@ -94,6 +97,16 @@ public class CustomPriceCalculationService : PriceCalculationService
{
_logger.Info($"orderItem.Id: {orderItem.Id}");
if (orderItem.UnitPriceInclTax == 0 || orderItem.UnitPriceExclTax == 0)
{
var orderDto = await _dbContext.OrderDtos.GetByIdAsync(orderItem.OrderId, false);
var customer = await _dbContext.Customers.GetByIdAsync(orderDto.CustomerId);
var product = await _dbContext.Products.GetByIdAsync(orderItem.ProductId);
var pr = await GetFinalPriceAsync(product, customer, _storeContext.GetCurrentStore(), null, 0, true, 1, null, null);
orderItem.UnitPriceInclTax = pr.finalPrice;
orderItem.UnitPriceExclTax = pr.finalPrice / (decimal)1.27;
}
var finalPrices = CalculateOrderItemFinalPrices(orderItem.Quantity, orderItem.UnitPriceInclTax, orderItem.UnitPriceExclTax, isMeasurable, netWeight);
if (finalPrices.finalPriceInclTax == orderItem.PriceInclTax && finalPrices.finalPriceExclTax == orderItem.PriceExclTax) return false;
@ -135,9 +148,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;
@ -157,6 +187,12 @@ public class CustomPriceCalculationService : PriceCalculationService
if (productDto.IsMeasurable)
{
// For measurable products the real price is weight × unit price, determined only after
// physical weighing. Until then we expose 0 so the cart and checkout total are honest.
// The actual PriceInclTax / PriceExclTax on OrderItem is set by
// CheckAndUpdateOrderItemFinalPricesAsync after the order is weighed.
//return (0m, 0m, 0m, new System.Collections.Generic.List<Nop.Core.Domain.Discounts.Discount>());
//finalPrice.priceWithoutDiscounts = 0;
//return (0, finalPrice.finalPrice, finalPrice.appliedDiscountAmount, []);
return finalPrice;

View File

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

View File

@ -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,17 +90,81 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
_dbContext = dbContext;
_attributeParser = attributeParser;
_customerService = customerService;
_workflowMessageService = workflowMessageService;
_fruitBankNotificationService = fruitBankNotificationService;
}
protected override string PluginSystemName => "Misc.FruitBankPlugin";
public async Task HandleEventAsync(OrderPlacedEvent eventMessage)
{
var order = eventMessage?.Order;
if (order == null) return;
// Transfer the customer's chosen delivery datetime to the order as DateOfReceipt
var customer = await _customerService.GetCustomerByIdAsync(order.CustomerId);
if (customer == null) return;
var storeId = order.StoreId;
const string pendingKey = "QuickOrderPendingDeliveryDateTime";
var pendingDateTime = await _fruitBankAttributeService
.GetGenericAttributeValueAsync<Nop.Core.Domain.Customers.Customer, DateTime?>(order.CustomerId, pendingKey, storeId);
if (pendingDateTime.HasValue)
{
await _fruitBankAttributeService
.InsertOrUpdateGenericAttributeAsync<Nop.Core.Domain.Orders.Order, DateTime>(
order.Id, nameof(IOrderDto.DateOfReceipt), pendingDateTime.Value, storeId);
// Clean up — the value has been transferred to the order
await _fruitBankAttributeService
.DeleteGenericAttributeAsync<Nop.Core.Domain.Customers.Customer>(order.CustomerId, pendingKey, storeId);
Console.WriteLine($"[EventConsumer] OrderPlaced #{order.Id} DateOfReceipt set to {pendingDateTime.Value:u}");
}
}
public async Task HandleEventAsync(EntityUpdatedEvent<Order> eventMessage)
{
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);
}
}
}
@ -126,7 +198,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
public override async Task HandleEventAsync(AdminMenuCreatedEvent eventMessage)
{
var rootNode = eventMessage.RootMenuItem;
var shippingsListMenuItem = new AdminMenuItem
{
@ -178,16 +250,49 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
{
Visible = true,
SystemName = "FruitBank",
Title = await _localizationService.GetResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.VoiceOrder"), // You can localize this with await _localizationService.GetResourceAsync("...")
Title = await _localizationService.GetResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.VoiceOrder"),
IconClass = "fas fa-microphone",
Url = _adminMenu.GetMenuItemUrl("VoiceOrder", "Create")
//ChildNodes = [shippingsListMenuItem, createShippingMenuItem, editShippingMenuItem]
};
shippingConfigurationItem.ChildNodes.Insert(3, voiceOrderMenuItem);
var preorderAvailabilityMenuItem = new AdminMenuItem
{
Visible = true,
SystemName = "PreorderAvailability",
Title = "Előrendelés — elérhetőség",
IconClass = "fas fa-calendar-check",
Url = _adminMenu.GetMenuItemUrl("PreorderAvailability", "Index")
};
//shippingConfigurationItem.ChildNodes.Insert(4, preorderAvailabilityMenuItem);
var preorderListMenuItem = new AdminMenuItem
{
Visible = true,
SystemName = "Preorders.List",
Title = "Előrendelések",
IconClass = "fas fa-calendar-plus",
Url = _adminMenu.GetMenuItemUrl("PreorderAdmin", "List")
};
var preordersRootMenuItem = new AdminMenuItem
{
Visible = true,
SystemName = "FruitBank",
Title = "Előrendelés",
//Title = await _localizationService.GetResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.Preorders"), // You can localize this with await _localizationService.GetResourceAsync("...")
IconClass = "fas fa-heart",
//Url = _adminMenu.GetMenuItemUrl("Shipping", "List")
//ChildNodes = [shippingsListMenuItem, createShippingMenuItem, editShippingMenuItem]
ChildNodes = [preorderAvailabilityMenuItem, preorderListMenuItem]
};
rootNode.ChildNodes.Insert(3, preordersRootMenuItem);
//shippingConfigurationItem.ChildNodes.Insert(5, preorderListMenuItem);
// Create a new top-level menu item
var InvoiceSyncMenuItem = new AdminMenuItem

View File

@ -0,0 +1,209 @@
using Nop.Core;
using Nop.Core.Domain.Common;
using Nop.Core.Domain.Messages;
using Nop.Core.Domain.Orders;
using Nop.Core.Events;
using Nop.Services.Affiliates;
using Nop.Services.Catalog;
using Nop.Services.Common;
using Nop.Services.Customers;
using Nop.Services.Localization;
using Nop.Services.Messages;
using Nop.Services.Orders;
using Nop.Services.Stores;
namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
public class FruitBankNotificationService(
CommonSettings commonSettings,
IMessageTemplateService messageTemplateService,
IEmailAccountService emailAccountService,
EmailAccountSettings emailAccountSettings,
IMessageTokenProvider messageTokenProvider,
IWorkflowMessageService workflowMessageService,
ICustomerService customerService,
IStoreContext storeContext,
IAddressService addressService,
IAffiliateService affiliateService,
IEventPublisher eventPublisher,
ILanguageService languageService,
ILocalizationService localizationService,
IOrderService orderService,
IProductService productService,
IQueuedEmailService queuedEmailService,
IStoreService storeService,
ITokenizer tokenizer,
MessagesSettings messagesSettings) : WorkflowMessageService(commonSettings,
emailAccountSettings,
addressService,
affiliateService,
customerService,
emailAccountService,
eventPublisher,
languageService,
localizationService,
messageTemplateService,
messageTokenProvider,
orderService,
productService,
queuedEmailService,
storeContext,
storeService,
tokenizer,
messagesSettings)
{
public const string ORDER_AUDITED_TEMPLATE_NAME = "FruitBank.OrderAudited.CustomerNotification";
public const string ORDER_STARTED_TEMPLATE_NAME = "FruitBank.OrderStarted.CustomerNotification";
public override async Task<IList<int>> SendOrderPlacedCustomerNotificationAsync(Order order, int languageId,
string attachmentFilePath = null, string attachmentFileName = null)
{
ArgumentNullException.ThrowIfNull(order);
var store = await _storeService.GetStoreByIdAsync(order.StoreId) ?? await _storeContext.GetCurrentStoreAsync();
languageId = await EnsureLanguageIsActiveAsync(languageId, store.Id);
var messageTemplates = await GetActiveMessageTemplatesAsync(MessageTemplateSystemNames.ORDER_PLACED_CUSTOMER_NOTIFICATION, store.Id);
if (!messageTemplates.Any())
return new List<int>();
//tokens
var commonTokens = new List<Token>();
await _messageTokenProvider.AddOrderTokensAsync(commonTokens, order, languageId);
await _messageTokenProvider.AddCustomerTokensAsync(commonTokens, order.CustomerId);
var customer = await _customerService.GetCustomerByIdAsync(order.CustomerId);
return await messageTemplates.SelectAwait(async messageTemplate =>
{
//email account
var emailAccount = await GetEmailAccountOfMessageTemplateAsync(messageTemplate, languageId);
var tokens = new List<Token>(commonTokens);
await _messageTokenProvider.AddStoreTokensAsync(tokens, store, emailAccount, languageId);
//event notification
await _eventPublisher.MessageTokensAddedAsync(messageTemplate, tokens);
var billingAddress = await _addressService.GetAddressByIdAsync(order.BillingAddressId);
string toEmail;
//we surely have shipping address for orders with shipping method, but let's be safe
if (billingAddress.Email != null)
{
if (!billingAddress.Email.EndsWith("inval.id"))
{
toEmail = billingAddress.Email;
}
else
{
Console.WriteLine($"Customer {customer.Id} has BillinggAddressId but emailaddress is invalid: {billingAddress.Email}");
return -1;
}
}
else
{
Console.WriteLine($"Customer {customer.Id} has BillinggAddressId but emailaddress not found.");
return -1;
}
var toName = $"{billingAddress.FirstName} {billingAddress.LastName}";
return await SendNotificationAsync(messageTemplate, emailAccount, languageId, tokens, toEmail, toName,
attachmentFilePath, attachmentFileName);
}).ToListAsync();
}
/// <summary>
/// Re-sends the order info email to the customer on demand from the admin.
/// Reuses the OrderPlaced template which already contains the full order table.
/// </summary>
public Task<IList<int>> SendOrderInfoEmailAsync(Order order)
=> SendOrderPlacedCustomerNotificationAsync(order, order.CustomerLanguageId);
/// <summary>
/// Sends the "order started" (being prepared) customer notification.
/// For measurable orders, informs the customer that final prices will be
/// confirmed after weighing. Fires once when MeasuringStatus transitions to Started.
/// </summary>
///
public async Task<int> SendOrderStartedCustomerNotificationAsync(Order order, bool isMeasurable)
{
var measurableNote = isMeasurable
? "<p>Rendel&#233;sed m&#233;rhet&#337; t&#233;teleket tartalmaz. A v&#233;gleges &#225;r a m&#233;r&#233;s ut&#225;n ker&#252;l meger&#337;s&#237;t&#233;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&#233;rt t&#233;telek s&#250;lyait r&#246;gz&#237;tett&#252;k, a v&#233;gleges &#225;r a rendel&#233;sen felt&#252;ntetett &#246;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));
int addressId = 0;
string customerEmail = customer.Email;
//bool customerHasShippingAddress = customer.ShippingAddressId.HasValue;
if (customer.ShippingAddressId.HasValue)
{
addressId = (int)customer.ShippingAddressId;
customerEmail = (await addressService.GetAddressByIdAsync(addressId)).Email ?? customer.Email;
}
Console.WriteLine($"Customer email determined as: {customerEmail} (addressId: {addressId})");
var toName = $"{customer.FirstName} {customer.LastName}".Trim();
if (string.IsNullOrWhiteSpace(toName)) toName = customer.Email;
return await workflowMessageService.SendNotificationAsync(
messageTemplate, emailAccount,
order.CustomerLanguageId,
tokens,
customerEmail, toName);
}
}

View File

@ -0,0 +1,231 @@
using AyCode.Core.Loggers;
using FruitBank.Common.Dtos;
using FruitBank.Common.Interfaces;
using Mango.Nop.Core.Loggers;
using Nop.Core;
using Nop.Core.Domain.Catalog;
using Nop.Core.Domain.Customers;
using Nop.Core.Domain.Orders;
using Nop.Core.Domain.Stores;
using Nop.Core.Domain.Tax;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Plugin.Misc.FruitBankPlugin.Models.Orders;
using Nop.Services.Catalog;
using Nop.Services.Localization;
using Nop.Services.Orders;
using Nop.Services.Tax;
namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
/// <summary>
/// Shared service for creating, adding and removing order items.
/// Extracted from CustomOrderController so the same logic can be reused
/// by PreorderConversionService without duplication.
/// </summary>
public class FruitBankOrderItemService
{
private readonly FruitBankDbContext _dbContext;
private readonly IProductService _productService;
private readonly IPriceCalculationService _priceCalculationService;
private readonly ITaxService _taxService;
private readonly IOrderService _orderService;
private readonly IStoreContext _storeContext;
private readonly ILocalizationService _localizationService;
private readonly ILogger _logger;
public FruitBankOrderItemService(
FruitBankDbContext dbContext,
IProductService productService,
IPriceCalculationService priceCalculationService,
ITaxService taxService,
IOrderService orderService,
IStoreContext storeContext,
ILocalizationService localizationService,
IEnumerable<IAcLogWriterBase> logWriters)
{
_dbContext = dbContext;
_productService = productService;
_priceCalculationService = priceCalculationService;
_taxService = taxService;
_orderService = orderService;
_storeContext = storeContext;
_localizationService = localizationService;
_logger = new Logger<FruitBankOrderItemService>(logWriters.ToArray());
}
// ── Create ────────────────────────────────────────────────────────────────
/// <summary>
/// Builds an OrderItem entity — no DB writes.
/// unitPricesIncludeDiscounts=true recalculates price from product;
/// false uses the supplied price as-is (manual override).
/// </summary>
public async Task<OrderItem> CreateOrderItemAsync(
Product product,
Order order,
int productId,
int quantity,
decimal price,
bool isMeasurable,
bool unitPricesIncludeDiscounts,
Customer? customer = null,
Store? store = null)
{
store ??= await _storeContext.GetCurrentStoreAsync();
customer ??= new Customer { Id = order.CustomerId };
decimal unitPriceInclTax;
if (unitPricesIncludeDiscounts)
{
var calc = await _priceCalculationService.GetFinalPriceAsync(
product, customer, store, includeDiscounts: true);
unitPriceInclTax = calc.finalPrice;
}
else
{
unitPriceInclTax = price;
}
var (unitPriceExclTax, _) = await _taxService.GetProductPriceAsync(
product, unitPriceInclTax, includingTax: false, customer);
return new OrderItem
{
OrderId = order.Id,
ProductId = productId,
Quantity = quantity,
OrderItemGuid = Guid.NewGuid(),
UnitPriceInclTax = unitPriceInclTax,
UnitPriceExclTax = unitPriceExclTax,
PriceInclTax = isMeasurable ? 0m : unitPriceInclTax * quantity,
PriceExclTax = isMeasurable ? 0m : unitPriceExclTax * quantity,
OriginalProductCost = await _priceCalculationService.GetProductCostAsync(product, null),
AttributeDescription = string.Empty,
AttributesXml = string.Empty,
DiscountAmountInclTax = decimal.Zero,
DiscountAmountExclTax = decimal.Zero,
DownloadCount = 0,
IsDownloadActivated = false,
LicenseDownloadId = 0,
ItemWeight = product.Weight * quantity
};
}
// ── Add ───────────────────────────────────────────────────────────────────
/// <summary>
/// Inserts a single OrderItem, deducts inventory, and updates order totals.
/// Does NOT check availability — caller is responsible for that guard.
/// Does NOT call UpdateOrderAsync — caller must do that after all items are added.
/// </summary>
public async Task AddOrderItemToOrderAsync(
Order order,
OrderItem orderItem,
string inventoryMessage)
{
await _orderService.InsertOrderItemAsync(orderItem);
var product = await _productService.GetProductByIdAsync(orderItem.ProductId);
if (product != null)
await _productService.AdjustInventoryAsync(
product, -orderItem.Quantity, orderItem.AttributesXml, inventoryMessage);
order.OrderSubtotalInclTax += orderItem.UnitPriceInclTax * orderItem.Quantity;
order.OrderSubtotalExclTax += orderItem.UnitPriceExclTax * orderItem.Quantity;
order.OrderTotal += orderItem.PriceInclTax;
}
/// <summary>
/// Batch add — mirrors the original CustomOrderController.AddOrderItemsThenUpdateOrder.
/// Checks availability, creates each OrderItem, adjusts inventory,
/// updates order totals, then persists the order and writes an order note.
/// </summary>
public async Task AddOrderItemsThenUpdateOrderAsync(
Order order,
IReadOnlyList<IOrderProductItemBase> items,
Customer? customer = null,
Store? store = null,
Customer? admin = null)
{
store ??= await _storeContext.GetCurrentStoreAsync();
customer ??= new Customer { Id = order.CustomerId };
admin ??= customer;
var productDtosById = await _dbContext.ProductDtos
.GetAllByIds(items.Select(x => x.Id).ToArray())
.ToDictionaryAsync(k => k.Id, v => v);
foreach (var item in items)
{
var product = await _productService.GetProductByIdAsync(item.Id);
if (product == null)
{
_logger.Warning($"AddOrderItemsThenUpdateOrderAsync: product #{item.Id} not found, skipped");
continue;
}
productDtosById.TryGetValue(item.Id, out var dto);
var isMeasurable = dto?.IsMeasurable ?? false;
var available = product.StockQuantity + (dto?.IncomingQuantity ?? 0);
if (available - item.Quantity < 0)
throw new Exception(
$"Insufficient stock for product #{product.Id} " +
$"(available: {available}, requested: {item.Quantity})");
bool useDiscountPrice = item.Price == product.Price;
var orderItem = await CreateOrderItemAsync(
product, order, item.Id, item.Quantity, item.Price,
isMeasurable, useDiscountPrice, customer, store);
var msg = string.Format(
await _localizationService.GetResourceAsync(
"Admin.StockQuantityHistory.Messages.PlaceOrder"), order.Id);
await AddOrderItemToOrderAsync(order, orderItem, msg);
}
await _orderService.UpdateOrderAsync(order);
await InsertOrderNoteAsync(order.Id, false,
$"Products added ({items.Count} item(s)) by " +
$"{admin.FirstName} {admin.LastName} (Id: {admin.Id})");
}
// ── Remove ────────────────────────────────────────────────────────────────
/// <summary>
/// Removes an OrderItem: deletes pallets/GAs/weight constraints,
/// restores inventory, deletes the item, adjusts order totals.
/// Caller must call UpdateOrderAsync after.
/// </summary>
public async Task RemoveOrderItemFromOrderAsync(
Order order,
OrderItem orderItem,
string inventoryMessage)
{
await _dbContext.DeleteOrderItemConstraintsAsync(orderItem);
var product = await _productService.GetProductByIdAsync(orderItem.ProductId);
if (product != null)
await _productService.AdjustInventoryAsync(
product, +orderItem.Quantity, orderItem.AttributesXml, inventoryMessage);
await _orderService.DeleteOrderItemAsync(orderItem);
order.OrderSubtotalInclTax -= orderItem.UnitPriceInclTax * orderItem.Quantity;
order.OrderSubtotalExclTax -= orderItem.UnitPriceExclTax * orderItem.Quantity;
order.OrderTotal -= orderItem.PriceInclTax;
}
// ── Helpers ───────────────────────────────────────────────────────────────
public Task InsertOrderNoteAsync(int orderId, bool displayToCustomer, string note)
=> _orderService.InsertOrderNoteAsync(new OrderNote
{
OrderId = orderId,
Note = note,
DisplayToCustomer = displayToCustomer,
CreatedOnUtc = DateTime.UtcNow
});
}

View File

@ -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 &lt;= CreditLimit.
/// </summary>
Task<bool> IsOrderAllowedAsync(int customerId, decimal newOrderTotal);
}

View File

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Net.Http;
using System.Threading.Tasks;
using System.Xml.Linq;
@ -156,8 +157,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
tetelElement.Add(new XElement("TetelNev", item.TetelNev ?? ""));
tetelElement.Add(new XElement("AfaSzoveg", item.AfaSzoveg ?? ""));
tetelElement.Add(new XElement("Brutto", item.Brutto ? "1" : "0"));
tetelElement.Add(new XElement("EgysegAr", item.EgysegAr.ToString()));
tetelElement.Add(new XElement("Mennyiseg", item.Mennyiseg.ToString()));
tetelElement.Add(new XElement("EgysegAr", item.EgysegAr.ToString("0.##", CultureInfo.InvariantCulture)));
tetelElement.Add(new XElement("Mennyiseg", item.Mennyiseg.ToString("0.##", CultureInfo.InvariantCulture)));
tetelElement.Add(new XElement("MennyisegEgyseg", new XCData(item.MennyisegEgyseg ?? "")));
if (!string.IsNullOrEmpty(item.CikkSzam))

View File

@ -822,14 +822,16 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
"'products' (array of objects with the following fields: " +
"'name' (string), " +
"'quantity' (int - the number of cartons, boxes or packages), " +
"'netWeight' (double - the net kilograms), " +
"'grossWeight' (double - the gross kilograms)," +
"'netWeight' (double - the net kilograms in European format, example: 1.372 kgs should be 1372,00 kgs), " +
"'grossWeight' (double - the gross kilogramsin European format, example: 1.372 kgs should be 1372,00 kgs)," +
"'unitCost (double - the unit price of the product on the document)'.\r \n \n" +
"";
string systemPrompt = "You are an AI assistant of FRUITBANK that extracts text and structured data from images. " +
"Carefully analyze the image content to extract all relevant information accurately. " +
"Provide the extracted data in a well-formatted JSON structure as specified.";
"Keep in mind, that all the information are in EU standards, so if you find '.' in numbers that is thousand separator, not decimatal point" +
"Provide the extracted data in a well-formatted JSON structure as specified. POINT IN NUMBERS IS THOUSAND SEPARATOR." +
"IMPORTANT: if you find point in numbers, return them without the point, as '.' is thousand separator, not decimal point. Example: 1.731 kgs is 1731 kgs, NOT 1,731 kgs.";
var payload = new
{

View File

@ -0,0 +1,184 @@
using FruitBank.Common.Dtos;
using FruitBank.Common.Entities;
using FruitBank.Common.Enums;
using FruitBank.Common.Interfaces;
using Nop.Core.Domain.Catalog;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Services.Catalog;
using Nop.Services.Orders;
namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
/// <summary>
/// Extension methods for product replacement logic on PreorderConversionService.
///
/// SETUP REQUIRED: Add this as a partial class by:
/// 1. Add `partial` keyword to PreorderConversionService class declaration
/// 2. This file uses the same injected fields — no extra DI needed
/// </summary>
public partial class PreorderConversionService
{
// ── Product replacement ───────────────────────────────────────────────────
/// <summary>
/// Called from FruitBankDataController.UpdateShippingItem when ProductId changes.
/// oldItem must be loaded BEFORE the DB update to capture the previous state.
/// </summary>
public async Task ReplaceShippingItemProductAsync(
int shippingItemId,
int newProductId,
ShippingItem oldItem)
{
if (oldItem.ProductId == null || oldItem.ProductId.Value == newProductId) return;
var oldProductId = oldItem.ProductId.Value;
var qty = oldItem.QuantityOnDocument;
var isMeasured = oldItem.IsMeasured;
Console.WriteLine($"[ReplaceShippingItemProduct] #{shippingItemId}: {oldProductId}→{newProductId}, qty={qty}, measured={isMeasured}");
// ── Guard: reject if any linked order is being measured ───────────────
var affectedOrders = await GetAffectedOpenOrdersAsync(oldProductId);
var startedOrders = affectedOrders
.Where(o => o.MeasuringStatus > MeasuringStatus.NotStarted)
.ToList();
if (startedOrders.Any())
throw new InvalidOperationException(
$"Mérés folyamatban a következő rendeléseken: " +
$"#{string.Join(", #", startedOrders.Select(o => o.Id))}. " +
$"Termékcsere nem lehetséges.");
// ── Stock / IncomingQuantity swap ─────────────────────────────────────
if (!isMeasured)
{
// Item not yet on the truck — only IncomingQuantity moves
await SyncIncomingQuantityAsync(oldProductId, qty, 0);
await SyncIncomingQuantityAsync(newProductId, 0, qty);
}
else
{
// Item already measured/received into stock — adjust actual stock
await _dbContext.UpdateStockQuantityAndWeightAsync(
oldProductId, -oldItem.MeasuredQuantity,
$"Termék csere: shippingItem #{shippingItemId} — {oldProductId}→{newProductId}",
oldItem.IsMeasurable ? -oldItem.MeasuredNetWeight : 0d);
await _dbContext.UpdateStockQuantityAndWeightAsync(
newProductId, +oldItem.MeasuredQuantity,
$"Termék csere: shippingItem #{shippingItemId} — {oldProductId}→{newProductId}",
oldItem.IsMeasurable ? +oldItem.MeasuredNetWeight : 0d);
}
// ── Swap order items ──────────────────────────────────────────────────
var openOrders = affectedOrders
.Where(o => o.MeasuringStatus == MeasuringStatus.NotStarted)
.OrderBy(o => o.Id)
.ToList();
var replacementBudget = qty;
var affectedOrderIds = new List<int>();
foreach (var orderDto in openOrders)
{
if (replacementBudget <= 0) break;
var order = await _dbContext.Orders.GetByIdAsync(orderDto.Id);
if (order == null) continue;
var allItems = await _orderService.GetOrderItemsAsync(order.Id);
var oldOrderItem = allItems.FirstOrDefault(oi => oi.ProductId == oldProductId);
if (oldOrderItem == null) continue;
var swapQty = Math.Min(oldOrderItem.Quantity, replacementBudget);
// Remove old item — restores stock, deletes pallets/GAs, deletes item
await _orderItemService.RemoveOrderItemFromOrderAsync(
order, oldOrderItem,
$"Termék csere: #{oldProductId}→#{newProductId}, rendelés #{order.Id}");
// Add new item — direct insert without availability check
var newProduct = await _productService.GetProductByIdAsync(newProductId);
if (newProduct != null)
{
var newProductDto = await _dbContext.ProductDtos.GetByIdAsync(newProductId, true);
var newOrderItem = await _orderItemService.CreateOrderItemAsync(
newProduct, order,
productId : newProductId,
quantity : swapQty,
price : oldOrderItem.UnitPriceInclTax,
isMeasurable : newProductDto?.IsMeasurable ?? false,
unitPricesIncludeDiscounts : false);
await _orderItemService.AddOrderItemToOrderAsync(
order, newOrderItem,
$"Termék csere felvitel: #{newProductId}, rendelés #{order.Id}");
}
await _orderService.UpdateOrderAsync(order);
await _orderItemService.InsertOrderNoteAsync(order.Id, false,
$"Termék cserélve: #{oldProductId}→#{newProductId} ({swapQty} db), " +
$"szállítói dok. #{oldItem.ShippingDocumentId}");
replacementBudget -= swapQty;
affectedOrderIds.Add(order.Id);
}
// ── Swap preorder items ───────────────────────────────────────────────
if (affectedOrderIds.Any())
{
var preorders = (await _preorderDbContext.Preorders.GetAll(false).ToListAsync())
.Where(p => p.OrderId.HasValue && affectedOrderIds.Contains(p.OrderId.Value))
.ToList();
foreach (var preorder in preorders)
{
var piList = await _preorderDbContext.PreorderItems
.GetAllByPreorderIdAsync(preorder.Id)
.ToListAsync();
foreach (var pi in piList.Where(i => i.ProductId == oldProductId))
{
pi.ProductId = newProductId;
await _preorderDbContext.PreorderItems.UpdateAsync(pi);
}
}
}
// ── Trigger conversion for new product ────────────────────────────────
// New product may now have pending preorders that can be fulfilled
await ConvertPreordersForProductsAsync(
new List<int> { newProductId },
oldItem.ShippingDocumentId);
// TODO: SignalR notification to admin hub
// TODO: SendPreorderProductReplacedNotificationAsync per affected customer
Console.WriteLine($"[ReplaceShippingItemProduct] Complete: " +
$"{affectedOrderIds.Count} orders swapped, budget remaining={replacementBudget}");
}
private async Task<List<OrderDto>> GetAffectedOpenOrdersAsync(int oldProductId)
{
// Orders that are referenced by a preorder AND contain the old product
var preorderOrderIds = (await _preorderDbContext.Preorders.GetAll(false).ToListAsync())
.Where(p => p.OrderId.HasValue)
.Select(p => p.OrderId!.Value)
.ToHashSet();
if (!preorderOrderIds.Any()) return new();
var matchingOrderIds = await _dbContext.OrderItems.Table
.Where(oi => oi.ProductId == oldProductId && preorderOrderIds.Contains(oi.OrderId))
.Select(oi => oi.OrderId)
.Distinct()
.ToListAsync();
if (!matchingOrderIds.Any()) return new();
return await _dbContext.OrderDtos
.GetAll(false)
.Where(o => matchingOrderIds.Contains(o.Id) && !o.Deleted)
.ToListAsync();
}
}

View File

@ -0,0 +1,580 @@
using FruitBank.Common.Entities;
using FruitBank.Common.Enums;
using FruitBank.Common.Interfaces;
using Nop.Core;
using Nop.Core.Domain.Catalog;
//using LinqToDB;
using Nop.Core.Domain.Orders;
using Nop.Core.Domain.Payments;
using Nop.Core.Domain.Shipping;
using Nop.Core.Events;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Services.Catalog;
using Nop.Services.Customers;
using Nop.Services.Orders;
namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
/// <summary>
/// Converts pending preorder items into real NopCommerce orders when
/// incoming stock is confirmed via shipping document processing.
///
/// Called once per shipping document save, after all IncomingQuantity
/// attributes have been written for that document's product set.
///
/// Allocation strategy: first-come-first-served by PreorderId (insertion order).
///
/// Multi-document design:
/// - Preorder.OrderId tracks the linked real order once created.
/// - First partial fulfillment → creates the order, saves OrderId on Preorder.
/// - Subsequent documents → appends only newly-fulfilled items to that same order.
/// - Dropped items are recorded in an order note but never become OrderItems.
/// </summary>
public partial class PreorderConversionService
{
private readonly PreorderDbContext _preorderDbContext;
private readonly FruitBankDbContext _dbContext;
private readonly ICustomerService _customerService;
private readonly IProductService _productService;
private readonly IEventPublisher _eventPublisher;
private readonly CustomPriceCalculationService _customPriceCalculationService;
private readonly IOrderService _orderService;
private readonly FruitBankAttributeService _fruitBankAttributeService;
private readonly FruitBankOrderItemService _orderItemService;
private readonly IStoreContext _storeContext;
public PreorderConversionService(
PreorderDbContext preorderDbContext,
FruitBankDbContext dbContext,
ICustomerService customerService,
IProductService productService,
IEventPublisher eventPublisher,
IPriceCalculationService priceCalculationService,
IOrderService orderService,
FruitBankAttributeService fruitBankAttributeService,
FruitBankOrderItemService orderItemService,
IStoreContext storeContext)
{
_preorderDbContext = preorderDbContext;
_dbContext = dbContext;
_customerService = customerService;
_productService = productService;
_eventPublisher = eventPublisher;
_customPriceCalculationService = priceCalculationService as CustomPriceCalculationService;
_orderService = orderService;
_fruitBankAttributeService = fruitBankAttributeService;
_orderItemService = orderItemService;
_storeContext = storeContext;
}
// ── Entry point ───────────────────────────────────────────────────────────
public async Task ConvertPreordersForProductsAsync(IList<int> productIds, int shippingDocumentId)
{
Console.WriteLine($"[PreorderConversion] Starting for {productIds.Count} products, shippingDocumentId={shippingDocumentId}");
// Always sweep expired preorders first — any preorder whose DateOfReceipt
// is in the past is closed regardless of stock, before we allocate anything
await SweepExpiredPreordersAsync();
var pendingItems = await _preorderDbContext.GetPendingItemsForProductsAsync(productIds);
if (!pendingItems.Any())
{
Console.WriteLine("[PreorderConversion] No pending preorder items — done.");
return;
}
// Filter out preorders whose delivery date is more than PreorderConversionWindowDays
// (4 days) away. With bi-weekly trucks, a delivery that far out will be served
// by the next truck's document — converting now would steal stock from
// earlier deliveries that legitimately need it.
var conversionCutoff = DateTime.UtcNow.Date.AddDays(FruitBankPluginConst.PreorderConversionWindowDays);
var pendingPreorderIds = pendingItems.Select(i => i.PreorderId).Distinct().ToList();
var parentPreorders = await _preorderDbContext.Preorders
.GetAll(false)
.Where(p => pendingPreorderIds.Contains(p.Id))
.ToListAsync();
var eligiblePreorderIds = parentPreorders
.Where(p => p.DateOfReceipt.Date <= conversionCutoff)
.Select(p => p.Id)
.ToHashSet();
pendingItems = pendingItems.Where(i => eligiblePreorderIds.Contains(i.PreorderId)).ToList();
if (!pendingItems.Any())
{
Console.WriteLine($"[PreorderConversion] All pending preorders are beyond the " +
$"{FruitBankPluginConst.PreorderConversionWindowDays}-day window — skipped.");
return;
}
Console.WriteLine($"[PreorderConversion] {pendingItems.Count} items eligible " +
$"(within {FruitBankPluginConst.PreorderConversionWindowDays}-day window).");
var incomingPool = await BuildIncomingQuantityPoolAsync(productIds);
// Track which items were newly resolved in THIS run, grouped by preorder
// Key: preorderId Value: list of items whose status changed in this run
var newlyResolvedByPreorder = new Dictionary<int, List<PreorderItem>>();
foreach (var item in pendingItems)
{
var prevFulfilled = item.FulfilledQuantity;
if (!incomingPool.TryGetValue(item.ProductId, out var available) || available <= 0)
{
// No stock available in this document run — leave item Pending
// so it can be picked up by a future document. The expiry sweep
// above handles permanent closure once DateOfReceipt is past.
continue;
}
else
{
var fulfill = Math.Min(item.RequestedQuantity - item.FulfilledQuantity, available);
item.FulfilledQuantity += fulfill;
incomingPool[item.ProductId] -= fulfill;
item.Status = item.FulfilledQuantity >= item.RequestedQuantity
? PreorderItemStatus.Fulfilled
: item.FulfilledQuantity > 0
? PreorderItemStatus.PartiallyFulfilled
: PreorderItemStatus.Dropped;
await _preorderDbContext.PreorderItems.UpdateAsync(item);
}
// Only track this item if something actually changed this run
// (i.e. it gained fulfilled quantity or got dropped)
var gainedQuantity = item.FulfilledQuantity - prevFulfilled;
bool wasDropped = item.Status == PreorderItemStatus.Dropped && prevFulfilled == 0;
if (gainedQuantity > 0 || wasDropped)
{
if (!newlyResolvedByPreorder.ContainsKey(item.PreorderId))
newlyResolvedByPreorder[item.PreorderId] = new List<PreorderItem>();
newlyResolvedByPreorder[item.PreorderId].Add(item);
}
Console.WriteLine($"[PreorderConversion] Item #{item.Id} (product {item.ProductId}): " +
$"requested={item.RequestedQuantity}, fulfilled={item.FulfilledQuantity}, " +
$"gained={item.FulfilledQuantity - prevFulfilled}, status={item.Status}");
}
// Process each affected preorder
foreach (var (preorderId, changedItems) in newlyResolvedByPreorder)
{
await _preorderDbContext.RefreshPreorderStatusAsync(preorderId);
var preorder = await _preorderDbContext.Preorders.GetByIdAsync(preorderId);
if (preorder == null) continue;
// Items newly gaining fulfilled quantity in this run
var newlyFulfilled = changedItems
.Where(i => i.FulfilledQuantity - 0 > 0 &&
(i.Status == PreorderItemStatus.Fulfilled ||
i.Status == PreorderItemStatus.PartiallyFulfilled))
.ToList();
// Items dropped in this run (no stock at all)
var newlyDropped = changedItems
.Where(i => i.Status == PreorderItemStatus.Dropped)
.ToList();
if (preorder.OrderId == null)
{
// First time any items are resolved → create the order
if (newlyFulfilled.Any() || newlyDropped.Any())
{
await CreateOrderAsync(preorder, newlyFulfilled, newlyDropped, shippingDocumentId);
}
}
else
{
// Order already exists from a previous document → append new items only
await AppendItemsToOrderAsync(preorder, newlyFulfilled, newlyDropped, shippingDocumentId);
}
}
Console.WriteLine($"[PreorderConversion] Done. {newlyResolvedByPreorder.Count} preorders affected.");
}
// ── Expiry sweep ───────────────────────────────────────────────────────────
/// <summary>
/// Closes all preorders whose DateOfReceipt is in the past and still have
/// Pending or PartiallyFulfilled items. Any still-Pending items become Dropped.
/// Items that were already Fulfilled/PartiallyFulfilled stay as-is (those
/// quantities already made it into a real order).
/// Called at the start of every conversion run.
/// </summary>
private async Task SweepExpiredPreordersAsync()
{
var now = DateTime.UtcNow;
var activePreorderStatuses = new[] { PreorderStatus.Pending, PreorderStatus.PartiallyFulfilled };
// Find preorders that are past their receipt date — fetch by date only,
// then filter by status in memory (LinqToDB can't translate enum comparisons)
var expiredPreorders = (await _preorderDbContext.Preorders
.GetAll(false)
.Where(p => p.DateOfReceipt < now)
.ToListAsync())
.Where(p => p.Status == PreorderStatus.Pending ||
p.Status == PreorderStatus.PartiallyFulfilled)
.ToList();
if (!expiredPreorders.Any()) return;
Console.WriteLine($"[PreorderConversion] Sweeping {expiredPreorders.Count} expired preorders");
foreach (var preorder in expiredPreorders)
{
var items = await _preorderDbContext.PreorderItems
.GetAllByPreorderIdAsync(preorder.Id)
.ToListAsync();
// Drop only the items that were never fulfilled — already-fulfilled
// items stay as-is since they are already on a real order
var stillPending = items.Where(i => i.Status == PreorderItemStatus.Pending).ToList();
foreach (var item in stillPending)
{
item.Status = PreorderItemStatus.Dropped;
await _preorderDbContext.PreorderItems.UpdateAsync(item);
}
// Recalculate header status
await _preorderDbContext.RefreshPreorderStatusAsync(preorder.Id);
var hadAnyFulfillment = items.Any(i =>
i.Status == PreorderItemStatus.Fulfilled ||
i.Status == PreorderItemStatus.PartiallyFulfilled);
Console.WriteLine($"[PreorderConversion] Expired preorder #{preorder.Id}: " +
$"{stillPending.Count} items dropped, " +
$"hadFulfillment={hadAnyFulfillment}, orderId={preorder.OrderId}");
// TODO: Send expiry notification if nothing was ever fulfilled
// (fully unfulfilled preorders — customer should be notified)
// if (!hadAnyFulfillment)
// await _fruitBankNotificationService.SendPreorderExpiredNotificationAsync(preorder);
}
}
// ── Create new order (first document that fulfills anything) ──────────────
private async Task CreateOrderAsync(
Preorder preorder,
List<PreorderItem> fulfilledItems,
List<PreorderItem> droppedItems,
int shippingDocumentId)
{
var customer = await _customerService.GetCustomerByIdAsync(preorder.CustomerId);
if (customer == null)
{
Console.WriteLine($"[PreorderConversion] Customer {preorder.CustomerId} not found — skipping order creation for preorder #{preorder.Id}");
return;
}
var billingAddressId = customer.BillingAddressId ?? 0;
if (billingAddressId == 0)
{
var addrMapping = await _dbContext.CustomerAddressMappings.Table
.Where(m => m.CustomerId == customer.Id)
.FirstOrDefaultAsync();
billingAddressId = addrMapping?.AddressId ?? 0;
}
if (billingAddressId == 0)
{
Console.WriteLine($"[PreorderConversion] No billing address for customer {customer.Id} — skipping for preorder #{preorder.Id}");
return;
}
var orderTotal = await CalculateTotalAsync(fulfilledItems);
var order = new Order
{
OrderGuid = Guid.NewGuid(),
StoreId = preorder.StoreId,
CustomerId = preorder.CustomerId,
BillingAddressId = billingAddressId,
OrderStatusId = (int)OrderStatus.Pending,
PaymentStatusId = (int)PaymentStatus.Pending,
ShippingStatusId = (int)ShippingStatus.NotYetShipped,
PaymentMethodSystemName = "Payments.CheckMoneyOrder",
CustomerLanguageId = 1,
CustomerTaxDisplayTypeId = 0,
OrderSubtotalInclTax = orderTotal,
OrderSubtotalExclTax = Math.Round(orderTotal / 1.27m, 2),
OrderSubTotalDiscountInclTax = 0m,
OrderSubTotalDiscountExclTax = 0m,
OrderShippingInclTax = 0m,
OrderShippingExclTax = 0m,
PaymentMethodAdditionalFeeInclTax = 0m,
PaymentMethodAdditionalFeeExclTax = 0m,
TaxRates = "0:0;",
OrderTax = 0m,
OrderTotal = orderTotal,
RefundedAmount = 0m,
CustomerCurrencyCode = "HUF",
CurrencyRate = 1m,
OrderDiscount = 0m,
CheckoutAttributeDescription = string.Empty,
CheckoutAttributesXml = string.Empty,
CustomerIp = string.Empty,
AllowStoringCreditCardNumber = false,
CardType = string.Empty,
CardName = string.Empty,
CardNumber = string.Empty,
MaskedCreditCardNumber = string.Empty,
CardCvv2 = string.Empty,
CardExpirationMonth = string.Empty,
CardExpirationYear = string.Empty,
AuthorizationTransactionId = string.Empty,
AuthorizationTransactionCode = string.Empty,
AuthorizationTransactionResult = string.Empty,
CaptureTransactionId = string.Empty,
CaptureTransactionResult = string.Empty,
SubscriptionTransactionId = string.Empty,
PaidDateUtc = null,
ShippingMethod = string.Empty,
ShippingRateComputationMethodSystemName = string.Empty,
Deleted = false,
CreatedOnUtc = DateTime.UtcNow,
CustomOrderNumber = string.Empty
};
await _dbContext.Orders.InsertAsync(order);
order.CustomOrderNumber = order.Id.ToString();
await _dbContext.Orders.UpdateAsync(order);
// Save OrderId back on the Preorder so future documents can find it
preorder.OrderId = order.Id;
preorder.UpdatedOnUtc = DateTime.UtcNow;
await _preorderDbContext.Preorders.UpdateAsync(preorder);
// DateOfReceipt generic attribute
await _dbContext.GenericAttributes.InsertAsync(new Nop.Core.Domain.Common.GenericAttribute
{
EntityId = order.Id, KeyGroup = nameof(Order), Key = "DateOfReceipt",
Value = preorder.DateOfReceipt.ToString("O"), StoreId = preorder.StoreId,
CreatedOrUpdatedDateUTC = DateTime.UtcNow
});
await InsertOrderItemsAsync(order, fulfilledItems);
await InsertOrderNoteAsync(order.Id, preorder.Id, shippingDocumentId, fulfilledItems, droppedItems);
// Fire event so existing handlers (EventConsumer etc.) run
await _eventPublisher.PublishAsync(new OrderPlacedEvent(order));
// TODO: Send "FruitBank.PreorderConverted.CustomerNotification" email
// summarising fulfilled items, dropped items, order ID, DateOfReceipt
// await _fruitBankNotificationService.SendPreorderConvertedNotificationAsync(order, preorder, fulfilledItems, droppedItems);
Console.WriteLine($"[PreorderConversion] Created Order #{order.Id} from Preorder #{preorder.Id} — " +
$"{fulfilledItems.Count} fulfilled, {droppedItems.Count} dropped, total {orderTotal:N0} Ft");
}
// ── Append to existing order (subsequent documents) ───────────────────────
private async Task AppendItemsToOrderAsync(
Preorder preorder,
List<PreorderItem> newlyFulfilled,
List<PreorderItem> newlyDropped,
int shippingDocumentId)
{
var order = await _dbContext.Orders.GetByIdAsync(preorder.OrderId!.Value);
if (order == null)
{
Console.WriteLine($"[PreorderConversion] Preorder #{preorder.Id} references Order #{preorder.OrderId} which no longer exists — creating fresh");
preorder.OrderId = null;
await CreateOrderAsync(preorder, newlyFulfilled, newlyDropped, shippingDocumentId);
return;
}
if (!newlyFulfilled.Any() && !newlyDropped.Any())
{
Console.WriteLine($"[PreorderConversion] Preorder #{preorder.Id}: no new items to append to Order #{order.Id}");
return;
}
// Append new OrderItems for the newly fulfilled items only
await InsertOrderItemsAsync(order, newlyFulfilled);
// Recalculate order total from all order items
var allItems = await _dbContext.OrderItems.Table
.Where(oi => oi.OrderId == order.Id)
.ToListAsync();
var newTotal = 0m;
foreach (var oi in allItems)
newTotal += oi.PriceInclTax;
order.OrderTotal = newTotal;
order.OrderSubtotalInclTax = newTotal;
order.OrderSubtotalExclTax = Math.Round(newTotal / 1.27m, 2);
await _dbContext.Orders.UpdateAsync(order);
// Add a note for this document's contribution
await InsertOrderNoteAsync(order.Id, preorder.Id, shippingDocumentId, newlyFulfilled, newlyDropped);
// TODO: Send update notification email (same template as initial, but framed as an update)
// await _fruitBankNotificationService.SendPreorderConvertedNotificationAsync(order, preorder, newlyFulfilled, newlyDropped);
Console.WriteLine($"[PreorderConversion] Appended {newlyFulfilled.Count} items to Order #{order.Id} " +
$"from Preorder #{preorder.Id} via document #{shippingDocumentId}. " +
$"New total: {newTotal:N0} Ft");
}
// ── Shared helpers ────────────────────────────────────────────────────────
private async Task InsertOrderItemsAsync(Order order, List<PreorderItem> items)
{
foreach (var item in items)
{
var productDto = await _dbContext.ProductDtos.GetByIdAsync(item.ProductId, true);
if (productDto == null) continue;
var product = await _productService.GetProductByIdAsync(item.ProductId);
if (product == null) continue;
var unitPriceExclTax = Math.Round(item.UnitPriceInclTax / 1.27m, 4);
var priceInclTax = productDto.IsMeasurable ? 0m : item.UnitPriceInclTax * item.FulfilledQuantity;
var priceExclTax = productDto.IsMeasurable ? 0m : unitPriceExclTax * item.FulfilledQuantity;
var orderItem = new OrderItem
{
OrderItemGuid = Guid.NewGuid(),
OrderId = order.Id,
ProductId = item.ProductId,
Quantity = item.FulfilledQuantity,
UnitPriceInclTax = item.UnitPriceInclTax,
UnitPriceExclTax = unitPriceExclTax,
PriceInclTax = priceInclTax,
PriceExclTax = priceExclTax,
DiscountAmountInclTax = 0m,
DiscountAmountExclTax = 0m,
OriginalProductCost = 0m,
AttributeDescription = string.Empty,
AttributesXml = string.Empty,
DownloadCount = 0,
IsDownloadActivated = false,
LicenseDownloadId = 0,
RentalStartDateUtc = null,
RentalEndDateUtc = null
};
// Use the service (fires NopCommerce events) instead of direct DB insert
await _orderService.InsertOrderItemAsync(orderItem);
// Deduct from stock — same as CustomOrderController and FruitBankOrderItemService
await _productService.AdjustInventoryAsync(
product,
-item.FulfilledQuantity,
string.Empty,
$"Előrendelés #{item.PreorderId} — rendelés #{order.Id} létrehozása");
}
}
private async Task InsertOrderNoteAsync(
int orderId, int preorderId, int shippingDocumentId,
List<PreorderItem> fulfilled, List<PreorderItem> dropped)
{
var fulfilledDesc = fulfilled.Any()
? $"Teljesített: {string.Join(", ", fulfilled.Select(i => $"#{i.ProductId} ({i.FulfilledQuantity} db)"))}"
: "Nincs teljesített tétel";
var droppedDesc = dropped.Any()
? $"Ejtett: {string.Join(", ", dropped.Select(i => $"#{i.ProductId}"))}"
: string.Empty;
var docRef = shippingDocumentId > 0
? $"szállítási dokumentum #{shippingDocumentId}"
: "azonnali készletből (előrendelés leadásakor)";
var note = new OrderNote
{
OrderId = orderId,
Note = $"Előrendelés #{preorderId} — {docRef}. " +
$"{fulfilledDesc}. {droppedDesc}".TrimEnd('.', ' ') + ".",
DisplayToCustomer = false,
CreatedOnUtc = DateTime.UtcNow
};
await _orderService.InsertOrderNoteAsync(note);
}
// ── IncomingQuantity sync ────────────────────────────────────────
public async Task SyncIncomingQuantityAsync(int productId, int oldQty, int newQty)
{
var delta = newQty - oldQty;
if (delta == 0 || productId <= 0) return;
var storeId = (await _storeContext.GetCurrentStoreAsync()).Id;
var current = await _fruitBankAttributeService
.GetGenericAttributeValueAsync<Product, int>(
productId, nameof(IIncomingQuantity.IncomingQuantity), storeId);
var updated = Math.Max(0, current + delta);
await _fruitBankAttributeService
.InsertOrUpdateGenericAttributeAsync<Product, int>(
productId, nameof(IIncomingQuantity.IncomingQuantity), updated, storeId);
Console.WriteLine($"[PreorderConversion] SyncIncomingQty product #{productId}: {current}+({delta})={updated}");
}
private async Task<decimal> CalculateTotalAsync(List<PreorderItem> items)
{
var total = 0m;
foreach (var item in items)
{
var productDto = await _dbContext.ProductDtos.GetByIdAsync(item.ProductId, true);
if (productDto == null || productDto.IsMeasurable) continue;
total += item.UnitPriceInclTax * item.FulfilledQuantity;
}
return total;
}
private async Task<Dictionary<int, int>> BuildIncomingQuantityPoolAsync(IList<int> productIds)
{
// 1. AvailableQuantity from ProductDto already accounts for
// StockQuantity + IncomingQuantity (stock is allowed to go negative
// to the limit of IncomingQuantity in the FruitBank stock model)
var productDtos = await _dbContext.ProductDtos
.GetAllByIds(productIds, loadRelations: false)
.ToListAsync();
var availableByProduct = productDtos.ToDictionary(
p => p.Id,
p => p.AvailableQuantity);
var activeItemStatuses = new[] { PreorderItemStatus.Fulfilled, PreorderItemStatus.PartiallyFulfilled };
// 2. Subtract quantities already committed to preorders in previous runs
// Fetch by productId only, filter by status in memory
var allCommittedItems = await _preorderDbContext.PreorderItems.Table
.Where(i => productIds.Contains(i.ProductId))
.ToListAsync();
var alreadyAllocated = allCommittedItems
.Where(i => i.Status == PreorderItemStatus.Fulfilled ||
i.Status == PreorderItemStatus.PartiallyFulfilled)
.GroupBy(i => i.ProductId)
.Select(g => new { ProductId = g.Key, Allocated = g.Sum(i => i.FulfilledQuantity) })
.ToList();
var allocatedByProduct = alreadyAllocated.ToDictionary(x => x.ProductId, x => x.Allocated);
// 3. Net pool = available already committed to preorders
var result = new Dictionary<int, int>();
foreach (var productId in productIds)
{
var available = availableByProduct.TryGetValue(productId, out var avail) ? avail : 0;
var committed = allocatedByProduct.TryGetValue(productId, out var alloc) ? alloc : 0;
result[productId] = Math.Max(0, available - committed);
}
return result;
}
}

View File

@ -0,0 +1,235 @@
using Mango.Nop.Core.Loggers;
using Nop.Services.Configuration;
using System.Text;
using System.Text.Json;
#nullable enable
namespace Nop.Plugin.Misc.FruitBankPlugin.Services
{
/// <summary>
/// Z.ai GLM-OCR service — szállítólevelek, rendelési dokumentumok strukturált szövegkinyerésére.
/// Endpoint: POST https://api.z.ai/api/paas/v4/layout_parsing
/// Konfiguráció: FruitBankSettings.ZaiApiKey (+ opcionális ZaiModel, default: "glm-ocr")
///
/// Output formátum: Markdown + HTML vegyes szöveg (md_results mező).
/// A táblázatokat &lt;table&gt;/&lt;thead&gt;/&lt;td&gt; tagekben adja vissza — LLM-nek közvetlenül átadható.
/// </summary>
public class ZaiService
{
private const string LayoutParsingEndpoint = "https://api.z.ai/api/paas/v4/layout_parsing";
private const string DefaultModel = "glm-ocr";
private readonly ISettingService _settingService;
private readonly FruitBankSettings _settings;
private readonly HttpClient _httpClient;
private readonly ILogger<ZaiService> _logger;
public ZaiService(
ISettingService settingService,
HttpClient httpClient,
ILogger<ZaiService> logger)
{
_settingService = settingService;
_settings = _settingService.LoadSetting<FruitBankSettings>();
_httpClient = httpClient;
_logger = logger;
}
private string ApiKey => _settings.ZaiApiKey
?? throw new InvalidOperationException("ZAI API kulcs nincs konfigurálva (FruitBankSettings.ZaiApiKey).");
private string Model => string.IsNullOrWhiteSpace(_settings.ZaiModel)
? DefaultModel
: _settings.ZaiModel;
// ── Publikus API ─────────────────────────────────────────────────────────────
/// <summary>
/// OCR elemzés nyilvánosan elérhető URL alapján (kép vagy PDF).
/// </summary>
/// <param name="fileUrl">Nyilvánosan elérhető HTTP(S) URL. Kép: max 10 MB, PDF: max 50 MB / 100 oldal.</param>
public async Task<ZaiOcrResult> AnalyzeUrlAsync(string fileUrl)
{
if (string.IsNullOrWhiteSpace(fileUrl))
return ZaiOcrResult.Failure("A fileUrl paraméter üres.");
var body = JsonSerializer.Serialize(new { model = Model, file = fileUrl });
return await CallApiAsync(body);
}
/// <summary>
/// OCR elemzés memóriából (Stream).
/// A stream tartalma base64-re konvertálódik, majd data URI-ként kerül az API-hoz.
/// </summary>
/// <param name="stream">Kép vagy PDF stream.</param>
/// <param name="mimeType">MIME típus, pl. "image/jpeg", "application/pdf".</param>
public async Task<ZaiOcrResult> AnalyzeStreamAsync(Stream stream, string mimeType)
{
if (stream == null || stream.Length == 0)
return ZaiOcrResult.Failure("Az átadott stream üres.");
byte[] bytes;
using (var ms = new MemoryStream())
{
await stream.CopyToAsync(ms);
bytes = ms.ToArray();
}
return await AnalyzeBase64Async(Convert.ToBase64String(bytes), mimeType);
}
/// <summary>
/// OCR elemzés base64 kódolt adat alapján.
/// Ha az adat még nem tartalmazza a "data:" prefixet, automatikusan data URI-vá alakítja.
/// </summary>
/// <param name="base64Data">Nyers base64 vagy teljes data URI.</param>
/// <param name="mimeType">MIME típus (csak nyers base64 esetén szükséges).</param>
public async Task<ZaiOcrResult> AnalyzeBase64Async(string base64Data, string mimeType = "image/jpeg")
{
if (string.IsNullOrWhiteSpace(base64Data))
return ZaiOcrResult.Failure("A base64Data paraméter üres.");
var dataUri = base64Data.StartsWith("data:", StringComparison.OrdinalIgnoreCase)
? base64Data
: $"data:{mimeType};base64,{base64Data}";
var body = JsonSerializer.Serialize(new { model = Model, file = dataUri });
return await CallApiAsync(body);
}
// ── Segédmetódus: MIME típus meghatározása fájlnév alapján ──────────────────
/// <summary>
/// Fájlkiterjesztés alapján visszaadja a megfelelő MIME típust.
/// </summary>
public static string GetMimeType(string fileName)
{
var ext = Path.GetExtension(fileName).ToLowerInvariant();
return ext switch
{
".pdf" => "application/pdf",
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
".tif" or ".tiff" => "image/tiff",
_ => "application/octet-stream"
};
}
// ── Belső API hívás ──────────────────────────────────────────────────────────
private async Task<ZaiOcrResult> CallApiAsync(string jsonBody)
{
using var request = new HttpRequestMessage(HttpMethod.Post, LayoutParsingEndpoint);
request.Headers.Add("Authorization", $"Bearer {ApiKey}");
request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
try
{
var response = await _httpClient.SendAsync(request);
var responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
_logger.Error("ZAI API hiba {StatusCode}: {Body}", new Exception(responseBody));
return ZaiOcrResult.Failure($"API hiba {(int)response.StatusCode}: {responseBody}");
}
using var doc = JsonDocument.Parse(responseBody);
var root = doc.RootElement;
var markdown = TryGetMarkdown(root);
// Token statisztika logolása (debug szinten, hogy ne spammelje a logot)
if (root.TryGetProperty("usage", out var usage))
{
var prompt = usage.TryGetProperty("prompt_tokens", out var pt) ? pt.GetInt32() : 0;
var completion = usage.TryGetProperty("completion_tokens", out var ct) ? ct.GetInt32() : 0;
_logger.Debug("ZAI GLM-OCR token felhasználás: {Prompt} + {Completion} = {Total}" +$"{prompt}, {completion}, {prompt} + {completion}");
}
if (string.IsNullOrEmpty(markdown))
{
_logger.Warning("ZAI GLM-OCR: md_results mező üres. Raw válasz: {Body}", responseBody);
return ZaiOcrResult.Failure("Az OCR eredmény üres (md_results mező hiányzik a válaszból).");
}
_logger.Debug($"ZAI GLM-OCR sikeres, karakter { markdown.Length}");
return ZaiOcrResult.Success(markdown, responseBody);
}
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
{
_logger.Error("ZAI GLM-OCR időtúllépés", ex);
return ZaiOcrResult.Failure("Időtúllépés: a GLM-OCR API nem válaszolt időben. Nagy PDF-eknél növeld a HttpClient timeout-ját.");
}
catch (Exception ex)
{
_logger.Error("ZAI GLM-OCR hívás kivétellel végződött", ex);
return ZaiOcrResult.Failure($"Hálózati hiba: {ex.Message}");
}
}
/// <summary>
/// Az md_results mezőt keresi elsőként (layout_parsing API), majd fallback-eket próbál
/// a chat completion API formátumhoz — így a service toleráns az esetleges API verziókkal szemben.
/// </summary>
private static string TryGetMarkdown(JsonElement root)
{
// Elsődleges: layout_parsing endpoint saját mezője
if (root.TryGetProperty("md_results", out var mdResults))
{
var val = mdResults.GetString();
if (!string.IsNullOrEmpty(val)) return val;
}
// Fallback 1: chat completion stílusú choices tömb
if (root.TryGetProperty("choices", out var choices) &&
choices.GetArrayLength() > 0 &&
choices[0].TryGetProperty("message", out var msg) &&
msg.TryGetProperty("content", out var content))
{
var val = content.GetString();
if (!string.IsNullOrEmpty(val)) return val;
}
// Fallback 2: egyszerű result mező
if (root.TryGetProperty("result", out var result))
{
var val = result.GetString();
if (!string.IsNullOrEmpty(val)) return val;
}
return string.Empty;
}
}
// ── Result record ────────────────────────────────────────────────────────────────
/// <summary>
/// A ZaiService által visszaadott OCR eredmény.
/// </summary>
public sealed class ZaiOcrResult
{
public bool IsSuccess { get; private init; }
/// <summary>
/// A teljes dokumentum Markdown+HTML vegyes formátumban.
/// Táblázatokat &lt;table&gt;/&lt;th&gt;/&lt;td&gt; tagek tartalmazzák — LLM promptba közvetlenül illeszthető.
/// </summary>
public string Markdown { get; private init; } = string.Empty;
/// <summary>
/// A nyers JSON válasz (diagnosztikához / layout_details feldolgozáshoz).
/// </summary>
public string? RawResponse { get; private init; }
public string? ErrorMessage { get; private init; }
public static ZaiOcrResult Success(string markdown, string raw) =>
new() { IsSuccess = true, Markdown = markdown, RawResponse = raw };
public static ZaiOcrResult Failure(string error) =>
new() { IsSuccess = false, ErrorMessage = error };
}
}

View File

@ -0,0 +1,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>

View File

@ -0,0 +1,286 @@
@using FruitBank.Common.Enums
@using Nop.Plugin.Misc.FruitBankPlugin.Controllers
@model List<CustomerPreorderController.CustomerPreorderRow>
@{
Layout = "_ColumnsTwo";
ViewBag.Title = "Előrendeléseim";
}
<link rel="stylesheet" href="~/Plugins/Misc.FruitBankPlugin/css/quick-order.css" />
<div class="page account-page my-preorders-page">
<div class="page-title">
<h1>Előrendeléseim</h1>
</div>
<div class="page-body">
@if (!Model.Any())
{
<div class="no-data">
<p>Még nem adtál le előrendelést.</p>
<a href="@Url.Action("Index", "Order")" class="button-1">Rendelés indítása</a>
</div>
}
else
{
foreach (var preorder in Model)
{
var statusClass = preorder.Status switch
{
PreorderStatus.Confirmed => "po-status-confirmed",
PreorderStatus.PartiallyFulfilled => "po-status-partial",
PreorderStatus.Cancelled => "po-status-cancelled",
_ => "po-status-pending"
};
var statusLabel = preorder.Status switch
{
PreorderStatus.Confirmed => "Megerősítve",
PreorderStatus.PartiallyFulfilled => "Részben teljesítve",
PreorderStatus.Cancelled => "Törölve / Lejárt",
_ => "Függőben"
};
<div class="po-customer-card">
<div class="po-card-header">
<div class="po-card-meta">
<span class="po-card-id">#@preorder.PreorderId előrendelés</span>
<span class="po-card-date">
<i class="fa fa-calendar"></i>
Kért szállítás: <strong>@preorder.DateOfReceipt.ToLocalTime().ToString("yyyy. MM. dd. HH:mm")</strong>
</span>
<span class="po-card-created">
Leadva: @preorder.CreatedOnUtc.ToLocalTime().ToString("yyyy. MM. dd.")
</span>
</div>
<div class="po-card-status-wrap">
<span class="po-status-badge @statusClass">@statusLabel</span>
@if (preorder.OrderId.HasValue)
{
<a href="@Url.RouteUrl("OrderDetails", new { orderId = preorder.OrderId })"
class="po-order-link">
<i class="fa fa-external-link"></i> Rendelés #@preorder.OrderId
</a>
}
</div>
</div>
@if (!string.IsNullOrWhiteSpace(preorder.CustomerNote))
{
<div class="po-card-note">
<i class="fa fa-comment-o"></i>
@preorder.CustomerNote
</div>
}
<div class="po-card-items">
<table class="po-items-table">
<thead>
<tr>
<th>Termék</th>
<th class="text-center">Kérve</th>
<th class="text-center">Teljesítve</th>
<th class="text-right">Egységár</th>
<th class="text-center po-status-col">Állapot</th>
</tr>
</thead>
<tbody>
@foreach (var item in preorder.Items)
{
var itemStatusLabel = item.Status switch
{
PreorderItemStatus.Fulfilled => "✓ Teljesítve",
PreorderItemStatus.PartiallyFulfilled => "◑ Részben",
PreorderItemStatus.Dropped => "✕ Ejtve",
_ => "⏳ Vár"
};
var itemStatusClass = item.Status switch
{
PreorderItemStatus.Fulfilled => "item-fulfilled",
PreorderItemStatus.PartiallyFulfilled => "item-partial",
PreorderItemStatus.Dropped => "item-dropped",
_ => "item-pending"
};
var unitPrice = item.IsMeasurable
? "Súlymérés"
: item.UnitPriceInclTax.ToString("N0") + " Ft/db";
<tr class="@itemStatusClass">
<td>
@item.ProductName
@if (item.IsMeasurable)
{
<span class="measurable-tag" title="Súlymérést igényel">⚖️</span>
}
</td>
<td class="text-center">@item.RequestedQuantity db</td>
<td class="text-center">@item.FulfilledQuantity db</td>
<td class="text-right">@unitPrice</td>
<td class="text-center">
<span class="item-status-label @itemStatusClass">@itemStatusLabel</span>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}
</div>
</div>
<style>
/* ── Page ─────────────────────────────────────────────────────── */
.my-preorders-page .page-title h1 {
font-size: 24px;
color: #1a3c22;
font-weight: 700;
}
.no-data {
text-align: center;
padding: 48px 20px;
color: #6b7c6e;
}
.no-data p { margin-bottom: 16px; font-size: 15px; }
/* ── Preorder card ────────────────────────────────────────────── */
.po-customer-card {
background: #fff;
border: 1px solid #dde8da;
border-radius: 10px;
margin-bottom: 20px;
overflow: hidden;
}
.po-card-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 16px 20px;
background: #f5f7f2;
border-bottom: 1px solid #dde8da;
}
.po-card-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 14px;
font-size: 13px;
color: #6b7c6e;
}
.po-card-id {
font-weight: 700;
color: #1a3c22;
font-size: 14px;
}
.po-card-date strong { color: #1a3c22; }
.po-card-status-wrap {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
/* Status badges */
.po-status-badge {
display: inline-block;
padding: 3px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 700;
}
.po-status-pending { background: #fff3cd; color: #856404; }
.po-status-confirmed { background: #d4edda; color: #155724; }
.po-status-partial { background: #fff8ee; color: #c87500; }
.po-status-cancelled { background: #f8d7da; color: #721c24; }
.po-order-link {
font-size: 12px;
font-weight: 600;
color: #2d7a3a;
text-decoration: none;
border: 1px solid #2d7a3a;
border-radius: 4px;
padding: 2px 10px;
}
.po-order-link:hover { background: #2d7a3a; color: #fff; }
/* Note */
.po-card-note {
padding: 10px 20px;
font-size: 13px;
color: #6b7c6e;
background: #fffdf7;
border-bottom: 1px solid #dde8da;
}
.po-card-note .fa { margin-right: 6px; color: #f4a236; }
/* Items table */
.po-card-items { padding: 0; }
.po-items-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.po-items-table th {
padding: 8px 14px;
background: #f0f4ee;
color: #1a3c22;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .5px;
border-bottom: 1px solid #dde8da;
}
.po-items-table td {
padding: 10px 14px;
border-bottom: 1px solid #f0f4ee;
color: #2c3e2e;
vertical-align: middle;
}
.po-items-table tr:last-child td { border-bottom: none; }
.po-items-table tr.item-fulfilled { background: #f6fbf4; }
.po-items-table tr.item-partial { background: #fffbf0; }
.po-items-table tr.item-dropped { background: #fdf6f6; color: #999; }
.item-status-label {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.item-status-label.item-fulfilled { background: #d4edda; color: #155724; }
.item-status-label.item-partial { background: #fff8ee; color: #c87500; }
.item-status-label.item-dropped { background: #f8d7da; color: #721c24; }
.item-status-label.item-pending { background: #fff3cd; color: #856404; }
.measurable-tag { margin-left: 4px; font-size: 13px; }
.text-center { text-align: center; }
.text-right { text-align: right; }
@@media (max-width: 600px) {
.po-status-col { display: none; }
.po-items-table th:last-child,
.po-items-table td:last-child { display: none; }
}
</style>

View File

@ -0,0 +1,10 @@
<li class="customer-navigation-item @(Context.Request.Path.Value?.Contains("elorerendeles") == true ? "active" : "")">
<a href="@Url.Action("List", "CustomerPreorder")">
Előrendeléseim
</a>
</li>
<li class="customer-navigation-item @(Context.Request.Path.Value?.Contains("segitseg") == true ? "active" : "")">
<a href="@Url.Action("Index", "Help")">
<i class="fa fa-question-circle" style="margin-right:5px;color:#2d7a3a;"></i> Segítség
</a>
</li>

View File

@ -0,0 +1,533 @@
@{
Layout = "_Root";
ViewBag.Title = "Segítség";
}
<style>
.help-page {
max-width: 780px;
margin: 0 auto;
padding: 0 0 60px;
font-family: 'DM Sans', sans-serif;
}
.help-hero {
background: linear-gradient(135deg, #1a3c22 0%, #2d7a3a 100%);
border-radius: 12px;
padding: 36px 32px;
margin-bottom: 36px;
color: #fff;
display: flex;
align-items: center;
gap: 24px;
}
.help-hero-icon {
font-size: 48px;
opacity: 0.9;
flex-shrink: 0;
}
.help-hero h1 {
font-size: 26px;
font-weight: 800;
margin-bottom: 8px;
letter-spacing: -0.5px;
}
.help-hero p {
font-size: 15px;
opacity: 0.85;
line-height: 1.6;
margin: 0;
}
/* ── Section ─────────────────────────────────────────────── */
.help-section {
margin-bottom: 36px;
}
.help-section-title {
font-size: 18px;
font-weight: 700;
color: #1a3c22;
border-left: 4px solid #2d7a3a;
padding-left: 14px;
margin-bottom: 18px;
letter-spacing: -0.3px;
}
/* ── Step cards ──────────────────────────────────────────── */
.help-steps {
display: flex;
flex-direction: column;
gap: 12px;
}
.help-step {
display: flex;
gap: 16px;
background: #f5f7f2;
border: 1px solid #dde8da;
border-radius: 10px;
padding: 16px 18px;
align-items: flex-start;
}
.help-step-num {
width: 36px;
height: 36px;
border-radius: 50%;
background: #2d7a3a;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
flex-shrink: 0;
}
.help-step-num.amber { background: #f4a236; color: #1a3c22; }
.help-step-body {}
.help-step-title { font-weight: 700; color: #1a3c22; margin-bottom: 4px; font-size: 14px; }
.help-step-desc { font-size: 13px; color: #4a5e4d; line-height: 1.6; }
/* ── Two-column flow cards ───────────────────────────────── */
.help-flow-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
.help-flow-card {
border-radius: 10px;
padding: 20px;
border: 2px solid;
}
.help-flow-card.green {
background: #eaf3de;
border-color: #2d7a3a;
}
.help-flow-card.amber {
background: #fff8ee;
border-color: #f4a236;
}
.hfc-icon {
font-size: 28px;
margin-bottom: 10px;
}
.help-flow-card.green .hfc-icon { color: #2d7a3a; }
.help-flow-card.amber .hfc-icon { color: #c87500; }
.hfc-title {
font-size: 15px;
font-weight: 800;
margin-bottom: 8px;
}
.help-flow-card.green .hfc-title { color: #1a3c22; }
.help-flow-card.amber .hfc-title { color: #7a4200; }
.hfc-when {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .5px;
margin-bottom: 6px;
}
.help-flow-card.green .hfc-when { color: #2d7a3a; }
.help-flow-card.amber .hfc-when { color: #c87500; }
.hfc-desc {
font-size: 13px;
color: #444;
line-height: 1.6;
margin-bottom: 10px;
}
.hfc-example {
font-size: 12px;
background: rgba(255,255,255,0.6);
border-radius: 6px;
padding: 8px 10px;
color: #555;
line-height: 1.5;
font-style: italic;
}
/* ── Table ───────────────────────────────────────────────── */
.help-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
margin: 14px 0;
}
.help-table th {
background: #1a3c22;
color: #fff;
padding: 9px 13px;
text-align: left;
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: .4px;
}
.help-table th:first-child { border-radius: 6px 0 0 0; }
.help-table th:last-child { border-radius: 0 6px 0 0; }
.help-table td {
padding: 10px 13px;
border-bottom: 1px solid #dde8da;
color: #2c3e2e;
vertical-align: top;
line-height: 1.5;
}
.help-table tr:last-child td { border-bottom: none; }
.help-table tr:nth-child(even) td { background: #f5f7f2; }
/* ── FAQ ─────────────────────────────────────────────────── */
.help-faq { display: flex; flex-direction: column; gap: 8px; }
.help-faq-item {
border: 1px solid #dde8da;
border-radius: 8px;
overflow: hidden;
}
.help-faq-q {
width: 100%;
background: #f5f7f2;
border: none;
padding: 13px 16px;
text-align: left;
font-family: 'DM Sans', sans-serif;
font-size: 13px;
font-weight: 600;
color: #1a3c22;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
}
.help-faq-q:hover { background: #eaf3de; }
.help-faq-q .fa-chevron-down { margin-left: auto; font-size: 11px; color: #6b7c6e; transition: transform 0.2s; }
.help-faq-q.open .fa-chevron-down { transform: rotate(180deg); }
.help-faq-q .fa:first-child { color: #2d7a3a; }
.help-faq-a {
display: none;
padding: 12px 16px;
font-size: 13px;
color: #444;
line-height: 1.7;
border-top: 1px solid #dde8da;
background: #fff;
}
/* ── Banner ──────────────────────────────────────────────── */
.help-banner {
background: #fff8ee;
border: 1px solid #f4c87a;
border-left: 4px solid #f4a236;
border-radius: 8px;
padding: 14px 18px;
font-size: 13px;
color: #7a4200;
display: flex;
gap: 12px;
align-items: flex-start;
line-height: 1.6;
margin: 14px 0;
}
.help-banner .fa { color: #f4a236; flex-shrink: 0; margin-top: 1px; font-size: 16px; }
.help-banner.green {
background: #eaf3de;
border-color: #a8d08d;
border-left-color: #2d7a3a;
color: #1a3c22;
}
.help-banner.green .fa { color: #2d7a3a; }
/* ── CTA ─────────────────────────────────────────────────── */
.help-cta {
background: linear-gradient(135deg, #1a3c22 0%, #2d7a3a 100%);
border-radius: 12px;
padding: 28px 32px;
text-align: center;
color: #fff;
margin-top: 40px;
}
.help-cta h3 {
font-size: 20px;
font-weight: 800;
margin-bottom: 8px;
}
.help-cta p {
font-size: 14px;
opacity: 0.85;
margin-bottom: 20px;
line-height: 1.6;
}
.help-cta-btn {
display: inline-flex;
align-items: center;
gap: 8px;
background: #f4a236;
color: #1a3c22 !important;
font-weight: 700;
font-size: 15px;
padding: 12px 28px;
border-radius: 8px;
text-decoration: none;
transition: background 0.18s;
}
.help-cta-btn:hover { background: #e8922a; }
@@media (max-width: 600px) {
.help-hero { flex-direction: column; text-align: center; padding: 24px 20px; }
.help-flow-grid { grid-template-columns: 1fr; }
}
</style>
<div class="help-page">
<!-- ── Hero ──────────────────────────────────────────────────────── -->
<div class="help-hero">
<div class="help-hero-icon"><i class="fa fa-question-circle"></i></div>
<div>
<h1>Hogyan rendeljek a FruitBankon?</h1>
<p>Minden, amit a rendelési folyamatról tudni kell — egyszerűen elmagyarázva.</p>
</div>
</div>
<!-- ── 1. A két rendelési mód ─────────────────────────────────────── -->
<div class="help-section">
<div class="help-section-title">A két rendelési mód</div>
<div class="help-flow-grid">
<div class="help-flow-card green">
<div class="hfc-icon"><i class="fa fa-shopping-basket"></i></div>
<div class="hfc-title">Rendelés</div>
<div class="hfc-when">Azonnali teljesítés</div>
<div class="hfc-desc">A raktáron lévő árukból azonnal leadhatsz rendelést. A termékeket szabad szöveges keresővel vagy <strong>hangutasítással</strong> adhatod a kosárhoz.</div>
<div class="hfc-example">Pl. „Narancs 100 doboz, alma 50 kg" — bemond vagy begépeled, a rendszer megtalálja a termékeket.</div>
</div>
<div class="help-flow-card amber">
<div class="hfc-icon"><i class="fa fa-calendar-plus-o"></i></div>
<div class="hfc-title">Előrendelés</div>
<div class="hfc-when">Jövő heti áru</div>
<div class="hfc-desc">Ha az áru még úton van (jövő héten érkezik), leadhatsz egy kívánságlistát. Amint megérkezik a szállítmány, <strong>automatikusan rendelés lesz belőle</strong> és e-mailben értesítünk.</div>
<div class="hfc-example">Pl. Hétfőn rendeled a csütörtökön érkező narancsot — a rendszer feljegyzi és automatikusan intézi.</div>
</div>
</div>
<div class="help-banner">
<i class="fa fa-info-circle"></i>
<div>A rendszer <strong>automatikusan</strong> dönti el, melyik módot mutatja — nem kell manuálisan választani. A kiválasztott szállítási nap alapján azonnal jelzi, mire számíthatsz.</div>
</div>
</div>
<!-- ── 2. Mikor melyik mód ─────────────────────────────────────────── -->
<div class="help-section">
<div class="help-section-title">Mikor melyik mód jelenik meg?</div>
<table class="help-table">
<thead>
<tr>
<th>Mai nap</th>
<th>Kért szállítási nap</th>
<th>Mód</th>
</tr>
</thead>
<tbody>
<tr>
<td>Hétfő / Kedd / Szerda</td>
<td>Bármely nap</td>
<td><strong style="color:#c87500;">Előrendelés</strong> — a heti áru még úton van</td>
</tr>
<tr>
<td>Csütörtök / Péntek / Szombat / Vasárnap</td>
<td>Következő héten (hétfőszerda)</td>
<td><strong style="color:#2d7a3a;">Rendelés</strong> — raktárkészletből azonnal</td>
</tr>
<tr>
<td>Csütörtök / Péntek / Szombat / Vasárnap</td>
<td>Ezen a héten (csütörtökvasárnap)</td>
<td><strong style="color:#2d7a3a;">Rendelés</strong> — az áru már megérkezett</td>
</tr>
<tr>
<td>Bármely nap</td>
<td>Jövő hét csütörtöktől</td>
<td><strong style="color:#c87500;">Előrendelés</strong> — jövő heti szállítmányból</td>
</tr>
</tbody>
</table>
</div>
<!-- ── 3. Lépések ─────────────────────────────────────────────────── -->
<div class="help-section">
<div class="help-section-title">A rendelés menete lépésről lépésre</div>
<div class="help-steps">
<div class="help-step">
<div class="help-step-num">1</div>
<div class="help-step-body">
<div class="help-step-title">Válassz szállítási napot és időpontot</div>
<div class="help-step-desc">Kattints a kívánt napra a naptárban, majd állítsd be a szállítási időpontot. A rendszer azonnal jelzi, hogy rendelés vagy előrendelés lesz-e belőle.</div>
</div>
</div>
<div class="help-step">
<div class="help-step-num">2</div>
<div class="help-step-body">
<div class="help-step-title">Add meg a termékeket</div>
<div class="help-step-desc"><strong>Rendelésnél:</strong> keress szöveggel (pl. „narancs 100") vagy nyomj a mikrofon gombra és mondd be hangosan. A rendszer megtalálja a termékeket és javasolja a mennyiséget.<br><strong>Előrendelésnél:</strong> a rendszer megmutatja az előrendelhető termékeket — csak add meg a kívánt mennyiségeket.</div>
</div>
</div>
<div class="help-step">
<div class="help-step-num">3</div>
<div class="help-step-body">
<div class="help-step-title">Ellenőrizd a kosarat / összesítőt</div>
<div class="help-step-desc">Jobb oldalon látod az összes hozzáadott terméket és a becsült összeget. A súlymérést igénylő tételeknél az ár a mérés után véglegesedik.</div>
</div>
</div>
<div class="help-step">
<div class="help-step-num">4</div>
<div class="help-step-body">
<div class="help-step-title">Add le a rendelést</div>
<div class="help-step-desc"><strong>Rendelésnél:</strong> kattints a „Tovább a pénztárhoz" gombra és erősítsd meg a rendelést.<br><strong>Előrendelésnél:</strong> kattints az „Előrendelés leadása" gombra. Visszaigazolást kapsz e-mailben, majd a szállítmány megérkezésekor értesítünk a végeredményről.</div>
</div>
</div>
</div>
</div>
<!-- ── 4. Hangalapú rendelés ──────────────────────────────────────── -->
<div class="help-section">
<div class="help-section-title">Hangalapú rendelés — hogyan használd?</div>
<div class="help-banner green">
<i class="fa fa-microphone"></i>
<div>A hangalapú bevitel <strong>raktármunkások számára</strong> tervezett funkció — gyors és kézszabad rendelés mobilon, táblagépen egyaránt.</div>
</div>
<div class="help-steps">
<div class="help-step">
<div class="help-step-num">1</div>
<div class="help-step-body">
<div class="help-step-title">Nyomj a mikrofon gombra</div>
<div class="help-step-desc">A böngésző engedélyt kér a mikrofonhoz — engedélyezd. A rendszer automatikusan érzékeli, mikor kezdesz el és mikor fejezed be a beszédet.</div>
</div>
</div>
<div class="help-step">
<div class="help-step-num">2</div>
<div class="help-step-body">
<div class="help-step-title">Mondd be a termékeket és a rekeszek számát</div>
<div class="help-step-desc">Pl. „Narancs száz, alma ötven, banán harminc." Mondd határozottan, a termékek nevét és mennyiségét együtt. A rendszer automatikusan leáll, ha hallgatás érzékel.</div>
</div>
</div>
<div class="help-step">
<div class="help-step-num">3</div>
<div class="help-step-body">
<div class="help-step-title">Ellenőrizd a találatokat</div>
<div class="help-step-desc">A rendszer megjeleníti, mit értett. Ha valamit rosszul azonosított, állítsd be a mennyiséget kézzel, vagy keress rá szöveggel. Majd add a kosárhoz.</div>
</div>
</div>
</div>
</div>
<!-- ── 5. Előrendelés részletei ──────────────────────────────────── -->
<div class="help-section">
<div class="help-section-title">Előrendelés — amit tudni kell</div>
<div class="help-faq">
<div class="help-faq-item">
<button class="help-faq-q" type="button">
<i class="fa fa-circle" style="font-size:8px;"></i>
Garantált az előrendelés teljesítése?
<i class="fa fa-chevron-down"></i>
</button>
<div class="help-faq-a">
Nem — az előrendelés egy <strong>kívánságlista</strong>, nem kötelező érvényű megrendelés. Ha a szállítmány nem hoz elegendő árut (pl. kevesebb érkezett a vártnál), a rendszer az érkezési sorrend alapján osztja el a készletet. Mindig értesítünk e-mailben, hogy miből mennyi teljesült.
</div>
</div>
<div class="help-faq-item">
<button class="help-faq-q" type="button">
<i class="fa fa-circle" style="font-size:8px;"></i>
Mi történik, ha csak részben teljesül?
<i class="fa fa-chevron-down"></i>
</button>
<div class="help-faq-a">
Automatikusan létrejön egy rendelés a teljesített tételekkel, és e-mailben értesítünk a részletekről — miből mennyi érkezett, és mi maradt ki. A kiesett tételek nem kerülnek automatikusan a következő szállítmányra.
</div>
</div>
<div class="help-faq-item">
<button class="help-faq-q" type="button">
<i class="fa fa-circle" style="font-size:8px;"></i>
Mikor jön létre a tényleges rendelés?
<i class="fa fa-chevron-down"></i>
</button>
<div class="help-faq-a">
Amint az adminisztrátor feldolgozza a szállítói dokumentumokat és rögzíti az érkező árut, a rendszer automatikusan létrehozza a rendelést. Ez általában a szállítást megelőző napon, szerdán vagy csütörtökön történik.
</div>
</div>
<div class="help-faq-item">
<button class="help-faq-q" type="button">
<i class="fa fa-circle" style="font-size:8px;"></i>
Módosíthatom az előrendelésemet?
<i class="fa fa-chevron-down"></i>
</button>
<div class="help-faq-a">
Az előrendelés módosítása jelenleg fejlesztés alatt van. Addig lépj kapcsolatba velünk telefonon vagy e-mailben, és segítünk a módosításban.
</div>
</div>
<div class="help-faq-item">
<button class="help-faq-q" type="button">
<i class="fa fa-circle" style="font-size:8px;"></i>
Hol látom az előrendeléseimet?
<i class="fa fa-chevron-down"></i>
</button>
<div class="help-faq-a">
A <a href="@Url.Action("List", "CustomerPreorder")" style="color:#2d7a3a;font-weight:600;">Saját fiók → Előrendeléseim</a> oldalon látod az összes leadott előrendelést, azok állapotát és a létrejött rendelésekre mutató hivatkozást.
</div>
</div>
</div>
</div>
<!-- ── CTA ────────────────────────────────────────────────────────── -->
<div class="help-cta">
<h3>Készen állsz a rendelésre?</h3>
<p>Válassz szállítási napot, és a rendszer vezet végig a folyamaton.</p>
<a href="@Url.Action("Index", "Order")" class="help-cta-btn">
<i class="fa fa-bolt"></i> Rendelés indítása
</a>
</div>
</div>
<script asp-location="Footer">
$(function () {
// FAQ accordion
$('.help-faq-q').click(function () {
var $a = $(this).next('.help-faq-a');
var isOpen = $a.is(':visible');
$('.help-faq-a').slideUp(180);
$('.help-faq-q').removeClass('open');
if (!isOpen) {
$a.slideDown(180);
$(this).addClass('open');
}
});
});
</script>

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,7 @@
<div class="form-group row">
<div class="col-12 col-md-7">
<div class="col-12 col-md-9">
<div class="card card-default mb-2">
<div class="card-header">
<i class="fas fa-file-invoice"></i>
@ -38,22 +38,27 @@
</div>
<hr />
<div class="form-group row">
<div class="col-md-3">
<div class="col-md-2">
<button type="button" class="btn btn-warning btn-block" data-toggle="modal" data-target="#allowRevisionModal">
<i class="fa fa-redo"></i> Újramérés engedélyezése
</button>
</div>
<div class="col-md-3">
<div class="col-md-2">
<button type="button" class="btn btn-success btn-block" data-toggle="modal" data-target="#sendOrderEmailModal">
<i class="fas fa-envelope"></i> Email küldése ügyfélnek
</button>
</div>
<div class="col-md-2">
<button type="button" class="btn btn-primary btn-block" data-toggle="modal" data-target="#sendMessageModal">
<i class="fas fa-paper-plane"></i> Üzenet küldése
</button>
</div>
<div class="col-md-3">
<div class="col-md-2">
<button type="button" class="btn btn-info btn-block" data-toggle="modal" data-target="#addOrderNoteModal">
<i class="fas fa-sticky-note"></i> Jegyzet hozzáadása
</button>
</div>
<div class="col-md-3">
<div class="col-md-2">
<button type="button"
class="btn btn-danger btn-block"
data-toggle="modal"
@ -70,7 +75,7 @@
</div>
</div>
</div>
<div class="col-12 col-md-5">
<div class="col-12 col-md-3">
<div class="card card-default mb-3">
<div class="card-header">
<i class="fas fa-file-invoice"></i>
@ -107,6 +112,36 @@
</div>
<!-- Send Order Email Modal -->
<div class="modal fade" id="sendOrderEmailModal" tabindex="-1" role="dialog" aria-labelledby="sendOrderEmailModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="sendOrderEmailModalLabel">
<i class="fas fa-envelope"></i> Rendelési email küldése ügyfélnek
</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>Ez a gomb elküldi a jelenlegi rendelés összefoglalóját az ügyfél email címére a rendelésfeladási email sablon alapján.</p>
<div id="sendOrderEmailStatus" class="alert" style="display: none; margin-top: 15px;">
<i class="fas fa-info-circle"></i> <span id="sendOrderEmailStatusMessage"></span>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">
<i class="fa fa-times"></i> Mégse
</button>
<button type="button" id="sendOrderEmailBtn" class="btn btn-success">
<i class="fas fa-envelope"></i> Email küldése
</button>
</div>
</div>
</div>
</div>
<!-- Allow Revision Modal -->
<div class="modal fade" id="allowRevisionModal" tabindex="-1" role="dialog" aria-labelledby="allowRevisionModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
@ -1244,6 +1279,54 @@
statusDiv.show();
}
// ========== SEND ORDER EMAIL TO CUSTOMER ==========
var sendOrderEmailUrl = '@Url.Action("SendOrderEmailToCustomer", "CustomOrder")';
$('#sendOrderEmailBtn').click(function(e) {
e.preventDefault();
e.stopPropagation();
var btn = $(this);
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Küldés...');
showSendOrderEmailStatus('Email küldése folyamatban...', 'info');
$.ajax({
type: 'POST',
url: sendOrderEmailUrl,
data: {
orderId: @Model.OrderId,
__RequestVerificationToken: $('input[name="__RequestVerificationToken"]').val()
},
dataType: 'json',
success: function(response) {
btn.prop('disabled', false).html('<i class="fas fa-envelope"></i> Email küldése');
if (response.success) {
showSendOrderEmailStatus(response.message, 'success');
setTimeout(function() { $('#sendOrderEmailModal').modal('hide'); }, 2000);
} else {
showSendOrderEmailStatus('Hiba: ' + (response.message || 'Ismeretlen hiba'), 'danger');
}
},
error: function(xhr) {
btn.prop('disabled', false).html('<i class="fas fa-envelope"></i> Email küldése');
showSendOrderEmailStatus('Hiba: ' + (xhr.responseText || 'Szerver hiba'), 'danger');
}
});
});
function showSendOrderEmailStatus(message, type) {
var statusDiv = $('#sendOrderEmailStatus');
statusDiv.removeClass('alert-info alert-success alert-warning alert-danger').addClass('alert-' + type);
$('#sendOrderEmailStatusMessage').text(message);
statusDiv.show();
}
$('#sendOrderEmailModal').on('hidden.bs.modal', function() {
$('#sendOrderEmailStatus').hide();
$('#sendOrderEmailBtn').prop('disabled', false).html('<i class="fas fa-envelope"></i> Email küldése');
});
// Clear split order status when modal is closed
$('#splitOrderModal').on('hidden.bs.modal', function () {
$("#splitOrderStatus").hide();

View File

@ -0,0 +1,460 @@
@using System.Text.Encodings.Web
@{
Layout = "_Root";
ViewBag.Title = T("Plugins.Misc.FruitBankPlugin.Preorder.PageTitle").Text;
}
<link rel="stylesheet" href="~/Plugins/Misc.FruitBankPlugin/css/quick-order.css" />
<link rel="stylesheet" href="~/Plugins/Misc.FruitBankPlugin/css/preorder.css" />
<div class="quick-order-page">
<!-- ── STEP 1: Delivery date + time ─────────────────────────────────── -->
<div id="deliveryStep" class="qo-delivery-step">
<div class="ds-header">
<i class="fa fa-calendar"></i>
<div>
<div class="ds-title">@T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Title")</div>
<div class="ds-subtitle">@T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Subtitle")</div>
</div>
</div>
<div class="ds-body">
<div class="ds-section-label">@T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.DayLabel")</div>
<div class="ds-day-buttons" id="dayButtons"></div>
<div class="ds-section-label" style="margin-top:20px;">
@T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.TimeLabel")
</div>
<div class="ds-time-wrapper">
<input type="time" id="deliveryTimePicker" class="ds-time-input" value="08:00" min="05:00" max="22:00" />
<span class="ds-time-hint">@T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.TimeHint")</span>
</div>
</div>
<div class="ds-footer">
<button type="button" class="ds-confirm-btn" id="deliveryConfirmBtn" disabled>
<i class="fa fa-arrow-right"></i> @T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ConfirmButton")
</button>
</div>
</div>
<!-- ── Delivery chip ────────────────────────────────────────────────── -->
<div id="deliveryChip" class="qo-delivery-chip" style="display:none;">
<i class="fa fa-calendar-check-o"></i>
<span class="dc-label">@T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ChangeLabel")</span>
<strong id="deliveryChipText"></strong>
<button type="button" class="dc-change-btn" id="deliveryChangeBtn">
<i class="fa fa-pencil"></i> @T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ChangeButton")
</button>
</div>
<!-- ── STEP 2: Product selection + submit ───────────────────────────── -->
<div id="mainContent" style="display:none;">
<!-- Info banner -->
<div class="po-info-banner">
<i class="fa fa-info-circle"></i>
@T("Plugins.Misc.FruitBankPlugin.Preorder.InfoBanner")
</div>
<div class="qo-layout">
<!-- LEFT: product list + note + submit -->
<div class="qo-products-panel">
<!-- Loading -->
<div id="productsLoadingState" class="products-empty-state">
<i class="fa fa-spinner fa-spin"></i>
<p>@T("Plugins.Misc.FruitBankPlugin.Preorder.LoadingProducts")</p>
</div>
<!-- No products -->
<div id="noProductsCard" class="no-results-card" style="display:none;">
<i class="fa fa-calendar-times-o"></i>
<p>@T("Plugins.Misc.FruitBankPlugin.Preorder.NoProductsAvailable")</p>
</div>
<!-- Product grid -->
<div id="productSection" style="display:none;">
<div class="matches-label">
<i class="fa fa-cubes"></i>
<span>@T("Plugins.Misc.FruitBankPlugin.Preorder.ProductsLabel")</span>
</div>
<div id="productGrid" class="product-grid"></div>
<!-- Customer note -->
<div class="po-note-section">
<label class="po-note-label" for="customerNote">
<i class="fa fa-comment-o"></i>
@T("Plugins.Misc.FruitBankPlugin.Preorder.NoteLabel")
</label>
<textarea id="customerNote" class="po-note-input"
placeholder="@T("Plugins.Misc.FruitBankPlugin.Preorder.NotePlaceholder")"
rows="3" maxlength="1000"></textarea>
</div>
<!-- Submit -->
<div class="po-submit-row">
<div id="selectionSummary" class="po-selection-summary"></div>
<button type="button" id="submitPreorderBtn" class="po-submit-btn" disabled>
<i class="fa fa-paper-plane"></i>
@T("Plugins.Misc.FruitBankPlugin.Preorder.SubmitButton")
</button>
</div>
</div>
</div>
<!-- RIGHT: summary panel -->
<div class="qo-cart-panel">
<div class="qo-section-title">
<i class="fa fa-list-ul"></i>
@T("Plugins.Misc.FruitBankPlugin.Preorder.SummaryTitle")
<span id="itemCountBadge" class="cart-count-badge">0</span>
</div>
<div id="summaryEmpty" class="cart-empty">
<i class="fa fa-list-ul"></i>
<p>@T("Plugins.Misc.FruitBankPlugin.Preorder.SummaryEmpty")</p>
</div>
<div id="summaryList" class="cart-items-list" style="display:none;"></div>
<div id="summaryNote" class="cart-total-row" style="display:none;">
<div class="cart-total-note">
<i class="fa fa-info-circle"></i>
<small>@T("Plugins.Misc.FruitBankPlugin.Preorder.SummaryNote")</small>
</div>
</div>
</div>
</div>
</div>
<!-- ── SUCCESS STATE ─────────────────────────────────────────────────── -->
<div id="successState" style="display:none;" class="po-success-state">
<div class="po-success-icon"><i class="fa fa-check-circle"></i></div>
<h2>@T("Plugins.Misc.FruitBankPlugin.Preorder.SuccessTitle")</h2>
<p id="successMessage"></p>
<a href="@Url.RouteUrl("Homepage")" class="po-back-btn">
<i class="fa fa-home"></i> @T("Plugins.Misc.FruitBankPlugin.Preorder.BackToHome")
</a>
</div>
</div>
@Html.AntiForgeryToken()
<script asp-location="Footer">
var poStr = {
dsToday : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Today").Text))',
dsTomorrow : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Tomorrow").Text))',
dsSaving : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Saving").Text))',
dsConfirm : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ConfirmButton").Text))',
measurable : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.MeasurableBadge").Text))',
pricePerPc : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.PricePerPiece").Text))',
pieceUnit : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.PieceUnit").Text))',
stockLabel : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.StockLabel").Text))',
selNone : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.SelectionNone").Text))',
selItems : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.SelectionItems").Text))',
submitting : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.Submitting").Text))',
successMsg : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.SuccessMessage").Text))',
errorPfx : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.ErrorPrefix").Text))',
huDayNames : ['vas\u00e1rnap','h\u00e9tf\u0151','kedd','szerda','cs\u00fct\u00f6rt\u00f6k','p\u00e9ntek','szombat']
};
</script>
<script asp-location="Footer">
var selectedDeliveryDate = null;
var selectedDeliveryTime = null;
var selectedDayLabel = null;
var products = []; // loaded from server
var quantities = {}; // productId → quantity (0 = not selected)
// ── Init ──────────────────────────────────────────────────────────────────
$(document).ready(function () {
renderDayButtons();
$(document).on('click', '.ds-day-btn', function () {
$('.ds-day-btn').removeClass('selected');
$(this).addClass('selected');
selectedDeliveryDate = $(this).data('date');
selectedDayLabel = $(this).data('label');
checkDeliveryReady();
});
$('#deliveryTimePicker').on('input change', function () {
selectedDeliveryTime = $(this).val() || null;
checkDeliveryReady();
});
selectedDeliveryTime = $('#deliveryTimePicker').val() || null;
$('#deliveryConfirmBtn').click(confirmDelivery);
$('#deliveryChangeBtn').click(function () {
$('#deliveryChip').hide();
$('#mainContent').hide();
$('#deliveryStep').show();
});
$('#submitPreorderBtn').click(submitPreorder);
// Restore saved delivery datetime if revisiting
$.ajax({
url: '@Url.Action("GetDeliveryDateTime", "Preorder")',
type: 'GET',
success: function (result) {
if (!result.success || !result.hasValue) return;
selectedDeliveryDate = result.date;
selectedDeliveryTime = result.time;
var $btn = $('.ds-day-btn[data-date="' + result.date + '"]');
if ($btn.length) {
$btn.addClass('selected');
selectedDayLabel = $btn.data('label');
} else {
selectedDayLabel = result.date;
}
$('#deliveryTimePicker').val(result.time);
showMainContent();
}
});
});
// ── Delivery step ─────────────────────────────────────────────────────────
function renderDayButtons() {
var container = $('#dayButtons').empty();
var today = new Date();
for (var i = 0; i < 14; i++) { // 2-week window for preorders
var d = new Date(today);
d.setDate(today.getDate() + i);
var iso = d.toISOString().split('T')[0];
var dayName;
if (i === 0) dayName = poStr.dsToday;
else if (i === 1) dayName = poStr.dsTomorrow;
else dayName = poStr.huDayNames[d.getDay()];
var dateStr = (d.getMonth() + 1) + '. ' + d.getDate() + '.';
var btn = $('<button type="button" class="ds-day-btn">')
.attr('data-date', iso)
.attr('data-label', dayName + ' ' + dateStr)
.html('<span class="ds-day-name">' + dayName + '</span><span class="ds-day-date">' + dateStr + '</span>');
container.append(btn);
}
}
function checkDeliveryReady() {
$('#deliveryConfirmBtn').prop('disabled', !(selectedDeliveryDate && selectedDeliveryTime));
}
function confirmDelivery() {
var btn = $('#deliveryConfirmBtn');
btn.prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> ' + poStr.dsSaving);
var deliveryDateTime = selectedDeliveryDate + 'T' + selectedDeliveryTime;
$.ajax({
url : '@Url.Action("SetDeliveryDateTime", "Preorder")',
type: 'POST',
data: {
deliveryDateTime: deliveryDateTime,
__RequestVerificationToken: $('input[name="__RequestVerificationToken"]').val()
},
success: function (result) {
if (!result.success) {
alert(poStr.errorPfx + (result.message || ''));
btn.prop('disabled', false).html('<i class="fa fa-arrow-right"></i> ' + poStr.dsConfirm);
return;
}
showMainContent();
},
error: function () {
btn.prop('disabled', false).html('<i class="fa fa-arrow-right"></i> ' + poStr.dsConfirm);
}
});
}
function showMainContent() {
var chipText = selectedDayLabel + ' \u2014 ' + selectedDeliveryTime;
$('#deliveryChipText').text(chipText);
$('#deliveryStep').hide();
$('#deliveryChip').show();
$('#mainContent').show();
loadProducts();
}
// ── Products ──────────────────────────────────────────────────────────────
function loadProducts() {
$('#productsLoadingState').show();
$('#noProductsCard').hide();
$('#productSection').hide();
$.ajax({
url : '@Url.Action("GetAvailableProducts", "Preorder")',
type: 'GET',
success: function (result) {
$('#productsLoadingState').hide();
if (!result.success || !result.products || result.products.length === 0) {
$('#noProductsCard').show();
return;
}
products = result.products;
quantities = {};
renderProducts();
$('#productSection').show();
},
error: function () {
$('#productsLoadingState').hide();
$('#noProductsCard').show();
}
});
}
function renderProducts() {
var grid = $('#productGrid').empty();
$.each(products, function (_, p) {
quantities[p.id] = quantities[p.id] || 0;
var priceHtml = p.isMeasurable
? '<span class="measurable-badge"><i class="fa fa-balance-scale"></i> ' + poStr.measurable + '</span>'
: (p.unitPrice > 0 ? '<span class="pm-price">' + fmt(p.unitPrice) + ' ' + poStr.pricePerPc + '</span>' : '');
var card = $('<div>').addClass('product-card po-product-card').attr('data-id', p.id);
card.html(
'<div class="pc-body">' +
'<div class="pc-name"><i class="fa fa-cube"></i> ' + p.name + '</div>' +
'<div class="pc-meta">' +
'<span class="pc-stock">' + poStr.stockLabel + ' ' + p.stockQuantity + ' ' + poStr.pieceUnit + '</span>' +
priceHtml +
'</div>' +
'</div>' +
'<div class="pc-actions">' +
'<div class="qty-stepper">' +
'<button type="button" class="qty-btn qty-minus" tabindex="-1"><i class="fa fa-minus"></i></button>' +
'<input type="number" class="qty-input po-qty" value="0" min="0" max="' + p.stockQuantity + '">' +
'<button type="button" class="qty-btn qty-plus" tabindex="-1"><i class="fa fa-plus"></i></button>' +
'</div>' +
'</div>'
);
card.find('.qty-minus').click(function () {
var inp = $(this).siblings('.qty-input');
var val = parseInt(inp.val()) || 0;
if (val > 0) { inp.val(val - 1); onQtyChange(p.id, val - 1, card); }
});
card.find('.qty-plus').click(function () {
var inp = $(this).siblings('.qty-input');
var val = parseInt(inp.val()) || 0;
if (val < p.stockQuantity) { inp.val(val + 1); onQtyChange(p.id, val + 1, card); }
});
card.find('.qty-input').on('input change blur', function () {
var val = parseInt($(this).val());
if (isNaN(val) || val < 0) val = 0;
if (val > p.stockQuantity) val = p.stockQuantity;
$(this).val(val);
onQtyChange(p.id, val, card);
});
grid.append(card);
});
}
function onQtyChange(productId, qty, $card) {
quantities[productId] = qty;
// Highlight selected cards
$card.toggleClass('po-selected', qty > 0);
updateSummary();
}
function updateSummary() {
var selectedItems = products.filter(function (p) { return (quantities[p.id] || 0) > 0; });
var count = selectedItems.length;
$('#itemCountBadge').text(count);
$('#submitPreorderBtn').prop('disabled', count === 0);
// Selection summary text
if (count === 0) {
$('#selectionSummary').text(poStr.selNone);
} else {
$('#selectionSummary').text(count + ' ' + poStr.selItems);
}
// Right panel
if (count === 0) {
$('#summaryEmpty').show();
$('#summaryList, #summaryNote').hide();
return;
}
$('#summaryEmpty').hide();
$('#summaryList, #summaryNote').show();
var list = $('#summaryList').empty();
var hasMeasurable = false;
$.each(selectedItems, function (_, p) {
var qty = quantities[p.id];
var priceHtml = p.isMeasurable
? '<span class="measurable-badge-sm"><i class="fa fa-balance-scale"></i></span>'
: '<strong class="line-total">' + fmt(p.unitPrice * qty) + ' Ft</strong>';
if (p.isMeasurable) hasMeasurable = true;
list.append(
'<div class="cart-item">' +
'<div class="ci-name">' + p.name + '</div>' +
'<div class="ci-details">' +
'<span class="ci-qty">' + qty + ' ' + poStr.pieceUnit + '</span>' +
priceHtml +
'</div>' +
'</div>'
);
});
if (hasMeasurable) $('#summaryNote').show();
else $('#summaryNote').hide();
}
// ── Submit ────────────────────────────────────────────────────────────────
function submitPreorder() {
var selectedItems = products.filter(function (p) { return (quantities[p.id] || 0) > 0; });
if (!selectedItems.length) return;
var btn = $('#submitPreorderBtn');
btn.prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> ' + poStr.submitting);
var payload = {
deliveryDateTime : selectedDeliveryDate + 'T' + selectedDeliveryTime,
customerNote : $('#customerNote').val().trim(),
items : selectedItems.map(function (p) {
return { productId: p.id, quantity: quantities[p.id] };
})
};
$.ajax({
url : '@Url.Action("PlacePreorder", "Preorder")',
type : 'POST',
contentType: 'application/json',
data : JSON.stringify(payload),
headers : { 'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').val() },
success : function (result) {
if (result.success) {
$('#mainContent, #deliveryChip').hide();
$('#successMessage').text(poStr.successMsg.replace('{0}', result.preorderId));
$('#successState').show();
} else {
alert(poStr.errorPfx + (result.message || ''));
btn.prop('disabled', false)
.html('<i class="fa fa-paper-plane"></i> @Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.SubmitButton").Text))');
}
},
error: function () {
alert(poStr.errorPfx);
btn.prop('disabled', false)
.html('<i class="fa fa-paper-plane"></i> @Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.SubmitButton").Text))');
}
});
}
function fmt(val) {
if (!val) return '—';
return Math.round(val).toLocaleString('hu-HU');
}
</script>

View File

@ -0,0 +1,744 @@
@using System.Text.Encodings.Web
@{
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">
<!-- ── STEP 1: Delivery date + time picker ───────────────────────────── -->
<div id="deliveryStep" class="qo-delivery-step">
<div class="ds-header">
<i class="fa fa-calendar"></i>
<div>
<div class="ds-title">@T("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Title")</div>
<div class="ds-subtitle">@T("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Subtitle")</div>
</div>
</div>
<div class="ds-body">
<div class="ds-section-label">@T("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.DayLabel")</div>
<div class="ds-day-buttons" id="dayButtons"></div>
<div class="ds-section-label" style="margin-top:20px;">
@T("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.TimeLabel")
</div>
<div class="ds-time-wrapper">
<input type="time" id="deliveryTimePicker" class="ds-time-input" value="08:00" min="05:00" max="22:00" />
<span class="ds-time-hint">@T("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.TimeHint")</span>
</div>
</div>
<div class="ds-footer">
<button type="button" class="ds-confirm-btn" id="deliveryConfirmBtn" disabled>
<i class="fa fa-arrow-right"></i> @T("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ConfirmButton")
</button>
</div>
</div>
<!-- ── Delivery chip (collapsed state) ──────────────────────────────── -->
<div id="deliveryChip" class="qo-delivery-chip" style="display:none;">
<i class="fa fa-calendar-check-o"></i>
<span class="dc-label">@T("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ChangeLabel")</span>
<strong id="deliveryChipText"></strong>
<button type="button" class="dc-change-btn" id="deliveryChangeBtn">
<i class="fa fa-pencil"></i> @T("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ChangeButton")
</button>
</div>
<!-- ── Search + layout (locked until delivery confirmed) ────────────── -->
<div id="mainContent" style="display:none;">
<!-- Full-width Search Bar -->
<div class="qo-search-bar-wrapper">
<div class="qo-search-bar">
<div class="search-input-group">
<button id="recordBtn" class="mic-btn" title="@T("Plugins.Misc.FruitBankPlugin.QuickOrder.MicButtonTitle")">
<i class="fa fa-microphone"></i>
</button>
<button id="stopBtn" class="mic-btn mic-btn-recording" style="display:none;" title="@T("Plugins.Misc.FruitBankPlugin.QuickOrder.StopButtonTitle")">
<i class="fa fa-stop"></i>
<span class="mic-pulse"></span>
</button>
<input type="text"
id="searchInput"
class="qo-input"
placeholder="@T("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchPlaceholder")"
onkeypress="if(event.key==='Enter') submitTextSearch()">
<button class="qo-search-btn" onclick="submitTextSearch()">
<i class="fa fa-search"></i> @T("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchButton")
</button>
</div>
<div id="recordingStatus" class="recording-status-bar" style="display:none;">
<span id="statusText">@T("Plugins.Misc.FruitBankPlugin.QuickOrder.ListeningStatus")</span>
<div class="volume-bar-container">
<div class="volume-bar volume-bar-silent"></div>
</div>
</div>
</div>
</div>
<!-- Two-column layout -->
<div class="qo-layout">
<!-- LEFT: Products -->
<div class="qo-products-panel">
<div id="transcribedCard" class="result-card" style="display:none;">
<div class="result-label"><i class="fa fa-microphone"></i> @T("Plugins.Misc.FruitBankPlugin.QuickOrder.TranscribedLabel")</div>
<div id="transcribedText" class="result-text"></div>
</div>
<div id="noResultsCard" class="no-results-card" style="display:none;">
<i class="fa fa-search"></i>
<p>@T("Plugins.Misc.FruitBankPlugin.QuickOrder.NoResultsText")</p>
</div>
<div id="productsLoadingState" class="products-empty-state">
<i class="fa fa-spinner fa-spin"></i>
<p>@T("Plugins.Misc.FruitBankPlugin.QuickOrder.LoadingProducts")</p>
</div>
<div id="productMatchesCard" style="display:none;">
<div class="matches-label">
<i class="fa fa-cubes"></i> <span id="matchesLabelText">@T("Plugins.Misc.FruitBankPlugin.QuickOrder.AllProductsLabel")</span>
@T("Plugins.Misc.FruitBankPlugin.QuickOrder.QuantityHint")
</div>
<div id="productButtons" class="product-grid"></div>
</div>
</div>
<!-- RIGHT: Cart -->
<div class="qo-cart-panel">
<div class="qo-section-title">
<i class="fa fa-shopping-basket"></i> @T("Plugins.Misc.FruitBankPlugin.QuickOrder.CartTitle")
<span id="cartItemCount" class="cart-count-badge">0</span>
</div>
<div id="cartEmptyState" class="cart-empty">
<i class="fa fa-shopping-basket"></i>
<p>@T("Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine1")<br>@T("Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine2")</p>
</div>
<div id="cartItemsList" class="cart-items-list" style="display:none;"></div>
<div id="cartTotalRow" class="cart-total-row" style="display:none;">
<div class="cart-total-note">
<i class="fa fa-info-circle"></i>
<small>@T("Plugins.Misc.FruitBankPlugin.QuickOrder.CartTotalNote")</small>
</div>
<div class="cart-total">
<span>@T("Plugins.Misc.FruitBankPlugin.QuickOrder.EstimatedTotal")</span>
<strong id="cartTotalAmount">0 Ft</strong>
</div>
</div>
<div id="cartActions" style="display:none;">
<a href="@Url.Action("Cart", "ShoppingCart")" class="btn-checkout">
<i class="fa fa-shopping-cart"></i> @T("Plugins.Misc.FruitBankPlugin.QuickOrder.CheckoutButton")
</a>
<a href="@Url.Action("Cart", "ShoppingCart")" class="btn-view-cart">
<i class="fa fa-eye"></i> @T("Plugins.Misc.FruitBankPlugin.QuickOrder.ViewCartButton")
</a>
</div>
</div>
</div>
</div><!-- /#mainContent -->
</div><!-- /.quick-order-page -->
@Html.AntiForgeryToken()
@* JS string bundle *@
<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))',
dsToday: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Today").Text))',
dsTomorrow: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Tomorrow").Text))',
dsSaving: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.Saving").Text))',
huDayNames: ['vas\u00e1rnap','h\u00e9tf\u0151','kedd','szerda','cs\u00fct\u00f6rt\u00f6k','p\u00e9ntek','szombat']
};
</script>
<script asp-location="Footer">
// ── State ─────────────────────────────────────────────────────────────────
var selectedDeliveryDate = null; // ISO date string e.g. "2026-04-16"
var selectedDeliveryTime = null; // e.g. "08:00"
var selectedDayLabel = null; // human-readable day label for chip
var mediaRecorder = null;
var audioChunks = [];
var isRecording = false;
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
};
// ── Init ──────────────────────────────────────────────────────────────────
$(document).ready(function () {
renderDayButtons();
$(document).on('click', '.ds-day-btn', function () {
$('.ds-day-btn').removeClass('selected');
$(this).addClass('selected');
selectedDeliveryDate = $(this).data('date');
selectedDayLabel = $(this).data('label');
checkDeliveryReady();
});
// Time picker: any valid time enables the confirm button
$('#deliveryTimePicker').on('input change', function () {
selectedDeliveryTime = $(this).val() || null;
checkDeliveryReady();
});
// Initialise with the default value already set in the input
selectedDeliveryTime = $('#deliveryTimePicker').val() || null;
$('#deliveryConfirmBtn').click(confirmDelivery);
$('#deliveryChangeBtn').click(function () {
$('#deliveryChip').hide();
$('#mainContent').hide();
$('#deliveryStep').show();
});
$('#recordBtn').click(startRecording);
$('#stopBtn').click(function () { stopRecording(false); });
loadCart();
// ── Restore previously saved delivery datetime (e.g. new tab / page refresh) ──
$.ajax({
url: '@Url.Action("GetDeliveryDateTime", "QuickOrder")',
type: 'GET',
success: function (result) {
if (!result.success || !result.hasValue) return;
// Restore state variables
selectedDeliveryDate = result.date; // e.g. "2026-04-17"
selectedDeliveryTime = result.time; // e.g. "08:00"
// Mark the correct day button as selected
var $btn = $('.ds-day-btn[data-date="' + result.date + '"]');
if ($btn.length) {
$btn.addClass('selected');
selectedDayLabel = $btn.data('label');
} else {
// The saved date is beyond the 7-day window shown — just use the date string
selectedDayLabel = result.date;
}
// Restore the time picker value
$('#deliveryTimePicker').val(result.time);
// Skip the step and go straight to the product list
var chipText = selectedDayLabel + ' \u2014 ' + result.time;
$('#deliveryChipText').text(chipText);
$('#deliveryStep').hide();
$('#deliveryChip').show();
$('#mainContent').show();
loadAllProducts();
}
// On error: silently leave the step visible — user picks again
});
});
// ── Delivery step ─────────────────────────────────────────────────────────
function renderDayButtons() {
var container = $('#dayButtons').empty();
var today = new Date();
for (var i = 0; i < 7; i++) {
var d = new Date(today);
d.setDate(today.getDate() + i);
var iso = d.toISOString().split('T')[0];
var dayName;
if (i === 0) dayName = qoStr.dsToday;
else if (i === 1) dayName = qoStr.dsTomorrow;
else dayName = qoStr.huDayNames[d.getDay()];
var dateStr = (d.getMonth() + 1) + '. ' + d.getDate() + '.';
var btn = $('<button type="button" class="ds-day-btn">')
.attr('data-date', iso)
.attr('data-label', dayName + ' ' + dateStr)
.html('<span class="ds-day-name">' + dayName + '</span><span class="ds-day-date">' + dateStr + '</span>');
container.append(btn);
}
}
function checkDeliveryReady() {
$('#deliveryConfirmBtn').prop('disabled', !(selectedDeliveryDate && selectedDeliveryTime));
}
function confirmDelivery() {
var btn = $('#deliveryConfirmBtn');
btn.prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> ' + qoStr.dsSaving);
// Combine date + time into ISO datetime string e.g. "2026-04-16T08:00"
var deliveryDateTime = selectedDeliveryDate + 'T' + selectedDeliveryTime;
$.ajax({
url: '@Url.Action("SetDeliveryDateTime", "QuickOrder")',
type: 'POST',
data: {
deliveryDateTime: deliveryDateTime,
__RequestVerificationToken: $('input[name="__RequestVerificationToken"]').val()
},
success: function (result) {
if (!result.success) {
alert(qoStr.errorPrefix + (result.message || ''));
btn.prop('disabled', false).html('<i class="fa fa-arrow-right"></i> @Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ConfirmButton").Text))');
return;
}
var chipText = selectedDayLabel + ' \u2014 ' + selectedDeliveryTime;
$('#deliveryChipText').text(chipText);
$('#deliveryStep').hide();
$('#deliveryChip').show();
$('#mainContent').show();
loadAllProducts();
},
error: function () {
alert(qoStr.errorPrefix);
btn.prop('disabled', false).html('<i class="fa fa-arrow-right"></i> @Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.DeliveryStep.ConfirmButton").Text))');
}
});
}
// ── Product list ──────────────────────────────────────────────────────────
function loadAllProducts() {
$('#transcribedCard').hide();
$('#noResultsCard').hide();
$('#productMatchesCard').hide();
$('#productsLoadingState').show();
$('#matchesLabelText').text(qoStr.allProducts);
$.ajax({
url: '@Url.Action("GetAllProducts", "QuickOrder")',
type: 'GET',
data: { deliveryDate: selectedDeliveryDate, deliveryTime: selectedDeliveryTime },
success: function (result) {
$('#productsLoadingState').hide();
if (result.success && result.products && result.products.length > 0) {
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('deliveryDate', selectedDeliveryDate || '');
formData.append('deliveryTime', selectedDeliveryTime || '');
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,
deliveryDate: selectedDeliveryDate,
deliveryTime: selectedDeliveryTime,
__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>

View File

@ -0,0 +1,203 @@
/*
* Preorder page supplemental styles
* Inherits all base styles from quick-order.css
*/
/* ── Day button window: 14-day grid ──────────────────────────────────── */
.ds-day-buttons {
display: flex;
flex-wrap: wrap;
gap: 6px;
max-height: 140px;
overflow-y: auto;
}
/* ── Info banner ──────────────────────────────────────────────────────── */
.po-info-banner {
background: #fff8ee;
border: 1px solid #f4a236;
border-left: 4px solid #f4a236;
border-radius: 8px;
padding: 12px 18px;
font-size: 14px;
color: #1a3c22;
margin-bottom: 20px;
display: flex;
align-items: flex-start;
gap: 10px;
line-height: 1.5;
}
.po-info-banner .fa {
color: #f4a236;
font-size: 16px;
flex-shrink: 0;
margin-top: 1px;
}
/* ── Product card: qty starts at 0, selected state ────────────────────── */
.po-product-card .qty-input {
color: #aaa;
}
.po-product-card.po-selected {
border-color: #2d7a3a;
box-shadow: 0 2px 10px rgba(45, 122, 58, 0.12);
}
.po-product-card.po-selected .qty-input {
color: #1a3c22;
font-weight: 700;
}
/* ── Note section ─────────────────────────────────────────────────────── */
.po-note-section {
margin-top: 24px;
background: #fff;
border: 1px solid #dde8da;
border-radius: 8px;
padding: 16px 18px;
}
.po-note-label {
display: flex;
align-items: center;
gap: 7px;
font-size: 13px;
font-weight: 700;
color: #2d7a3a;
text-transform: uppercase;
letter-spacing: 0.4px;
margin-bottom: 10px;
}
.po-note-label .fa {
font-size: 15px;
}
.po-note-input {
width: 100%;
border: 1px solid #dde8da;
border-radius: 6px;
padding: 10px 12px;
font-size: 14px;
font-family: 'DM Sans', sans-serif;
color: #2c2c2c;
resize: vertical;
min-height: 80px;
transition: border-color 0.2s;
}
.po-note-input:focus {
outline: none;
border-color: #2d7a3a;
}
/* ── Submit row ───────────────────────────────────────────────────────── */
.po-submit-row {
display: flex;
align-items: center;
gap: 16px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #dde8da;
}
.po-selection-summary {
font-size: 13px;
color: #6b7c6e;
flex: 1;
}
.po-submit-btn {
padding: 12px 28px;
background: #2d7a3a;
color: #fff;
border: none;
border-radius: 8px;
font-size: 15px;
font-family: 'DM Sans', sans-serif;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: background 0.18s;
flex-shrink: 0;
}
.po-submit-btn:hover:not(:disabled) {
background: #1a3c22;
}
.po-submit-btn:disabled {
background: #dde8da;
color: #6b7c6e;
cursor: default;
}
.po-submit-btn .fa {
font-size: 15px;
}
/* ── Success state ────────────────────────────────────────────────────── */
.po-success-state {
text-align: center;
padding: 60px 20px;
background: #fff;
border: 1px solid #dde8da;
border-radius: 8px;
margin-top: 24px;
}
.po-success-icon {
font-size: 64px;
color: #2d7a3a;
margin-bottom: 20px;
line-height: 1;
}
.po-success-state h2 {
font-size: 24px;
font-weight: 800;
color: #1a3c22;
margin-bottom: 12px;
}
.po-success-state p {
font-size: 15px;
color: #6b7c6e;
margin-bottom: 28px;
line-height: 1.6;
}
.po-back-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 28px;
background: #f5f7f2;
color: #2d7a3a !important;
border: 1px solid #dde8da;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
text-decoration: none;
transition: background 0.18s;
}
.po-back-btn:hover {
background: #dde8da;
}
/* ── Responsive ───────────────────────────────────────────────────────── */
@media (max-width: 600px) {
.po-submit-row {
flex-direction: column;
align-items: stretch;
}
.po-submit-btn {
justify-content: center;
}
}

View File

@ -0,0 +1,872 @@
/*
* 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;
}
/*
DELIVERY STEP CARD
*/
.qo-delivery-step {
background: #fff;
border: 1px solid #dde8da;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(45, 122, 58, 0.10);
margin-bottom: 24px;
overflow: hidden;
}
.ds-header {
background: #1a3c22;
color: #fff;
padding: 18px 24px;
display: flex;
align-items: center;
gap: 16px;
}
.ds-header > .fa {
font-size: 28px;
color: #f4a236;
flex-shrink: 0;
}
.ds-title {
font-size: 18px;
font-weight: 800;
line-height: 1.2;
}
.ds-subtitle {
font-size: 13px;
color: rgba(255,255,255,0.7);
margin-top: 3px;
}
.ds-body {
padding: 20px 24px;
}
.ds-section-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
color: #6b7c6e;
margin-bottom: 10px;
}
/* Day buttons */
.ds-day-buttons {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.ds-day-btn {
display: flex;
flex-direction: column;
align-items: center;
padding: 10px 18px;
background: #f5f7f2;
border: 2px solid #dde8da;
border-radius: 8px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
min-width: 76px;
font-family: 'DM Sans', sans-serif;
}
.ds-day-btn:hover {
border-color: #2d7a3a;
background: #eef4eb;
}
.ds-day-btn.selected {
border-color: #2d7a3a;
background: #2d7a3a;
color: #fff;
}
.ds-day-name {
font-size: 13px;
font-weight: 700;
line-height: 1.2;
}
.ds-day-date {
font-size: 11px;
opacity: 0.75;
margin-top: 2px;
}
/* Time picker */
.ds-time-wrapper {
display: flex;
align-items: center;
gap: 14px;
}
.ds-time-input {
height: 48px;
padding: 0 16px;
font-size: 22px;
font-family: 'DM Sans', sans-serif;
font-weight: 700;
color: #1a3c22;
background: #f5f7f2;
border: 2px solid #dde8da;
border-radius: 8px;
cursor: pointer;
transition: border-color 0.15s;
width: 160px;
}
.ds-time-input:focus {
outline: none;
border-color: #2d7a3a;
background: #fff;
}
.ds-time-hint {
font-size: 13px;
color: #6b7c6e;
}
/* Confirm button */
.ds-footer {
padding: 16px 24px 20px;
border-top: 1px solid #f5f7f2;
display: flex;
justify-content: flex-end;
}
.ds-confirm-btn {
padding: 12px 32px;
background: #2d7a3a;
color: #fff;
border: none;
border-radius: 8px;
font-size: 15px;
font-family: 'DM Sans', sans-serif;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: background 0.18s;
}
.ds-confirm-btn:hover:not(:disabled) {
background: #1a3c22;
}
.ds-confirm-btn:disabled {
background: #dde8da;
color: #6b7c6e;
cursor: default;
}
.ds-confirm-btn .fa {
font-size: 14px;
}
/*
DELIVERY CHIP (collapsed state)
*/
.qo-delivery-chip {
background: #1a3c22;
border-radius: 8px;
padding: 10px 18px;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 10px;
color: #fff;
font-size: 14px;
}
.qo-delivery-chip > .fa {
color: #f4a236;
font-size: 16px;
flex-shrink: 0;
}
.dc-label {
color: rgba(255,255,255,0.65);
font-size: 12px;
white-space: nowrap;
}
#deliveryChipText {
font-weight: 700;
font-size: 14px;
flex: 1;
}
.dc-change-btn {
margin-left: auto;
padding: 5px 14px;
background: rgba(255,255,255,0.12);
border: 1px solid rgba(255,255,255,0.25);
border-radius: 6px;
color: #fff;
font-size: 12px;
font-family: 'DM Sans', sans-serif;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
white-space: nowrap;
transition: background 0.15s;
flex-shrink: 0;
}
.dc-change-btn:hover {
background: rgba(244,162,54,0.25);
border-color: #f4a236;
}
/*
SEARCH BAR
*/
.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-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; }
.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; }
.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 {
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)
*/
.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-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;
}
.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;
}
.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 {
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;
}
.pc-body {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 14px;
}
.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;
}
.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;
}
.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;
}
.pc-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.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; }
.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; }
.ds-day-buttons { gap: 6px; }
.ds-day-btn { min-width: 64px; padding: 8px 12px; }
}
@media (max-width: 600px) {
.quick-order-page { width: 100%; padding: 12px 12px 40px; }
.ds-header { padding: 14px 16px; }
.ds-body { padding: 16px; }
.ds-footer { padding: 12px 16px 16px; }
.ds-confirm-btn { width: 100%; justify-content: center; }
.ds-time-wrapper { flex-direction: column; align-items: flex-start; }
.qo-delivery-chip { flex-wrap: wrap; gap: 6px; }
#deliveryChipText { flex: 1 1 100%; order: 3; }
.dc-change-btn { margin-left: 0; }
.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; }
}