merge
This commit is contained in:
commit
9b2e34f7b5
|
|
@ -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 →</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 →</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 →</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 ? ' <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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 factory’s ~5 queries per row.
|
||||
int? filterCustomerId = int.TryParse(billingCompany, out var cid) && cid > 0 ? cid : null;
|
||||
|
||||
// UTC conversion for date filters (same logic as base factory)
|
||||
var currentTz = await _dateTimeHelper.GetCurrentTimeZoneAsync();
|
||||
DateTime? startUtc = startDate.HasValue ? (DateTime?)_dateTimeHelper.ConvertToUtcTime(startDate.Value, currentTz) : null;
|
||||
DateTime? endUtc = endDate.HasValue ? (DateTime?)_dateTimeHelper.ConvertToUtcTime(endDate.Value, currentTz).AddDays(1) : null;
|
||||
|
||||
var query = _dbContext.OrderDtos
|
||||
.GetAll(true) // loads GenericAttributes in 1 batch query
|
||||
.Where(o => !o.Deleted);
|
||||
|
||||
if (startUtc.HasValue) query = query.Where(o => o.CreatedOnUtc >= startUtc.Value);
|
||||
if (endUtc.HasValue) query = query.Where(o => o.CreatedOnUtc <= endUtc.Value);
|
||||
if (filterCustomerId.HasValue) query = query.Where(o => o.CustomerId == filterCustomerId.Value);
|
||||
if (orderStatusIds.Any()) query = query.Where(o => orderStatusIds.Contains(o.OrderStatusId));
|
||||
if (paymentStatusIds.Any()) query = query.Where(o => paymentStatusIds.Contains(o.PaymentStatusId));
|
||||
if (shippingStatusIds.Any()) query = query.Where(o => shippingStatusIds.Contains(o.ShippingStatusId));
|
||||
|
||||
// Apply sort at DB level
|
||||
bool asc = sortDir == "asc";
|
||||
query = sortColName.ToLowerInvariant() switch
|
||||
{
|
||||
"customordernumber" => asc ? query.OrderBy(o => o.CustomOrderNumber) : query.OrderByDescending(o => o.CustomOrderNumber),
|
||||
"createdon" => asc ? query.OrderBy(o => o.CreatedOnUtc) : query.OrderByDescending(o => o.CreatedOnUtc),
|
||||
"dateofreceipt" => asc ? query.OrderBy(o => o.DateOfReceipt) : query.OrderByDescending(o => o.DateOfReceipt),
|
||||
"orderstatusid" => asc ? query.OrderBy(o => o.OrderStatusId) : query.OrderByDescending(o => o.OrderStatusId),
|
||||
"measuringstatus" => asc ? query.OrderBy(o => o.MeasuringStatus) : query.OrderByDescending(o => o.MeasuringStatus),
|
||||
"customercompany" => asc ? query.OrderBy(o => o.CustomerId) : query.OrderByDescending(o => o.CustomerId),
|
||||
_ => query.OrderByDescending(o => o.Id)
|
||||
};
|
||||
|
||||
// Per-column DB-mappable filters
|
||||
if (colSearch.TryGetValue("CustomOrderNumber", out var coNum) && !string.IsNullOrEmpty(coNum))
|
||||
query = query.Where(o => o.CustomOrderNumber.Contains(coNum));
|
||||
if (colSearch.TryGetValue("OrderStatusId", out var osColStr) && int.TryParse(osColStr, out var osColId))
|
||||
query = query.Where(o => o.OrderStatusId == osColId);
|
||||
if (colSearch.TryGetValue("MeasuringStatus", out var msColStr) && int.TryParse(msColStr, out var msColId))
|
||||
query = query.Where(o => (int)o.MeasuringStatus == msColId);
|
||||
// IsMeasurable: computed from OrderItemDtos – pre-query the OrderItem table
|
||||
// to get order IDs where any item belongs to a measurable product, then filter SQL
|
||||
var isMeasurableColVal = colSearch.TryGetValue("IsMeasurable", out var imcs) ? imcs : null;
|
||||
bool? effectiveIsMeasurable = isMeasurableFilter;
|
||||
if (isMeasurableColVal != null && bool.TryParse(isMeasurableColVal, out var imcb))
|
||||
effectiveIsMeasurable = imcb;
|
||||
|
||||
if (effectiveIsMeasurable.HasValue)
|
||||
{
|
||||
// Get all order IDs where any item has a measurable product
|
||||
var measurableOrderIds = await _dbContext.OrderItemDtos
|
||||
.GetAll(false)
|
||||
.Where(oi => oi.ProductDto != null && oi.ProductDto.IsMeasurable)
|
||||
.Select(oi => oi.OrderId)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
|
||||
query = effectiveIsMeasurable.Value
|
||||
? query.Where(o => measurableOrderIds.Contains(o.Id))
|
||||
: query.Where(o => !measurableOrderIds.Contains(o.Id));
|
||||
|
||||
_logger.Info($"[PERF] FruitBankOrderList – IsMeasurable pre-query: {measurableOrderIds.Count} measurable order IDs");
|
||||
}
|
||||
|
||||
// CustomerCompany column search: pre-query Customer table for matching IDs
|
||||
if (colSearch.TryGetValue("CustomerCompany", out var ccColVal) && !string.IsNullOrEmpty(ccColVal))
|
||||
{
|
||||
var matchingCustomerIds = await _dbContext.Customers.Table
|
||||
.Where(c => c.Company.Contains(ccColVal) ||
|
||||
(c.FirstName + " " + c.LastName).Contains(ccColVal))
|
||||
.Select(c => c.Id)
|
||||
.ToListAsync();
|
||||
|
||||
query = query.Where(o => matchingCustomerIds.Contains(o.CustomerId));
|
||||
_logger.Info($"[PERF] FruitBankOrderList – CustomerCompany pre-query: {matchingCustomerIds.Count} matching customers");
|
||||
}
|
||||
|
||||
// COUNT – runs as a simple SELECT COUNT(*) against the filtered set
|
||||
int total;
|
||||
try { total = await query.CountAsync(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"FruitBankOrderList – count error: {ex.Message}", ex);
|
||||
return Json(new { draw, recordsTotal = 0, recordsFiltered = 0, data = Array.Empty<object>() });
|
||||
}
|
||||
|
||||
_logger.Info($"[PERF] FruitBankOrderList – COUNT {sw.ElapsedMilliseconds} ms | total: {total}");
|
||||
sw.Restart();
|
||||
|
||||
// Step 1: get just the IDs for the current page (plain SQL, no relations)
|
||||
List<int> pageIds;
|
||||
try
|
||||
{
|
||||
pageIds = await query.Skip(start).Take(length).Select(o => o.Id).ToListAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"FruitBankOrderList – page IDs query error: {ex.Message}", ex);
|
||||
return Json(new { draw, recordsTotal = 0, recordsFiltered = 0, data = Array.Empty<object>() });
|
||||
}
|
||||
|
||||
_logger.Info($"[PERF] FruitBankOrderList – page IDs (Skip+Take) {sw.ElapsedMilliseconds} ms | ids: {pageIds.Count}");
|
||||
sw.Restart();
|
||||
|
||||
// Step 2: reload those ~50 rows with only the relations we need.
|
||||
// LoadWith works here because it’s applied to the base table query, not a filtered IQueryable.
|
||||
List<OrderDto> rows;
|
||||
try
|
||||
{
|
||||
// GetAllByIds(ids, false) uses GetAll(false) which has LoadWith(GenericAttributes) baked in.
|
||||
// LoadWith on a chained IQueryable is not supported by LinqToDB.
|
||||
rows = await _dbContext.OrderDtos
|
||||
.GetAllByIds(pageIds, true)
|
||||
.ToListAsync();
|
||||
|
||||
// Re-sort to match the original query order (IN clause doesn’t guarantee order)
|
||||
rows = pageIds
|
||||
.Select(id => rows.FirstOrDefault(r => r.Id == id))
|
||||
.Where(r => r != null)
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"FruitBankOrderList – relations query error: {ex.Message}", ex);
|
||||
return Json(new { draw, recordsTotal = 0, recordsFiltered = 0, data = Array.Empty<object>() });
|
||||
}
|
||||
|
||||
_logger.Info($"[PERF] FruitBankOrderList – relations load (GetAll+LoadWith) {sw.ElapsedMilliseconds} ms | rows: {rows.Count}");
|
||||
sw.Restart();
|
||||
|
||||
var userTz = currentTz;
|
||||
|
||||
// ── 4. Map to lightweight DTO ──────────────────────────────────
|
||||
static string MeasuringStatusLabel(MeasuringStatus s) => s switch
|
||||
{
|
||||
MeasuringStatus.NotStarted => "Nincs elindítva",
|
||||
MeasuringStatus.Started => "Folyamatban",
|
||||
MeasuringStatus.Finnished => "Mérve",
|
||||
MeasuringStatus.Audited => "Lezárva",
|
||||
_ => s.ToString()
|
||||
};
|
||||
static string OrderStatusLabel(int id) => id switch
|
||||
{
|
||||
10 => "Függőben",
|
||||
20 => "Feldolgozás",
|
||||
30 => "Teljesítve",
|
||||
40 => "Törölve",
|
||||
_ => id.ToString()
|
||||
};
|
||||
static string PaymentStatusLabel(int id) => id switch
|
||||
{
|
||||
10 => "Fizetésre vár",
|
||||
20 => "Félig fizetve",
|
||||
30 => "Fizetve",
|
||||
35 => "Túlfizetve",
|
||||
40 => "Visszatérítve",
|
||||
_ => id.ToString()
|
||||
};
|
||||
static string ShippingStatusLabel(int id) => id switch
|
||||
{
|
||||
10 => "Szállítás nincs",
|
||||
20 => "Nincs kiszállítva",
|
||||
25 => "Részben kiszállítva",
|
||||
30 => "Kiszállítva",
|
||||
_ => id.ToString()
|
||||
};
|
||||
|
||||
var dtos = rows.Select(o =>
|
||||
{
|
||||
var ga = o.GenericAttributes;
|
||||
var dateOfReceipt = ga?.FirstOrDefault(a => a.Key == "DateOfReceipt")?.Value is string dv && DateTime.TryParse(dv, out var dp) ? dp : (DateTime?)null;
|
||||
var innvoiceTechId = ga?.FirstOrDefault(a => a.Key == "InnVoiceOrderTechId")?.Value;
|
||||
var company = o.Customer != null
|
||||
? $"{o.Customer.Company} {o.Customer.FirstName}_{o.Customer.LastName}".Trim()
|
||||
: string.Empty;
|
||||
|
||||
return new Nop.Plugin.Misc.FruitBankPlugin.Models.Orders.FruitBankOrderRowDto
|
||||
{
|
||||
Id = o.Id,
|
||||
CustomOrderNumber = o.CustomOrderNumber,
|
||||
CustomerCompany = company,
|
||||
CustomerId = o.CustomerId,
|
||||
InnvoiceTechId = innvoiceTechId,
|
||||
IsAllOrderItemAvgWeightValid = o.IsAllOrderItemAvgWeightValid,
|
||||
IsMeasurable = o.IsMeasurable,
|
||||
MeasuringStatus = (int)o.MeasuringStatus,
|
||||
MeasuringStatusString = MeasuringStatusLabel(o.MeasuringStatus),
|
||||
DateOfReceipt = dateOfReceipt,
|
||||
OrderStatusId = o.OrderStatusId,
|
||||
OrderStatus = OrderStatusLabel(o.OrderStatusId),
|
||||
PaymentStatusId = o.PaymentStatusId,
|
||||
PaymentStatus = PaymentStatusLabel(o.PaymentStatusId),
|
||||
ShippingStatusId = o.ShippingStatusId,
|
||||
ShippingStatus = ShippingStatusLabel(o.ShippingStatusId),
|
||||
StoreName = string.Empty, // not needed in grid
|
||||
CreatedOn = TimeZoneInfo.ConvertTimeFromUtc(o.CreatedOnUtc, userTz),
|
||||
OrderTotal = !o.IsComplete && o.IsMeasurable
|
||||
? "kalkuláció alatt..."
|
||||
: $"{o.OrderTotal:N0} Ft"
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
_logger.Info($"[PERF] FruitBankOrderList – DTO mapping {sw.ElapsedMilliseconds} ms");
|
||||
sw.Restart();
|
||||
|
||||
// InnVoice filter is post-query (it’s in GenericAttributes, not a plain column)
|
||||
if (hasInnvoiceFilter.HasValue)
|
||||
dtos = hasInnvoiceFilter.Value
|
||||
? dtos.Where(o => !string.IsNullOrEmpty(o.InnvoiceTechId)).ToList()
|
||||
: dtos.Where(o => string.IsNullOrEmpty(o.InnvoiceTechId)).ToList();
|
||||
|
||||
// InnVoice column-header filter (post-query: stored in GenericAttributes)
|
||||
if (colSearch.TryGetValue("InnvoiceTechId", out var innColVal))
|
||||
dtos = innColVal == "has" ? dtos.Where(o => !string.IsNullOrEmpty(o.InnvoiceTechId)).ToList()
|
||||
: innColVal == "none" ? dtos.Where(o => string.IsNullOrEmpty(o.InnvoiceTechId)).ToList()
|
||||
: dtos;
|
||||
|
||||
var result = Json(new { draw, recordsTotal = total, recordsFiltered = total, data = dtos });
|
||||
|
||||
_logger.Info($"[PERF] FruitBankOrderList – JSON serialize {sw.ElapsedMilliseconds} ms");
|
||||
_logger.Info($"[PERF] FruitBankOrderList – TOTAL {swTotal.ElapsedMilliseconds} ms | page: {dtos.Count}");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <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 });
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -41,6 +41,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
|
|||
private readonly FileStorageService _fileStorageService;
|
||||
private readonly FruitBankAttributeService _fruitBankAttributeService;
|
||||
private readonly IStoreContext _storeContext;
|
||||
private readonly PreorderConversionService _preorderConversionService;
|
||||
|
||||
public FileManagerController(
|
||||
IPermissionService permissionService,
|
||||
|
|
@ -53,7 +54,8 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
|
|||
IWorkContext workContext,
|
||||
FileStorageService fileStorageService,
|
||||
FruitBankAttributeService fruitBankAttributeService,
|
||||
IStoreContext storeContext)
|
||||
IStoreContext storeContext,
|
||||
PreorderConversionService preorderConversionService)
|
||||
{
|
||||
_permissionService = permissionService;
|
||||
_aiApiService = aiApiService;
|
||||
|
|
@ -66,6 +68,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
|
|||
_fileStorageService = fileStorageService;
|
||||
_fruitBankAttributeService = fruitBankAttributeService;
|
||||
_storeContext = storeContext;
|
||||
_preorderConversionService = preorderConversionService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -1120,6 +1123,32 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
|
|||
newIncomingQuantity, _storeContext.GetCurrentStore().Id
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step 3: Convert pending preorders that cover these products ──────────
|
||||
var productIdsWithIncoming = shippingDocument.ShippingItems
|
||||
.Where(x => x.ProductId != null)
|
||||
.Select(x => x.ProductId!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (productIdsWithIncoming.Any())
|
||||
{
|
||||
// Fire-and-forget with error isolation so a conversion failure
|
||||
// never blocks the shipping document save response
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _preorderConversionService
|
||||
.ConvertPreordersForProductsAsync(productIdsWithIncoming, shippingDocument.Id);
|
||||
}
|
||||
catch (Exception convEx)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[PreorderConversion] Error during conversion for document #{shippingDocument.Id}: {convEx.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return Json(new
|
||||
|
|
|
|||
|
|
@ -34,7 +34,9 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
|
|||
ApiBaseUrl = _settings.ApiBaseUrl,
|
||||
MaxTokens = _settings.MaxTokens,
|
||||
Temperature = _settings.Temperature,
|
||||
RequestTimeoutSeconds = _settings.RequestTimeoutSeconds
|
||||
RequestTimeoutSeconds = _settings.RequestTimeoutSeconds,
|
||||
ZaiApiKey = _settings.ZaiApiKey,
|
||||
ZaiModel = _settings.ZaiModel
|
||||
};
|
||||
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Configure/Configure.cshtml", model);
|
||||
}
|
||||
|
|
@ -58,6 +60,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
|
|||
_settings.MaxTokens = model.MaxTokens;
|
||||
_settings.Temperature = model.Temperature;
|
||||
_settings.RequestTimeoutSeconds = model.RequestTimeoutSeconds;
|
||||
_settings.ZaiApiKey = model.ZaiApiKey ?? string.Empty;
|
||||
_settings.ZaiModel = model.ZaiModel ?? "glm-ocr";
|
||||
|
||||
// Save settings
|
||||
await _settingService.SaveSettingAsync(_settings);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,452 @@
|
|||
using FruitBank.Common.Entities;
|
||||
using FruitBank.Common.Enums;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nop.Core.Domain.Customers;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||
using Nop.Services.Customers;
|
||||
using Nop.Services.Security;
|
||||
using Nop.Web.Framework;
|
||||
using Nop.Web.Framework.Controllers;
|
||||
using Nop.Web.Framework.Mvc.Filters;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers;
|
||||
|
||||
[AuthorizeAdmin]
|
||||
[Area(AreaNames.ADMIN)]
|
||||
[AutoValidateAntiforgeryToken]
|
||||
public class PreorderAdminController : BasePluginController
|
||||
{
|
||||
private readonly IPermissionService _permissionService;
|
||||
private readonly PreorderDbContext _preorderDbContext;
|
||||
private readonly FruitBankDbContext _dbContext;
|
||||
private readonly ICustomerService _customerService;
|
||||
private readonly PreorderConversionService _preorderConversionService;
|
||||
|
||||
private static readonly Dictionary<PreorderStatus, string> StatusLabels = new()
|
||||
{
|
||||
{ PreorderStatus.Pending, "Függőben" },
|
||||
{ PreorderStatus.Confirmed, "Megerősítve" },
|
||||
{ PreorderStatus.PartiallyFulfilled, "Részben teljesítve" },
|
||||
{ PreorderStatus.Cancelled, "Törölve" }
|
||||
};
|
||||
|
||||
private static readonly Dictionary<PreorderItemStatus, string> ItemStatusLabels = new()
|
||||
{
|
||||
{ PreorderItemStatus.Pending, "Függőben" },
|
||||
{ PreorderItemStatus.Fulfilled, "Teljesítve" },
|
||||
{ PreorderItemStatus.PartiallyFulfilled, "Részben" },
|
||||
{ PreorderItemStatus.Dropped, "Ejtve" }
|
||||
};
|
||||
|
||||
public PreorderAdminController(
|
||||
IPermissionService permissionService,
|
||||
PreorderDbContext preorderDbContext,
|
||||
FruitBankDbContext dbContext,
|
||||
ICustomerService customerService,
|
||||
PreorderConversionService preorderConversionService)
|
||||
{
|
||||
_permissionService = permissionService;
|
||||
_preorderDbContext = preorderDbContext;
|
||||
_dbContext = dbContext;
|
||||
_customerService = customerService;
|
||||
_preorderConversionService = preorderConversionService;
|
||||
}
|
||||
|
||||
// ── LIST PAGE ─────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet]
|
||||
[Route("Admin/Preorders")]
|
||||
public async Task<IActionResult> List()
|
||||
{
|
||||
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||
return AccessDeniedView();
|
||||
|
||||
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Preorder/List.cshtml");
|
||||
}
|
||||
|
||||
// ── DATATABLES SERVER-SIDE ────────────────────────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
[Route("Admin/Preorders/PreorderList")]
|
||||
public async Task<IActionResult> PreorderList()
|
||||
{
|
||||
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||
return Forbid();
|
||||
|
||||
_ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1);
|
||||
_ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0);
|
||||
_ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 25 : Math.Min(length, 500);
|
||||
|
||||
_ = int.TryParse(Request.Form["order[0][column]"].FirstOrDefault(), out int sortColIdx);
|
||||
var sortDir = Request.Form["order[0][dir]"].FirstOrDefault() ?? "desc";
|
||||
var sortColName = Request.Form[$"columns[{sortColIdx}][name]"].FirstOrDefault() ?? "CreatedOnUtc";
|
||||
|
||||
var globalSearch = Request.Form["search[value]"].FirstOrDefault()?.Trim() ?? "";
|
||||
var statusFilter = Request.Form["statusFilter"].FirstOrDefault()?.Trim() ?? "";
|
||||
|
||||
// 1. All preorders with items — two queries
|
||||
var preorders = await _preorderDbContext.Preorders.GetAll(false).ToListAsync();
|
||||
var allItems = await _preorderDbContext.PreorderItems.GetAll().ToListAsync();
|
||||
|
||||
var itemsByPreorder = allItems
|
||||
.GroupBy(i => i.PreorderId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
// 2. Customers — batch
|
||||
var customerIds = preorders.Select(p => p.CustomerId).Distinct().ToList();
|
||||
var customers = await _dbContext.Customers.Table
|
||||
.Where(c => customerIds.Contains(c.Id))
|
||||
.Select(c => new { c.Id, c.Email, c.FirstName, c.LastName })
|
||||
.ToListAsync();
|
||||
var customerById = customers.ToDictionary(c => c.Id);
|
||||
|
||||
// 3. Linked orders — find orders created from preorders via CustomOrderNumber lookup
|
||||
// We store the preorder id in the order note, but the simplest link is checking
|
||||
// OrderNotes for "előrendelésből" text matching preorderId.
|
||||
// For now we surface the link on the detail page only.
|
||||
|
||||
// 4. Build rows — derive status from quantities, not enum (LinqToDB enum reads unreliable)
|
||||
var rows = preorders.Select(p =>
|
||||
{
|
||||
customerById.TryGetValue(p.CustomerId, out var c);
|
||||
var items = itemsByPreorder.TryGetValue(p.Id, out var its) ? its : new();
|
||||
|
||||
// Derive status from quantities rather than relying on the enum read
|
||||
var fulfilledCount = items.Count(i => i.FulfilledQuantity > 0);
|
||||
var allFulfilled = items.Any() && items.All(i => i.FulfilledQuantity >= i.RequestedQuantity);
|
||||
var anyFulfilled = items.Any(i => i.FulfilledQuantity > 0);
|
||||
var hasOrderId = p.OrderId.HasValue;
|
||||
|
||||
// Derive a display status: use the DB enum if it looks valid (non-zero),
|
||||
// otherwise infer from quantities
|
||||
var effectiveStatus = (int)p.Status != 0
|
||||
? p.Status
|
||||
: allFulfilled ? PreorderStatus.Confirmed
|
||||
: anyFulfilled ? PreorderStatus.PartiallyFulfilled
|
||||
: PreorderStatus.Pending;
|
||||
|
||||
return new PreorderListRow
|
||||
{
|
||||
PreorderId = p.Id,
|
||||
CustomerId = p.CustomerId,
|
||||
CustomerName = c != null ? $"{c.FirstName} {c.LastName}".Trim() : $"#{p.CustomerId}",
|
||||
CustomerEmail = c?.Email ?? string.Empty,
|
||||
DateOfReceipt = p.DateOfReceipt.ToLocalTime().ToString("yyyy.MM.dd HH:mm"),
|
||||
CreatedOnUtc = p.CreatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd HH:mm"),
|
||||
Status = effectiveStatus,
|
||||
StatusLabel = StatusLabels.TryGetValue(effectiveStatus, out var sl) ? sl : effectiveStatus.ToString(),
|
||||
ItemCount = items.Count,
|
||||
FulfilledCount = fulfilledCount,
|
||||
OrderId = p.OrderId
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
int recordsTotal = rows.Count;
|
||||
|
||||
// 5. Filter by status
|
||||
if (!string.IsNullOrWhiteSpace(statusFilter) && Enum.TryParse<PreorderStatus>(statusFilter, out var statusEnum))
|
||||
rows = rows.Where(r => r.Status == statusEnum).ToList();
|
||||
|
||||
// 6. Global search
|
||||
if (!string.IsNullOrWhiteSpace(globalSearch))
|
||||
rows = rows.Where(r =>
|
||||
r.CustomerName.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) ||
|
||||
r.CustomerEmail.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) ||
|
||||
r.PreorderId.ToString().Contains(globalSearch)
|
||||
).ToList();
|
||||
|
||||
int recordsFiltered = rows.Count;
|
||||
|
||||
// 7. Sort
|
||||
bool asc = sortDir == "asc";
|
||||
rows = sortColName switch
|
||||
{
|
||||
"CustomerName" => asc ? rows.OrderBy(r => r.CustomerName).ToList() : rows.OrderByDescending(r => r.CustomerName).ToList(),
|
||||
"DateOfReceipt" => asc ? rows.OrderBy(r => r.DateOfReceipt).ToList() : rows.OrderByDescending(r => r.DateOfReceipt).ToList(),
|
||||
"Status" => asc ? rows.OrderBy(r => r.Status).ToList() : rows.OrderByDescending(r => r.Status).ToList(),
|
||||
_ => asc ? rows.OrderBy(r => r.CreatedOnUtc).ToList() : rows.OrderByDescending(r => r.CreatedOnUtc).ToList()
|
||||
};
|
||||
|
||||
// 8. Paginate
|
||||
var page = rows.Skip(start).Take(length).ToList();
|
||||
|
||||
return Json(new { draw, recordsTotal, recordsFiltered, data = page });
|
||||
}
|
||||
|
||||
// ── DETAIL PAGE ───────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet]
|
||||
[Route("Admin/Preorders/Detail/{id:int}")]
|
||||
public async Task<IActionResult> Detail(int id)
|
||||
{
|
||||
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||
return AccessDeniedView();
|
||||
|
||||
var preorder = await _preorderDbContext.Preorders.GetByIdAsync(id, loadRelations: false);
|
||||
if (preorder == null) return NotFound();
|
||||
|
||||
var items = await _preorderDbContext.PreorderItems
|
||||
.GetAllByPreorderIdAsync(id)
|
||||
.ToListAsync();
|
||||
|
||||
var customer = await _customerService.GetCustomerByIdAsync(preorder.CustomerId);
|
||||
|
||||
// Resolve product names in one batch
|
||||
var productIds = items.Select(i => i.ProductId).Distinct().ToList();
|
||||
var productDtos = await _dbContext.ProductDtos
|
||||
.GetAll(false)
|
||||
.Where(p => productIds.Contains(p.Id))
|
||||
.ToListAsync();
|
||||
var productById = productDtos.ToDictionary(p => p.Id);
|
||||
|
||||
// Use preorder.OrderId directly — stored on the entity at conversion time
|
||||
int? linkedOrderId = preorder.OrderId;
|
||||
|
||||
var model = new PreorderDetailModel
|
||||
{
|
||||
PreorderId = preorder.Id,
|
||||
CustomerId = preorder.CustomerId,
|
||||
CustomerName = customer != null ? $"{customer.FirstName} {customer.LastName}".Trim() : $"#{preorder.CustomerId}",
|
||||
CustomerEmail = customer?.Email ?? string.Empty,
|
||||
DateOfReceipt = preorder.DateOfReceipt.ToLocalTime().ToString("yyyy.MM.dd HH:mm"),
|
||||
CreatedOnUtc = preorder.CreatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd HH:mm"),
|
||||
UpdatedOnUtc = preorder.UpdatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd HH:mm"),
|
||||
Status = preorder.Status,
|
||||
CustomerNote = preorder.CustomerNote,
|
||||
OrderId = linkedOrderId,
|
||||
Items = items.Select(i =>
|
||||
{
|
||||
productById.TryGetValue(i.ProductId, out var dto);
|
||||
|
||||
// Derive item status from quantities — enum reads unreliable in LinqToDB
|
||||
var derivedStatus = i.FulfilledQuantity == 0
|
||||
? PreorderItemStatus.Pending
|
||||
: i.FulfilledQuantity >= i.RequestedQuantity
|
||||
? PreorderItemStatus.Fulfilled
|
||||
: PreorderItemStatus.PartiallyFulfilled;
|
||||
|
||||
// If DB enum read as non-zero, prefer it; otherwise use derived
|
||||
var effectiveItemStatus = (int)i.Status != 0 ? i.Status : derivedStatus;
|
||||
|
||||
return new PreorderDetailItemRow
|
||||
{
|
||||
ItemId = i.Id,
|
||||
ProductId = i.ProductId,
|
||||
ProductName = dto?.Name ?? $"Product #{i.ProductId}",
|
||||
IsMeasurable = dto?.IsMeasurable ?? false,
|
||||
RequestedQuantity = i.RequestedQuantity,
|
||||
FulfilledQuantity = i.FulfilledQuantity,
|
||||
UnitPriceInclTax = i.UnitPriceInclTax,
|
||||
Status = effectiveItemStatus,
|
||||
StatusLabel = ItemStatusLabels.TryGetValue(effectiveItemStatus, out var isl) ? isl : effectiveItemStatus.ToString()
|
||||
};
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Preorder/Detail.cshtml", model);
|
||||
}
|
||||
|
||||
// ── CREATE (admin phone order) ───────────────────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
[Route("Admin/Preorders/CreatePreorder")]
|
||||
public async Task<IActionResult> CreatePreorder(
|
||||
int customerId,
|
||||
string deliveryDateTime,
|
||||
string? customerNote,
|
||||
string productsJson)
|
||||
{
|
||||
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||
return Json(new { success = false, error = "Hozzáférés megtagadva" });
|
||||
|
||||
try
|
||||
{
|
||||
// Validate customer
|
||||
var customer = await _customerService.GetCustomerByIdAsync(customerId);
|
||||
if (customer == null)
|
||||
return Json(new { success = false, error = "Az ügyfél nem található" });
|
||||
|
||||
// Validate delivery date
|
||||
if (!DateTime.TryParse(deliveryDateTime, out var deliveryDate))
|
||||
return Json(new { success = false, error = "Érvénytelen szállítási dátum" });
|
||||
|
||||
// Parse products
|
||||
if (string.IsNullOrWhiteSpace(productsJson))
|
||||
return Json(new { success = false, error = "Nincs termék megadva" });
|
||||
|
||||
var productItems = System.Text.Json.JsonSerializer.Deserialize<List<ProductItemRequest>>(productsJson);
|
||||
if (productItems == null || !productItems.Any())
|
||||
return Json(new { success = false, error = "Nincs érvényes termék" });
|
||||
|
||||
// Get store
|
||||
var storeId = (await _dbContext.Shippings.GetAll().Select(s => s.Id).FirstOrDefaultAsync() > 0)
|
||||
? 1 : 1; // fallback to store 1
|
||||
// Use first available store from generic attributes context
|
||||
var gaStore = await _dbContext.GenericAttributes.Table
|
||||
.Select(g => g.StoreId).FirstOrDefaultAsync();
|
||||
storeId = gaStore > 0 ? gaStore : 1;
|
||||
|
||||
var preorder = new Preorder
|
||||
{
|
||||
CustomerId = customerId,
|
||||
StoreId = storeId,
|
||||
DateOfReceipt = deliveryDate,
|
||||
CustomerNote = customerNote?.Trim()
|
||||
};
|
||||
|
||||
var items = new List<PreorderItem>();
|
||||
foreach (var pi in productItems.Where(p => p.quantity > 0))
|
||||
{
|
||||
var product = await _dbContext.Products.GetByIdAsync(pi.id);
|
||||
if (product == null || product.Deleted || !product.Published) continue;
|
||||
|
||||
items.Add(new PreorderItem
|
||||
{
|
||||
ProductId = pi.id,
|
||||
RequestedQuantity = pi.quantity,
|
||||
UnitPriceInclTax = (decimal)pi.price
|
||||
});
|
||||
}
|
||||
|
||||
if (!items.Any())
|
||||
return Json(new { success = false, error = "Nincs érvényes termék az előrendelésben" });
|
||||
|
||||
var saved = await _preorderDbContext.InsertPreorderAsync(preorder, items);
|
||||
|
||||
Console.WriteLine($"[Admin] Created preorder #{saved.Id} for customer #{customerId} " +
|
||||
$"by admin, {items.Count} items, delivery {deliveryDate:u}");
|
||||
|
||||
// Immediately check if any items can be fulfilled from current stock —
|
||||
// same inline conversion as the customer-facing PlacePreorder endpoint.
|
||||
var productIds = items.Select(i => i.ProductId).Distinct().ToList();
|
||||
await _preorderConversionService.ConvertPreordersForProductsAsync(productIds, 0);
|
||||
|
||||
// Re-read to pick up OrderId if conversion created a real order
|
||||
var refreshed = await _preorderDbContext.Preorders.GetByIdAsync(saved.Id);
|
||||
|
||||
return Json(new { success = true, preorderId = saved.Id, orderId = refreshed?.OrderId });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Json(new { success = false, error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private class ProductItemRequest
|
||||
{
|
||||
public int id { get; set; }
|
||||
public string? name { get; set; }
|
||||
public int quantity { get; set; }
|
||||
public double price { get; set; }
|
||||
}
|
||||
|
||||
// ── CANCEL ────────────────────────────────────────────────────────────────
|
||||
|
||||
// ── CANCEL ───────────────────────────────────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
[Route("Admin/Preorders/Cancel/{id:int}")]
|
||||
public async Task<IActionResult> Cancel(int id)
|
||||
{
|
||||
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||
return Json(new { success = false, error = "Access denied" });
|
||||
|
||||
var preorder = await _preorderDbContext.Preorders.GetByIdAsync(id);
|
||||
if (preorder == null)
|
||||
return Json(new { success = false, error = "Preorder not found" });
|
||||
|
||||
if (preorder.Status != PreorderStatus.Pending)
|
||||
return Json(new { success = false, error = "Only pending preorders can be cancelled" });
|
||||
|
||||
await _preorderDbContext.CancelPreorderAsync(id);
|
||||
return Json(new { success = true });
|
||||
}
|
||||
|
||||
// ── DEMAND LIST ───────────────────────────────────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
[Route("Admin/Preorders/DemandList")]
|
||||
public async Task<IActionResult> DemandList(bool openOnly = true)
|
||||
{
|
||||
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||
return Forbid();
|
||||
|
||||
_ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1);
|
||||
_ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0);
|
||||
_ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 25 : Math.Min(length, 500);
|
||||
var openOnlyParam = Request.Form["openOnly"].FirstOrDefault();
|
||||
openOnly = openOnlyParam != "false";
|
||||
|
||||
// Fetch all preorder items + preorders in two queries
|
||||
var allItems = await _preorderDbContext.PreorderItems.GetAll().ToListAsync();
|
||||
var allPreorders = await _preorderDbContext.Preorders.GetAll(false).ToListAsync();
|
||||
|
||||
// For "open only": include only items from preorders that still have
|
||||
// unfulfilled demand (FulfilledQuantity < RequestedQuantity).
|
||||
// We use quantities rather than Status enum (enum reads unreliable).
|
||||
IEnumerable<PreorderItem> items = allItems;
|
||||
if (openOnly)
|
||||
{
|
||||
// Open preorders: those where at least one item still needs fulfillment
|
||||
var openPreorderIds = allPreorders
|
||||
.Where(p => allItems
|
||||
.Where(i => i.PreorderId == p.Id)
|
||||
.Any(i => i.FulfilledQuantity < i.RequestedQuantity))
|
||||
.Select(p => p.Id)
|
||||
.ToHashSet();
|
||||
|
||||
items = allItems.Where(i => openPreorderIds.Contains(i.PreorderId));
|
||||
}
|
||||
|
||||
// Group by product
|
||||
var grouped = items
|
||||
.GroupBy(i => i.ProductId)
|
||||
.Select(g => new
|
||||
{
|
||||
ProductId = g.Key,
|
||||
TotalRequested = g.Sum(i => i.RequestedQuantity),
|
||||
TotalFulfilled = g.Sum(i => i.FulfilledQuantity),
|
||||
TotalUnfulfilled = g.Sum(i => i.RequestedQuantity - i.FulfilledQuantity),
|
||||
PreorderCount = g.Select(i => i.PreorderId).Distinct().Count(),
|
||||
AvgUnitPrice = g.Where(i => i.UnitPriceInclTax > 0).Any()
|
||||
? g.Where(i => i.UnitPriceInclTax > 0).Average(i => i.UnitPriceInclTax)
|
||||
: 0m
|
||||
})
|
||||
.OrderByDescending(g => g.TotalUnfulfilled)
|
||||
.ThenByDescending(g => g.TotalRequested)
|
||||
.ToList();
|
||||
|
||||
// Resolve product names in one batch
|
||||
var productIds = grouped.Select(g => g.ProductId).Distinct().ToList();
|
||||
var productDtos = await _dbContext.ProductDtos
|
||||
.GetAll(false)
|
||||
.Where(p => productIds.Contains(p.Id))
|
||||
.ToListAsync();
|
||||
var productById = productDtos.ToDictionary(p => p.Id);
|
||||
|
||||
var rows = grouped.Select(g =>
|
||||
{
|
||||
productById.TryGetValue(g.ProductId, out var dto);
|
||||
return new PreorderDemandRow
|
||||
{
|
||||
ProductId = g.ProductId,
|
||||
ProductName = dto?.Name ?? $"Product #{g.ProductId}",
|
||||
Sku = dto?.Id.ToString(),
|
||||
IsMeasurable = dto?.IsMeasurable ?? false,
|
||||
TotalRequested = g.TotalRequested,
|
||||
TotalFulfilled = g.TotalFulfilled,
|
||||
TotalUnfulfilled = g.TotalUnfulfilled,
|
||||
PreorderCount = g.PreorderCount,
|
||||
AvgUnitPrice = Math.Round(g.AvgUnitPrice, 0)
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
int recordsTotal = rows.Count;
|
||||
int recordsFiltered = rows.Count;
|
||||
var page = rows.Skip(start).Take(length).ToList();
|
||||
|
||||
return Json(new { draw, recordsTotal, recordsFiltered, data = page });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,258 @@
|
|||
using FruitBank.Common.Server;
|
||||
using LinqToDB;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nop.Core;
|
||||
using Nop.Core.Domain.Catalog;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||
using Nop.Services.Security;
|
||||
using Nop.Web.Framework;
|
||||
using Nop.Web.Framework.Controllers;
|
||||
using Nop.Web.Framework.Mvc.Filters;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers;
|
||||
|
||||
[AuthorizeAdmin]
|
||||
[Area(AreaNames.ADMIN)]
|
||||
[AutoValidateAntiforgeryToken]
|
||||
public class PreorderAvailabilityController : BasePluginController
|
||||
{
|
||||
private readonly IPermissionService _permissionService;
|
||||
private readonly FruitBankDbContext _dbContext;
|
||||
private readonly FruitBankAttributeService _fruitBankAttributeService;
|
||||
private readonly IStoreContext _storeContext;
|
||||
|
||||
public PreorderAvailabilityController(
|
||||
IPermissionService permissionService,
|
||||
FruitBankDbContext dbContext,
|
||||
FruitBankAttributeService fruitBankAttributeService,
|
||||
IStoreContext storeContext)
|
||||
{
|
||||
_permissionService = permissionService;
|
||||
_dbContext = dbContext;
|
||||
_fruitBankAttributeService = fruitBankAttributeService;
|
||||
_storeContext = storeContext;
|
||||
}
|
||||
|
||||
// ── INDEX ─────────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet]
|
||||
[Route("Admin/PreorderAvailability")]
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||
return AccessDeniedView();
|
||||
|
||||
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/PreorderAvailability/Index.cshtml");
|
||||
}
|
||||
|
||||
// ── ALL PRODUCTS — DataTables server-side ─────────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
[Route("Admin/PreorderAvailability/ProductList")]
|
||||
public async Task<IActionResult> ProductList()
|
||||
{
|
||||
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||
return Forbid();
|
||||
|
||||
_ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1);
|
||||
_ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0);
|
||||
_ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 25 : Math.Min(length, 500);
|
||||
|
||||
var globalSearch = Request.Form["search[value]"].FirstOrDefault()?.Trim() ?? "";
|
||||
|
||||
var storeId = (await _storeContext.GetCurrentStoreAsync()).Id;
|
||||
|
||||
// 1. All published products
|
||||
var products = await _dbContext.Products.Table
|
||||
.Where(p => !p.Deleted && p.Published)
|
||||
.OrderBy(p => p.Name)
|
||||
.Select(p => new { p.Id, p.Name, p.Sku })
|
||||
.ToListAsync();
|
||||
|
||||
// 2. All preorder window generic attributes — two queries, no N+1
|
||||
var gaStart = await _dbContext.GenericAttributes.Table
|
||||
.Where(ga => ga.KeyGroup == nameof(Product)
|
||||
&& ga.Key == FruitBankConst.PreorderWindowStart
|
||||
&& ga.StoreId == storeId)
|
||||
.ToListAsync();
|
||||
|
||||
var gaEnd = await _dbContext.GenericAttributes.Table
|
||||
.Where(ga => ga.KeyGroup == nameof(Product)
|
||||
&& ga.Key == FruitBankConst.PreorderWindowEnd
|
||||
&& ga.StoreId == storeId)
|
||||
.ToListAsync();
|
||||
|
||||
var startByProduct = gaStart.ToDictionary(g => g.EntityId, g => g.Value);
|
||||
var endByProduct = gaEnd.ToDictionary(g => g.EntityId, g => g.Value);
|
||||
|
||||
var today = DateTime.UtcNow.Date;
|
||||
|
||||
// 3. Build rows
|
||||
var rows = products.Select(p =>
|
||||
{
|
||||
DateTime.TryParse(startByProduct.GetValueOrDefault(p.Id), out var ws);
|
||||
DateTime.TryParse(endByProduct.GetValueOrDefault(p.Id), out var we);
|
||||
|
||||
var hasStart = startByProduct.ContainsKey(p.Id);
|
||||
var hasEnd = endByProduct.ContainsKey(p.Id);
|
||||
|
||||
return new PreorderAvailabilityRow
|
||||
{
|
||||
ProductId = p.Id,
|
||||
ProductName = p.Name,
|
||||
Sku = p.Sku,
|
||||
WindowStart = hasStart ? ws.ToString("yyyy-MM-dd") : null,
|
||||
WindowEnd = hasEnd ? we.ToString("yyyy-MM-dd") : null,
|
||||
IsAvailableToday = hasStart && hasEnd && ws.Date <= today && today <= we.Date
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
int recordsTotal = rows.Count;
|
||||
|
||||
// 4. Global search
|
||||
if (!string.IsNullOrWhiteSpace(globalSearch))
|
||||
{
|
||||
rows = rows.Where(r =>
|
||||
r.ProductName.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) ||
|
||||
(r.Sku?.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) ?? false)
|
||||
).ToList();
|
||||
}
|
||||
|
||||
int recordsFiltered = rows.Count;
|
||||
|
||||
// 5. Paginate
|
||||
var page = rows.Skip(start).Take(length).ToList();
|
||||
|
||||
return Json(new { draw, recordsTotal, recordsFiltered, data = page });
|
||||
}
|
||||
|
||||
// ── AVAILABLE TODAY — DataTables server-side ──────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
[Route("Admin/PreorderAvailability/AvailableTodayList")]
|
||||
public async Task<IActionResult> AvailableTodayList()
|
||||
{
|
||||
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||
return Forbid();
|
||||
|
||||
_ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1);
|
||||
_ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0);
|
||||
_ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 25 : Math.Min(length, 500);
|
||||
|
||||
var storeId = (await _storeContext.GetCurrentStoreAsync()).Id;
|
||||
var today = DateTime.UtcNow.Date;
|
||||
|
||||
// Reuse same build logic — filter to available today only
|
||||
var products = await _dbContext.Products.Table
|
||||
.Where(p => !p.Deleted && p.Published)
|
||||
.OrderBy(p => p.Name)
|
||||
.Select(p => new { p.Id, p.Name, p.Sku })
|
||||
.ToListAsync();
|
||||
|
||||
var gaStart = await _dbContext.GenericAttributes.Table
|
||||
.Where(ga => ga.KeyGroup == nameof(Product)
|
||||
&& ga.Key == FruitBankConst.PreorderWindowStart
|
||||
&& ga.StoreId == storeId)
|
||||
.ToListAsync();
|
||||
|
||||
var gaEnd = await _dbContext.GenericAttributes.Table
|
||||
.Where(ga => ga.KeyGroup == nameof(Product)
|
||||
&& ga.Key == FruitBankConst.PreorderWindowEnd
|
||||
&& ga.StoreId == storeId)
|
||||
.ToListAsync();
|
||||
|
||||
var startByProduct = gaStart.ToDictionary(g => g.EntityId, g => g.Value);
|
||||
var endByProduct = gaEnd.ToDictionary(g => g.EntityId, g => g.Value);
|
||||
|
||||
var rows = products
|
||||
.Where(p =>
|
||||
{
|
||||
if (!startByProduct.TryGetValue(p.Id, out var sRaw)) return false;
|
||||
if (!endByProduct.TryGetValue(p.Id, out var eRaw)) return false;
|
||||
if (!DateTime.TryParse(sRaw, out var ws)) return false;
|
||||
if (!DateTime.TryParse(eRaw, out var we)) return false;
|
||||
return ws.Date <= today && today <= we.Date;
|
||||
})
|
||||
.Select(p =>
|
||||
{
|
||||
DateTime.TryParse(startByProduct[p.Id], out var ws);
|
||||
DateTime.TryParse(endByProduct[p.Id], out var we);
|
||||
return new PreorderAvailabilityRow
|
||||
{
|
||||
ProductId = p.Id,
|
||||
ProductName = p.Name,
|
||||
Sku = p.Sku,
|
||||
WindowStart = ws.ToString("yyyy-MM-dd"),
|
||||
WindowEnd = we.ToString("yyyy-MM-dd"),
|
||||
IsAvailableToday = true
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
int recordsTotal = rows.Count;
|
||||
int recordsFiltered = rows.Count;
|
||||
|
||||
var page = rows.Skip(start).Take(length).ToList();
|
||||
return Json(new { draw, recordsTotal, recordsFiltered, data = page });
|
||||
}
|
||||
|
||||
// ── SAVE WINDOW DATES for a product ───────────────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
[Route("Admin/PreorderAvailability/SaveWindow")]
|
||||
public async Task<IActionResult> SaveWindow(int productId, string? windowStart, string? windowEnd)
|
||||
{
|
||||
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
|
||||
return Json(new { success = false, error = "Access denied" });
|
||||
|
||||
try
|
||||
{
|
||||
var storeId = (await _storeContext.GetCurrentStoreAsync()).Id;
|
||||
|
||||
// WindowStart
|
||||
if (string.IsNullOrWhiteSpace(windowStart))
|
||||
{
|
||||
await _fruitBankAttributeService
|
||||
.DeleteGenericAttributeAsync<Product>(productId, FruitBankConst.PreorderWindowStart, storeId);
|
||||
}
|
||||
else if (DateTime.TryParse(windowStart, out var ws))
|
||||
{
|
||||
await _fruitBankAttributeService
|
||||
.InsertOrUpdateGenericAttributeAsync<Product, DateTime>(
|
||||
productId, FruitBankConst.PreorderWindowStart, ws.Date, storeId);
|
||||
}
|
||||
else return Json(new { success = false, error = $"Invalid start date: {windowStart}" });
|
||||
|
||||
// WindowEnd
|
||||
if (string.IsNullOrWhiteSpace(windowEnd))
|
||||
{
|
||||
await _fruitBankAttributeService
|
||||
.DeleteGenericAttributeAsync<Product>(productId, FruitBankConst.PreorderWindowEnd, storeId);
|
||||
}
|
||||
else if (DateTime.TryParse(windowEnd, out var we))
|
||||
{
|
||||
await _fruitBankAttributeService
|
||||
.InsertOrUpdateGenericAttributeAsync<Product, DateTime>(
|
||||
productId, FruitBankConst.PreorderWindowEnd, we.Date, storeId);
|
||||
}
|
||||
else return Json(new { success = false, error = $"Invalid end date: {windowEnd}" });
|
||||
|
||||
// Return the new availability state
|
||||
var today = DateTime.UtcNow.Date;
|
||||
DateTime.TryParse(windowStart, out var startParsed);
|
||||
DateTime.TryParse(windowEnd, out var endParsed);
|
||||
bool isAvailableToday = !string.IsNullOrWhiteSpace(windowStart)
|
||||
&& !string.IsNullOrWhiteSpace(windowEnd)
|
||||
&& startParsed.Date <= today
|
||||
&& today <= endParsed.Date;
|
||||
|
||||
return Json(new { success = true, isAvailableToday });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Json(new { success = false, error = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,25 +9,25 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models
|
|||
{
|
||||
public record ConfigureModel
|
||||
{
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ApiKey")]
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.CerebrasApiKey")]
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ModelName")]
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.CerebrasModelName")]
|
||||
public string ModelName { get; set; } = string.Empty;
|
||||
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ApiKey")]
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.OpenAIApiKey")]
|
||||
public string OpenAIApiKey { get; set; } = string.Empty;
|
||||
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ModelName")]
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.OpenAIModelName")]
|
||||
public string OpenAIModelName { get; set; } = string.Empty;
|
||||
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.IsEnabled")]
|
||||
public bool IsEnabled { get; set; }
|
||||
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ApiBaseUrl")]
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.CerebrasApiBaseUrl")]
|
||||
public string ApiBaseUrl { get; set; } = string.Empty;
|
||||
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ApiBaseUrl")]
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.OpenAIApiBaseUrl")]
|
||||
public string OpenAIApiBaseUrl { get; set; } = string.Empty;
|
||||
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.MaxTokens")]
|
||||
|
|
@ -38,6 +38,14 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models
|
|||
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.RequestTimeoutSeconds")]
|
||||
public int RequestTimeoutSeconds { get; set; }
|
||||
|
||||
// ── Z.ai GLM-OCR ──────────────────────────────────────────────────────────────
|
||||
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ZaiApiKey")]
|
||||
public string ZaiApiKey { get; set; } = string.Empty;
|
||||
|
||||
[NopResourceDisplayName("Plugins.FruitBankPlugin.Fields.ZaiModel")]
|
||||
public string ZaiModel { get; set; } = "glm-ocr";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models;
|
||||
|
||||
public class CustomerCreditListRow
|
||||
{
|
||||
public int CustomerId { get; set; }
|
||||
public string CustomerEmail { get; set; } = string.Empty;
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public bool HasCreditLimit { get; set; }
|
||||
public decimal CreditLimit { get; set; }
|
||||
public decimal OutstandingBalance { get; set; }
|
||||
public decimal? RemainingCredit { get; set; }
|
||||
public string? Comment { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models;
|
||||
|
||||
public class CustomerCreditModel
|
||||
{
|
||||
public int CustomerId { get; set; }
|
||||
public string CustomerEmail { get; set; } = string.Empty;
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
|
||||
// Credit record
|
||||
public int CreditId { get; set; }
|
||||
public decimal CreditLimit { get; set; }
|
||||
public string? Comment { get; set; }
|
||||
public bool HasCreditLimit { get; set; }
|
||||
|
||||
// Calculated
|
||||
public decimal OutstandingBalance { get; set; }
|
||||
public decimal? RemainingCredit { get; set; }
|
||||
|
||||
// Unpaid orders table
|
||||
public List<CustomerCreditOrderRow> UnpaidOrders { get; set; } = new();
|
||||
}
|
||||
|
||||
public class CustomerCreditOrderRow
|
||||
{
|
||||
public int OrderId { get; set; }
|
||||
public decimal OrderTotal { get; set; }
|
||||
public DateTime CreatedOnUtc { get; set; }
|
||||
public string OrderStatus { get; set; } = string.Empty;
|
||||
public string PaymentStatus { get; set; } = string.Empty;
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
using FruitBank.Common.Enums;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models;
|
||||
|
||||
public class PreorderListRow
|
||||
{
|
||||
public int PreorderId { get; set; }
|
||||
public int CustomerId { get; set; }
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public string CustomerEmail { get; set; } = string.Empty;
|
||||
public string DateOfReceipt { get; set; } = string.Empty; // formatted
|
||||
public string CreatedOnUtc { get; set; } = string.Empty; // formatted
|
||||
public PreorderStatus Status { get; set; }
|
||||
public string StatusLabel { get; set; } = string.Empty;
|
||||
public int ItemCount { get; set; }
|
||||
public int FulfilledCount { get; set; }
|
||||
public int? OrderId { get; set; } // linked real order, if created
|
||||
}
|
||||
|
||||
public class PreorderDetailModel
|
||||
{
|
||||
public int PreorderId { get; set; }
|
||||
public int CustomerId { get; set; }
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public string CustomerEmail { get; set; } = string.Empty;
|
||||
public string DateOfReceipt { get; set; } = string.Empty;
|
||||
public string CreatedOnUtc { get; set; } = string.Empty;
|
||||
public string UpdatedOnUtc { get; set; } = string.Empty;
|
||||
public PreorderStatus Status { get; set; }
|
||||
public string? CustomerNote { get; set; }
|
||||
public int? OrderId { get; set; }
|
||||
public List<PreorderDetailItemRow> Items { get; set; } = new();
|
||||
}
|
||||
|
||||
public class PreorderDetailItemRow
|
||||
{
|
||||
public int ItemId { get; set; }
|
||||
public int ProductId { get; set; }
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
public bool IsMeasurable { get; set; }
|
||||
public int RequestedQuantity { get; set; }
|
||||
public int FulfilledQuantity { get; set; }
|
||||
public decimal UnitPriceInclTax { get; set; }
|
||||
public PreorderItemStatus Status { get; set; }
|
||||
public string StatusLabel { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class PreorderDemandRow
|
||||
{
|
||||
public int ProductId { get; set; }
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
public string? Sku { get; set; }
|
||||
public bool IsMeasurable { get; set; }
|
||||
public int TotalRequested { get; set; } // sum of RequestedQuantity
|
||||
public int TotalFulfilled { get; set; } // sum of FulfilledQuantity
|
||||
public int TotalUnfulfilled { get; set; } // TotalRequested - TotalFulfilled
|
||||
public int PreorderCount { get; set; } // distinct preorders containing this product
|
||||
public decimal AvgUnitPrice { get; set; } // average snapshot price
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models;
|
||||
|
||||
public class PreorderAvailabilityRow
|
||||
{
|
||||
public int ProductId { get; set; }
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
public string? Sku { get; set; }
|
||||
public string? WindowStart { get; set; } // ISO date string "yyyy-MM-dd" or null
|
||||
public string? WindowEnd { get; set; } // ISO date string "yyyy-MM-dd" or null
|
||||
public bool IsAvailableToday { get; set; }
|
||||
}
|
||||
|
|
@ -23,40 +23,42 @@
|
|||
<label asp-for="ApiKey"></label>
|
||||
<input asp-for="ApiKey" class="form-control" type="password" placeholder="Adja meg az AI API kulcsot" />
|
||||
<span asp-validation-for="ApiKey" class="text-danger"></span>
|
||||
<small class="form-text text-muted">A Cerebras API kulcs</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label asp-for="ModelName"></label>
|
||||
<input asp-for="ModelName" class="form-control" placeholder="pl. gpt-3.5-turbo, gpt-4" />
|
||||
<span asp-validation-for="ModelName" class="text-danger"></span>
|
||||
<small class="form-text text-muted">Az AI modell neve (pl. gpt-3.5-turbo, gpt-4, claude-3-sonnet)</small>
|
||||
<small class="form-text text-muted">A Cerebras AI modell neve (pl. gpt-3.5-turbo, gpt-4, claude-3-sonnet)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label asp-for="OpenAIApiKey"></label>
|
||||
<input asp-for="OpenAIApiKey" class="form-control" type="password" placeholder="Adja meg az OpenAI API kulcsot" />
|
||||
<span asp-validation-for="OpenAIApiKey" class="text-danger"></span>
|
||||
<small class="form-text text-muted">Az OpenAI API kulcs</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label asp-for="OpenAIModelName"></label>
|
||||
<input asp-for="OpenAIModelName" class="form-control" placeholder="pl. gpt-3.5-turbo, gpt-4" />
|
||||
<span asp-validation-for="OpenAIModelName" class="text-danger"></span>
|
||||
<small class="form-text text-muted">Az AI modell neve (pl. gpt-3.5-turbo, gpt-4)</small>
|
||||
<small class="form-text text-muted">Az OpenAI AI modell neve (pl. gpt-3.5-turbo, gpt-4)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label asp-for="ApiBaseUrl"></label>
|
||||
<input asp-for="ApiBaseUrl" class="form-control" placeholder="https://api.openai.com/v1" />
|
||||
<span asp-validation-for="ApiBaseUrl" class="text-danger"></span>
|
||||
<small class="form-text text-muted">Az API alapcíme (OpenAI, Azure OpenAI, stb.)</small>
|
||||
<small class="form-text text-muted">A Cerebras API alapcíme (OpenAI, Azure OpenAI, stb.)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label asp-for="OpenAIApiBaseUrl"></label>
|
||||
<input asp-for="OpenAIApiBaseUrl" class="form-control" placeholder="https://api.openai.com/v1" />
|
||||
<span asp-validation-for="OpenAIApiBaseUrl" class="text-danger"></span>
|
||||
<small class="form-text text-muted">Az API alapcíme (OpenAI, Azure OpenAI, stb.)</small>
|
||||
<small class="form-text text-muted">Az OpenAI API alapcíme (OpenAI, Azure OpenAI, stb.)</small>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
|
|
@ -88,6 +90,28 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4" />
|
||||
<h5 class="mb-3"><i class="fas fa-file-alt me-2"></i>Z.ai GLM-OCR — Dokumentumfeldolgozás</h5>
|
||||
<p class="text-muted small mb-3">
|
||||
A GLM-OCR multimodális modell szállítólevelek és rendelési dokumentumok (kép, PDF) strukturált szövegkinyerésére.
|
||||
Táblázatokat HTML formátumban ad vissza, amit közvetlenül LLM promptba lehet illeszteni.
|
||||
API kulcs igénylése: <a href="https://bigmodel.cn" target="_blank">bigmodel.cn</a> — ingyenes tier elérhető.
|
||||
</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label asp-for="ZaiApiKey"></label>
|
||||
<input asp-for="ZaiApiKey" class="form-control" type="password" placeholder="Adja meg a Z.ai API kulcsot" />
|
||||
<span asp-validation-for="ZaiApiKey" class="text-danger"></span>
|
||||
<small class="form-text text-muted">Z.ai API kulcs (bigmodel.cn). Üres hagyva a GLM-OCR funkció nem érhető el.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label asp-for="ZaiModel"></label>
|
||||
<input asp-for="ZaiModel" class="form-control" placeholder="glm-ocr" />
|
||||
<span asp-validation-for="ZaiModel" class="text-danger"></span>
|
||||
<small class="form-text text-muted">GLM-OCR modell neve. Alapesetben: <code>glm-ocr</code></small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> Mentés
|
||||
|
|
|
|||
|
|
@ -0,0 +1,151 @@
|
|||
@model Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models.CustomerCreditModel
|
||||
@using Nop.Web.Framework.UI
|
||||
|
||||
@{
|
||||
// Layout = "_FruitBankAdminLayout";
|
||||
var remaining = Model.RemainingCredit;
|
||||
var statusClass = !Model.HasCreditLimit ? "status-unlimited"
|
||||
: remaining <= 0 ? "status-blocked"
|
||||
: remaining < Model.CreditLimit * 0.2m ? "status-warning"
|
||||
: "status-ok";
|
||||
}
|
||||
|
||||
<style>
|
||||
.credit-summary { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
|
||||
.credit-card { flex: 1; min-width: 160px; background: #fff; border: 1px solid #e0e0e0; border-radius: 8px; padding: 1rem 1.25rem; }
|
||||
.credit-card .label { font-size: 0.78rem; color: #666; margin-bottom: .3rem; }
|
||||
.credit-card .value { font-size: 1.4rem; font-weight: 700; }
|
||||
.status-ok .value { color: #2d7a3a; }
|
||||
.status-warning .value { color: #f4a236; }
|
||||
.status-blocked .value { color: #c0392b; }
|
||||
.status-unlimited .value { color: #555; }
|
||||
.back-link { margin-bottom: 1rem; display: inline-block; }
|
||||
</style>
|
||||
|
||||
<a class="back-link" href="/Admin/Customer/Edit/@Model.CustomerId">
|
||||
<i class="fa fa-arrow-left"></i> @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.BackToCustomer")
|
||||
</a>
|
||||
|
||||
<div class="content-header clearfix">
|
||||
<h1 class="pull-left">
|
||||
@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle") — @Model.CustomerName (@Model.CustomerEmail)
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@* ── Summary cards ── *@
|
||||
<div class="credit-summary">
|
||||
<div class="credit-card">
|
||||
<div class="label">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit")</div>
|
||||
<div class="value">@(Model.HasCreditLimit ? Model.CreditLimit.ToString("N0") + " Ft" : "—")</div>
|
||||
</div>
|
||||
<div class="credit-card">
|
||||
<div class="label">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance")</div>
|
||||
<div class="value">@Model.OutstandingBalance.ToString("N0") Ft</div>
|
||||
</div>
|
||||
<div class="credit-card @statusClass">
|
||||
<div class="label">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit")</div>
|
||||
<div class="value">
|
||||
@if (!Model.HasCreditLimit)
|
||||
{
|
||||
@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited")
|
||||
}
|
||||
else
|
||||
{
|
||||
@remaining!.Value.ToString("N0")
|
||||
<span style="font-size:.9rem">Ft</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Edit form ── *@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.EditTitle")</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form asp-action="Save" asp-controller="CustomerCredit" asp-area="Admin" method="post">
|
||||
<input type="hidden" name="CustomerId" value="@Model.CustomerId" />
|
||||
<input type="hidden" name="CreditId" value="@Model.CreditId" />
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3 col-form-label">
|
||||
<label>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit")</label>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<input type="number" name="CreditLimit" value="@Model.CreditLimit" min="0" step="1000" class="form-control" style="max-width:240px" />
|
||||
<small class="form-text text-muted">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimitHint")</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3 col-form-label">
|
||||
<label>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment")</label>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<textarea name="Comment" class="form-control" rows="3" style="max-width:480px">@Model.Comment</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-md-9 offset-md-3">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fa fa-save"></i> @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Save")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Unpaid orders table ── *@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.UnpaidOrdersTitle") (@Model.UnpaidOrders.Count)</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
@if (!Model.UnpaidOrders.Any())
|
||||
{
|
||||
<p class="text-muted">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.NoUnpaidOrders")</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-bordered table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderId")</th>
|
||||
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderDate")</th>
|
||||
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderTotal")</th>
|
||||
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderStatus")</th>
|
||||
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.PaymentStatus")</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var o in Model.UnpaidOrders)
|
||||
{
|
||||
<tr>
|
||||
<td>#@o.OrderId</td>
|
||||
<td>@o.CreatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd HH:mm")</td>
|
||||
<td><strong>@o.OrderTotal.ToString("N0") Ft</strong></td>
|
||||
<td>@o.OrderStatus</td>
|
||||
<td>@o.PaymentStatus</td>
|
||||
<td>
|
||||
<a href="/Admin/Order/Edit/@o.OrderId" class="btn btn-xs btn-default" target="_blank">
|
||||
<i class="fa fa-external-link"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="2"><strong>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Total")</strong></td>
|
||||
<td><strong>@Model.OutstandingBalance.ToString("N0") Ft</strong></td>
|
||||
<td colspan="3"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
@{
|
||||
ViewBag.PageTitle = "Hitelkeretek";
|
||||
NopHtml.SetActiveMenuItemSystemName("CustomerCredit.List");
|
||||
}
|
||||
|
||||
<div class="content-header clearfix">
|
||||
<h1 class="float-left">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle")</h1>
|
||||
</div>
|
||||
|
||||
<section class="content">
|
||||
<div class="container-fluid">
|
||||
<div class="card card-default">
|
||||
<div class="card-body p-0">
|
||||
@Html.AntiForgeryToken()
|
||||
<table id="cc-grid" class="table table-bordered table-hover m-0" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CustomerName")</th>
|
||||
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CustomerEmail")</th>
|
||||
<th title="Kattintásra szerkeszthető — törléshez hagyd üresen">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit") <small class="text-muted">✏️</small></th>
|
||||
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance")</th>
|
||||
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit")</th>
|
||||
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment")</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* ── Editable credit limit cell ─────────────────────────────── */
|
||||
#cc-grid tbody td.cc-editable {
|
||||
cursor: pointer;
|
||||
}
|
||||
#cc-grid tbody td.cc-editable:hover {
|
||||
background-color: #fff8e1;
|
||||
}
|
||||
#cc-grid tbody td.cc-editable input[type="number"] {
|
||||
width: 130px;
|
||||
font-size: 13px;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid #80bdff;
|
||||
border-radius: 3px;
|
||||
}
|
||||
/* ── Status colours ─────────────────────────────────────────── */
|
||||
.cc-remaining-ok { color: #2d7a3a; font-weight: 600; }
|
||||
.cc-remaining-warning { color: #e67e22; font-weight: 600; }
|
||||
.cc-remaining-blocked { color: #c0392b; font-weight: 600; }
|
||||
.cc-remaining-none { color: #888; }
|
||||
/* ── Stripe + hover ─────────────────────────────────────────── */
|
||||
#cc-grid tbody tr:nth-child(even) { background-color: #f9f9f9; }
|
||||
#cc-grid tbody tr:hover { background-color: #eaf2ff; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
$(function () {
|
||||
|
||||
var _token = $('input[name="__RequestVerificationToken"]').val();
|
||||
|
||||
function fmt(val) {
|
||||
if (val == null) return '—';
|
||||
return Number(val).toLocaleString('hu-HU') + ' Ft';
|
||||
}
|
||||
|
||||
function renderRemaining(row) {
|
||||
if (!row.HasCreditLimit) return '<span class="cc-remaining-none">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited").Text</span>';
|
||||
var r = row.RemainingCredit;
|
||||
var cls = r <= 0 ? 'cc-remaining-blocked'
|
||||
: r < row.CreditLimit * 0.2 ? 'cc-remaining-warning'
|
||||
: 'cc-remaining-ok';
|
||||
return '<span class="' + cls + '">' + fmt(r) + '</span>';
|
||||
}
|
||||
|
||||
function renderCreditLimit(row) {
|
||||
if (!row.HasCreditLimit) return '<span class="text-muted">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited").Text</span>';
|
||||
return fmt(row.CreditLimit);
|
||||
}
|
||||
|
||||
var table = $('#cc-grid').DataTable({
|
||||
serverSide : true,
|
||||
processing : true,
|
||||
orderCellsTop: true,
|
||||
pageLength : 25,
|
||||
lengthMenu : [[10, 25, 50, 100], [10, 25, 50, 100]],
|
||||
order : [[3, 'desc']],
|
||||
language : {
|
||||
processing : 'Betöltés...',
|
||||
search : 'Keresés:',
|
||||
lengthMenu : '_MENU_ sor/oldal',
|
||||
info : '_START_ – _END_ / _TOTAL_ ügyfél',
|
||||
infoEmpty : '0 ügyfél',
|
||||
infoFiltered : '(szűrve _MAX_-ból)',
|
||||
paginate : { first: '««', previous: '«', next: '»', last: '»»' },
|
||||
emptyTable : 'Nincs találat',
|
||||
zeroRecords : 'Nincs találat'
|
||||
},
|
||||
ajax: {
|
||||
url : '/Admin/CustomerCredit/CustomerCreditList',
|
||||
type: 'POST',
|
||||
data: function (d) {
|
||||
d.__RequestVerificationToken = _token;
|
||||
},
|
||||
error: function (xhr) {
|
||||
console.error('CustomerCreditList error:', xhr.status, xhr.responseText);
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
/* 0 */ { data: 'CustomerName', name: 'CustomerName' },
|
||||
/* 1 */ { data: 'CustomerEmail', name: 'CustomerEmail' },
|
||||
/* 2 */ { data: 'CreditLimit', name: 'CreditLimit', className: 'cc-editable text-right',
|
||||
render: function (d, t, row) { return renderCreditLimit(row); } },
|
||||
/* 3 */ { data: 'OutstandingBalance', name: 'OutstandingBalance', className: 'text-right',
|
||||
render: function (d) {
|
||||
var color = d > 0 ? 'color:#c0392b;font-weight:600' : '';
|
||||
return '<span style="' + color + '">' + fmt(d) + '</span>';
|
||||
}},
|
||||
/* 4 */ { data: 'RemainingCredit', name: 'RemainingCredit',
|
||||
render: function (d, t, row) { return renderRemaining(row); } },
|
||||
/* 5 */ { data: 'Comment', name: 'Comment', orderable: false,
|
||||
render: function (d) { return d ? '<span class="text-muted">' + d + '</span>' : ''; } },
|
||||
/* 6 */ { data: 'CustomerId', name: null, orderable: false, searchable: false, width: '80px', className: 'text-center',
|
||||
render: function (d) {
|
||||
return '<a href="/Admin/CustomerCredit/Details/' + d + '" class="btn btn-default btn-xs" title="Részletek"><i class="fas fa-edit"></i></a>' +
|
||||
' <a href="/Admin/Customer/Edit/' + d + '" class="btn btn-default btn-xs" title="Ügyfél szerkesztése"><i class="fas fa-user"></i></a>';
|
||||
}}
|
||||
]
|
||||
});
|
||||
|
||||
/* ── Inline editing: CreditLimit ─────────────────────────────── */
|
||||
$(document).on('click', '#cc-grid tbody td.cc-editable', function () {
|
||||
var $td = $(this);
|
||||
if ($td.find('input').length) return;
|
||||
|
||||
var $row = $td.closest('tr');
|
||||
var rowData = table.row($row).data();
|
||||
if (!rowData) return;
|
||||
|
||||
var savedHtml = $td.html();
|
||||
var current = rowData.HasCreditLimit ? rowData.CreditLimit : '';
|
||||
|
||||
var $inp = $('<input type="number" min="0" step="1000" placeholder="Korlátlan (törléshez hagyd üresen)">')
|
||||
.val(current)
|
||||
.css({ width: '180px', fontSize: '13px' });
|
||||
|
||||
$td.html('').append($inp);
|
||||
$inp.focus().select();
|
||||
|
||||
function restore() { $td.html(savedHtml); }
|
||||
|
||||
function persist() {
|
||||
var raw = $inp.val().trim();
|
||||
var removeLimit = raw === ''; // empty = remove limit → unlimited
|
||||
var newVal = removeLimit ? null : parseFloat(raw);
|
||||
|
||||
// If a number was typed but is invalid or negative, cancel
|
||||
if (!removeLimit && (isNaN(newVal) || newVal < 0)) { restore(); return; }
|
||||
|
||||
// No change: still has limit and same value
|
||||
if (!removeLimit && rowData.HasCreditLimit && newVal === rowData.CreditLimit) { restore(); return; }
|
||||
|
||||
// No change: was already unlimited and still wants unlimited
|
||||
if (removeLimit && !rowData.HasCreditLimit) { restore(); return; }
|
||||
|
||||
$.ajax({
|
||||
url : '/Admin/CustomerCredit/UpdateCreditLimit',
|
||||
type : 'POST',
|
||||
data : {
|
||||
__RequestVerificationToken : _token,
|
||||
customerId : rowData.CustomerId,
|
||||
creditLimit : removeLimit ? '' : newVal, // empty string signals "remove"
|
||||
removeLimit : removeLimit,
|
||||
comment : rowData.Comment || ''
|
||||
},
|
||||
success: function (res) {
|
||||
if (res.success) {
|
||||
rowData.CreditLimit = res.creditLimit;
|
||||
rowData.OutstandingBalance = res.outstanding;
|
||||
rowData.RemainingCredit = res.remaining;
|
||||
rowData.HasCreditLimit = res.hasLimit;
|
||||
table.row($row).data(rowData).invalidate().draw(false);
|
||||
} else {
|
||||
restore();
|
||||
alert('Mentési hiba: ' + (res.error || 'Ismeretlen hiba'));
|
||||
}
|
||||
},
|
||||
error: function () { restore(); }
|
||||
});
|
||||
}
|
||||
|
||||
$inp.on('blur', function () { persist(); });
|
||||
$inp.on('keydown', function (e) {
|
||||
if (e.key === 'Enter') { $inp.off('blur'); persist(); }
|
||||
if (e.key === 'Escape') { $inp.off('blur'); restore(); }
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"> </span></button>
|
||||
<ul class="dropdown-menu" role="menu">
|
||||
<li class="dropdown-item"><button asp-action="ExportXml" type="submit" name="exportxml-all"><i class="far fa-file-code"></i> @T("Admin.Common.ExportToXml.All")</button></li>
|
||||
<li class="dropdown-item"><button type="button" id="exportxml-selected"><i class="far fa-file-code"></i> @T("Admin.Common.ExportToXml.Selected")</button></li>
|
||||
<li class="dropdown-divider"></li>
|
||||
<li class="dropdown-item"><button asp-action="ExportExcel" type="submit" name="exportexcel-all"><i class="far fa-file-excel"></i> @T("Admin.Common.ExportToExcel.All")</button></li>
|
||||
<li class="dropdown-item"><button type="button" id="exportexcel-selected"><i class="far fa-file-excel"></i> @T("Admin.Common.ExportToExcel.Selected")</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-info"><i class="far fa-file-pdf"></i> @T("Admin.Orders.PdfInvoices")</button>
|
||||
<button type="button" class="btn btn-info dropdown-toggle dropdown-icon" data-toggle="dropdown"><span class="sr-only"> </span></button>
|
||||
<ul class="dropdown-menu" role="menu">
|
||||
<li class="dropdown-item"><button asp-action="PdfInvoice" type="submit" name="pdf-invoice-all">@T("Admin.Orders.PdfInvoices.All")</button></li>
|
||||
<li class="dropdown-item"><button type="button" id="pdf-invoice-selected">@T("Admin.Orders.PdfInvoices.Selected")</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section class="content">
|
||||
<div class="container-fluid">
|
||||
|
||||
@* ── Filter Panel ─────────────────────────────────────────────── *@
|
||||
<div class="card card-default card-search mb-2">
|
||||
<div class="card-body py-2">
|
||||
<div class="row align-items-end">
|
||||
@* Date from *@
|
||||
<div class="col-md-2">
|
||||
<div class="form-group mb-1">
|
||||
<nop-label asp-for="StartDate" />
|
||||
<nop-editor asp-for="StartDate" />
|
||||
</div>
|
||||
</div>
|
||||
@* Date to *@
|
||||
<div class="col-md-2">
|
||||
<div class="form-group mb-1">
|
||||
<nop-label asp-for="EndDate" />
|
||||
<nop-editor asp-for="EndDate" />
|
||||
</div>
|
||||
</div>
|
||||
@* Partner autocomplete → stores customer ID in hidden *@
|
||||
<div class="col-md-4">
|
||||
<div class="form-group mb-1">
|
||||
<label class="col-form-label">Partner</label>
|
||||
<div class="input-group">
|
||||
<input type="text" id="fb-company-display" autocomplete="off" class="form-control" placeholder="Cég neve..." />
|
||||
<div class="input-group-append">
|
||||
<button type="button" id="fb-company-clear" class="btn btn-outline-secondary" style="display:none">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input asp-for="BillingCompany" type="hidden" id="BillingCompany" />
|
||||
</div>
|
||||
</div>
|
||||
@* Go to order by number *@
|
||||
<div class="col-md-3">
|
||||
<div class="form-group mb-1">
|
||||
<nop-label asp-for="GoDirectlyToCustomOrderNumber" />
|
||||
<div class="input-group">
|
||||
<nop-editor asp-for="GoDirectlyToCustomOrderNumber" />
|
||||
<div class="input-group-append">
|
||||
<button type="button" id="go-to-order-by-number" class="btn btn-info">
|
||||
@T("Admin.Common.Go")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@* Search button *@
|
||||
<div class="col-md-1">
|
||||
<div class="form-group mb-1">
|
||||
<label class="col-form-label"> </label>
|
||||
<button type="button" id="fb-search-btn" class="btn btn-primary btn-block">
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Grid ─────────────────────────────────────────────────────── *@
|
||||
<div class="card card-default">
|
||||
<div class="card-body p-0">
|
||||
@* Anti-forgery token for AJAX POSTs *@
|
||||
@Html.AntiForgeryToken()
|
||||
<table id="fb-orders-grid" class="table table-bordered table-hover m-0 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>×</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>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,229 @@
|
|||
@model Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models.PreorderDetailModel
|
||||
@using FruitBank.Common.Enums
|
||||
|
||||
@{
|
||||
ViewBag.PageTitle = $"Előrendelés #{Model.PreorderId}";
|
||||
Layout = "~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/_FruitBankAdminLayout.cshtml";
|
||||
|
||||
var statusClass = Model.Status switch
|
||||
{
|
||||
PreorderStatus.Confirmed => "po-status-confirmed",
|
||||
PreorderStatus.PartiallyFulfilled => "po-status-partial",
|
||||
PreorderStatus.Cancelled => "po-status-cancelled",
|
||||
_ => "po-status-pending"
|
||||
};
|
||||
|
||||
var statusLabel = Model.Status switch
|
||||
{
|
||||
PreorderStatus.Confirmed => "Megerősítve",
|
||||
PreorderStatus.PartiallyFulfilled => "Részben teljesítve",
|
||||
PreorderStatus.Cancelled => "Törölve",
|
||||
_ => "Függőben"
|
||||
};
|
||||
}
|
||||
|
||||
@Html.AntiForgeryToken()
|
||||
|
||||
<style>
|
||||
.po-status-pending { background:#fff3cd; color:#856404; border-radius:6px; padding:4px 12px; font-weight:700; display:inline-block; }
|
||||
.po-status-confirmed { background:#d4edda; color:#155724; border-radius:6px; padding:4px 12px; font-weight:700; display:inline-block; }
|
||||
.po-status-partial { background:#fff8ee; color:#c87500; border-radius:6px; padding:4px 12px; font-weight:700; display:inline-block; }
|
||||
.po-status-cancelled { background:#f8d7da; color:#721c24; border-radius:6px; padding:4px 12px; font-weight:700; display:inline-block; }
|
||||
|
||||
.po-meta-card { background:#fff; border:1px solid #dde8da; border-radius:8px; padding:16px 20px; margin-bottom:20px; }
|
||||
.po-meta-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(200px,1fr)); gap:16px; }
|
||||
.po-meta-item .label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.5px; color:#6b7c6e; margin-bottom:4px; }
|
||||
.po-meta-item .value { font-size:15px; color:#1a3c22; font-weight:600; }
|
||||
|
||||
.item-fulfilled { background:#eaf7ee; }
|
||||
.item-partial { background:#fffbf0; }
|
||||
.item-dropped { background:#fdf0f0; color:#888; }
|
||||
.item-pending { }
|
||||
|
||||
.qty-bar-wrap { width:100px; display:inline-block; vertical-align:middle; }
|
||||
.qty-bar { height:6px; background:#dde8da; border-radius:3px; overflow:hidden; display:inline-block; width:100%; }
|
||||
.qty-bar-fill { height:100%; border-radius:3px; }
|
||||
</style>
|
||||
|
||||
<!-- Back link -->
|
||||
<a href="/Admin/Preorders" class="btn btn-default btn-sm mb-3">
|
||||
<i class="fas fa-arrow-left"></i> Vissza a listához
|
||||
</a>
|
||||
|
||||
<div class="content-header clearfix">
|
||||
<h1 class="float-left">
|
||||
<i class="fas fa-calendar-plus" style="color:#2d7a3a;"></i>
|
||||
Előrendelés <strong>#@Model.PreorderId</strong>
|
||||
<span class="@statusClass ml-2">@statusLabel</span>
|
||||
</h1>
|
||||
<div class="float-right">
|
||||
@if (Model.OrderId.HasValue)
|
||||
{
|
||||
<a href="/Admin/Order/Edit/@Model.OrderId" class="btn btn-success btn-sm" target="_blank">
|
||||
<i class="fas fa-external-link-alt"></i> Rendelés #@Model.OrderId
|
||||
</a>
|
||||
}
|
||||
@if (Model.Status == PreorderStatus.Pending)
|
||||
{
|
||||
<button id="cancelBtn" class="btn btn-danger btn-sm ml-2">
|
||||
<i class="fas fa-times"></i> Visszavonás
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="content">
|
||||
<div class="container-fluid">
|
||||
|
||||
<!-- ── Meta cards ──────────────────────────────────────────────────── -->
|
||||
<div class="po-meta-card">
|
||||
<div class="po-meta-grid">
|
||||
<div class="po-meta-item">
|
||||
<div class="label">Ügyfél</div>
|
||||
<div class="value">
|
||||
<a href="/Admin/Customer/Edit/@Model.CustomerId">@Model.CustomerName</a>
|
||||
</div>
|
||||
<small class="text-muted">@Model.CustomerEmail</small>
|
||||
</div>
|
||||
<div class="po-meta-item">
|
||||
<div class="label">Kért szállítási időpont</div>
|
||||
<div class="value"><i class="fas fa-calendar-day text-muted mr-1"></i>@Model.DateOfReceipt</div>
|
||||
</div>
|
||||
<div class="po-meta-item">
|
||||
<div class="label">Leadva</div>
|
||||
<div class="value">@Model.CreatedOnUtc</div>
|
||||
</div>
|
||||
<div class="po-meta-item">
|
||||
<div class="label">Utoljára frissítve</div>
|
||||
<div class="value">@Model.UpdatedOnUtc</div>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(Model.CustomerNote))
|
||||
{
|
||||
<div class="po-meta-item" style="grid-column:1/-1;">
|
||||
<div class="label">Ügyfél megjegyzése</div>
|
||||
<div class="value" style="font-weight:400;font-size:14px;color:#444;">@Model.CustomerNote</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Items table ─────────────────────────────────────────────────── -->
|
||||
<div class="card card-default">
|
||||
<div class="card-header">
|
||||
<strong>Tételek (@Model.Items.Count)</strong>
|
||||
@{
|
||||
var fulfilled = Model.Items.Count(i => i.Status == PreorderItemStatus.Fulfilled);
|
||||
var partial = Model.Items.Count(i => i.Status == PreorderItemStatus.PartiallyFulfilled);
|
||||
var dropped = Model.Items.Count(i => i.Status == PreorderItemStatus.Dropped);
|
||||
var pending = Model.Items.Count(i => i.Status == PreorderItemStatus.Pending);
|
||||
}
|
||||
<span class="ml-2 text-muted" style="font-size:13px;">
|
||||
@if (fulfilled > 0) { <span class="badge badge-success">@fulfilled teljesítve</span> }
|
||||
@if (partial > 0) { <span class="badge badge-warning ml-1">@partial részben</span> }
|
||||
@if (dropped > 0) { <span class="badge badge-danger ml-1">@dropped ejtve</span> }
|
||||
@if (pending > 0) { <span class="badge badge-secondary ml-1">@pending függőben</span> }
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-bordered table-hover table-sm m-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Termék</th>
|
||||
<th width="80" class="text-center">Kérve</th>
|
||||
<th width="80" class="text-center">Teljesítve</th>
|
||||
<th width="160">Teljesítés</th>
|
||||
<th width="130" class="text-right">Egységár</th>
|
||||
<th width="130" class="text-right">Becsült ár</th>
|
||||
<th width="110" class="text-center">Állapot</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var item in Model.Items)
|
||||
{
|
||||
var rowClass = item.Status switch
|
||||
{
|
||||
PreorderItemStatus.Fulfilled => "item-fulfilled",
|
||||
PreorderItemStatus.PartiallyFulfilled => "item-partial",
|
||||
PreorderItemStatus.Dropped => "item-dropped",
|
||||
_ => "item-pending"
|
||||
};
|
||||
var pct = item.RequestedQuantity > 0
|
||||
? (int)Math.Round((double)item.FulfilledQuantity / item.RequestedQuantity * 100)
|
||||
: 0;
|
||||
var barColor = pct == 100 ? "#2d7a3a" : pct > 0 ? "#f4a236" : "#dc3545";
|
||||
var estimatedPrice = item.IsMeasurable
|
||||
? "—"
|
||||
: (item.UnitPriceInclTax * item.FulfilledQuantity).ToString("N0") + " Ft";
|
||||
var unitPrice = item.IsMeasurable ? "súlymérés" : item.UnitPriceInclTax.ToString("N0") + " Ft";
|
||||
|
||||
<tr class="@rowClass">
|
||||
<td>
|
||||
<a href="/Admin/Product/Edit/@item.ProductId" target="_blank">@item.ProductName</a>
|
||||
@if (item.IsMeasurable)
|
||||
{
|
||||
<span class="badge badge-light ml-1" title="Súlymérést igényel">⚖️</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-center">@item.RequestedQuantity db</td>
|
||||
<td class="text-center">
|
||||
<strong>@item.FulfilledQuantity db</strong>
|
||||
</td>
|
||||
<td>
|
||||
<div class="qty-bar-wrap">
|
||||
<div class="qty-bar">
|
||||
<div class="qty-bar-fill" style="width:@pct%;background:@barColor;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<small class="ml-1">@pct%</small>
|
||||
</td>
|
||||
<td class="text-right">@unitPrice</td>
|
||||
<td class="text-right">@estimatedPrice</td>
|
||||
<td class="text-center">
|
||||
<span class="po-status-@item.Status.ToString().ToLower()" style="font-size:11px;padding:2px 6px;">
|
||||
@item.StatusLabel
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
@{
|
||||
var totalEstimated = Model.Items
|
||||
.Where(i => !i.IsMeasurable && (i.Status == PreorderItemStatus.Fulfilled || i.Status == PreorderItemStatus.PartiallyFulfilled))
|
||||
.Sum(i => i.UnitPriceInclTax * i.FulfilledQuantity);
|
||||
}
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="5" class="text-right"><strong>Becsült összeg:</strong></td>
|
||||
<td class="text-right"><strong>@totalEstimated.ToString("N0") Ft</strong></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (Model.Status == PreorderStatus.Pending)
|
||||
{
|
||||
<script>
|
||||
$(function () {
|
||||
$('#cancelBtn').click(function () {
|
||||
if (!confirm('Biztosan visszavonod ezt az előrendelést? Ez a művelet nem visszafordítható.')) return;
|
||||
$.ajax({
|
||||
url : '/Admin/Preorders/Cancel/@Model.PreorderId',
|
||||
type : 'POST',
|
||||
data : { __RequestVerificationToken: $('input[name="__RequestVerificationToken"]').val() },
|
||||
success: function (res) {
|
||||
if (res.success) {
|
||||
location.href = '/Admin/Preorders';
|
||||
} else {
|
||||
alert('Hiba: ' + (res.error || 'Ismeretlen hiba'));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
}
|
||||
|
|
@ -0,0 +1,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>×</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> | >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>
|
||||
|
|
@ -0,0 +1,282 @@
|
|||
@{
|
||||
ViewBag.PageTitle = "Előrendelés — termékelérhetőség";
|
||||
NopHtml.SetActiveMenuItemSystemName("PreorderAvailability");
|
||||
Layout = "~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/_FruitBankAdminLayout.cshtml";
|
||||
}
|
||||
|
||||
@Html.AntiForgeryToken()
|
||||
|
||||
<div class="content-header clearfix">
|
||||
<h1 class="float-left">
|
||||
<i class="fas fa-calendar-check" style="color:#2d7a3a;"></i>
|
||||
Előrendelés — termékelérhetőség
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<section class="content">
|
||||
<div class="container-fluid">
|
||||
|
||||
<!-- ── Tabs ─────────────────────────────────────────────────────── -->
|
||||
<ul class="nav nav-tabs mb-3" id="paTabs" role="tablist">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" id="tab-all-link" data-toggle="tab" href="#tab-all" role="tab">
|
||||
<i class="fas fa-list"></i> Összes termék
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" id="tab-today-link" data-toggle="tab" href="#tab-today" role="tab">
|
||||
<i class="fas fa-calendar-day"></i> Elérhető ma
|
||||
<span id="todayBadge" class="badge badge-success ml-1" style="display:none;"></span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
|
||||
<!-- ── TAB 1: All products ──────────────────────────────────── -->
|
||||
<div class="tab-pane fade show active" id="tab-all" role="tabpanel">
|
||||
<div class="card card-default">
|
||||
<div class="card-header">
|
||||
<div class="d-flex align-items-center gap-2" style="gap:10px;">
|
||||
<span class="text-muted" style="font-size:13px;">
|
||||
Kattints a dátum cellákra a szerkesztéshez.
|
||||
Törléshez hagyd üresen és nyomj Entert.
|
||||
</span>
|
||||
<button id="btnSaveAll" class="btn btn-sm btn-success ml-auto" style="display:none;">
|
||||
<i class="fas fa-save"></i> Összes módosítás mentése
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table id="pa-grid" class="table table-bordered table-hover table-sm m-0" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Termék neve</th>
|
||||
<th width="100">SKU</th>
|
||||
<th width="160" title="Kattints a szerkesztéshez">Elérhetőség kezdete ✏️</th>
|
||||
<th width="160" title="Kattints a szerkesztéshez">Elérhetőség vége ✏️</th>
|
||||
<th width="110" class="text-center">Ma elérhető?</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── TAB 2: Available today ───────────────────────────────── -->
|
||||
<div class="tab-pane fade" id="tab-today" role="tabpanel">
|
||||
<div class="card card-default">
|
||||
<div class="card-header">
|
||||
<span class="text-muted" style="font-size:13px;">
|
||||
Termékek, amelyek ma meg tudják rendelni az ügyfelek előrendelésként.
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table id="today-grid" class="table table-bordered table-hover table-sm m-0" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Termék neve</th>
|
||||
<th width="100">SKU</th>
|
||||
<th width="160">Elérhetőség kezdete</th>
|
||||
<th width="160">Elérhetőség vége</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /.tab-content -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* ── Editable date cells ──────────────────────────────────────── */
|
||||
#pa-grid tbody td.pa-date {
|
||||
cursor: pointer;
|
||||
min-width: 130px;
|
||||
}
|
||||
#pa-grid tbody td.pa-date:hover {
|
||||
background-color: #fff8e1;
|
||||
}
|
||||
#pa-grid tbody td.pa-date input[type="date"] {
|
||||
width: 135px;
|
||||
font-size: 13px;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid #80bdff;
|
||||
border-radius: 3px;
|
||||
color: #333;
|
||||
}
|
||||
.pa-available-yes { color: #2d7a3a; font-weight: 600; }
|
||||
.pa-available-no { color: #999; }
|
||||
.pa-date-set { color: #1a3c22; }
|
||||
.pa-date-empty { color: #bbb; font-style: italic; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
$(function () {
|
||||
var _token = $('input[name="__RequestVerificationToken"]').val();
|
||||
|
||||
function fmtDate(val) {
|
||||
if (!val) return '<span class="pa-date-empty">—</span>';
|
||||
// "2026-04-21" → "2026. 04. 21."
|
||||
var p = val.split('-');
|
||||
return '<span class="pa-date-set">' + p[0] + '. ' + p[1] + '. ' + p[2] + '.</span>';
|
||||
}
|
||||
|
||||
function fmtAvailable(row) {
|
||||
return row.IsAvailableToday
|
||||
? '<span class="pa-available-yes"><i class="fas fa-check-circle"></i> Igen</span>'
|
||||
: '<span class="pa-available-no">—</span>';
|
||||
}
|
||||
|
||||
// ── ALL PRODUCTS TABLE ───────────────────────────────────────────────────
|
||||
var paTable = $('#pa-grid').DataTable({
|
||||
serverSide : true,
|
||||
processing : true,
|
||||
pageLength : 50,
|
||||
lengthMenu : [[25, 50, 100, 250], [25, 50, 100, 250]],
|
||||
order : [[0, 'asc']],
|
||||
language : {
|
||||
processing : 'Betöltés...',
|
||||
search : 'Keresés:',
|
||||
lengthMenu : '_MENU_ sor/oldal',
|
||||
info : '_START_–_END_ / _TOTAL_ termék',
|
||||
infoEmpty : '0 termék',
|
||||
infoFiltered : '(szűrve _MAX_-ból)',
|
||||
paginate : { first: '««', previous: '«', next: '»', last: '»»' },
|
||||
emptyTable : 'Nincs termék',
|
||||
zeroRecords : 'Nincs találat'
|
||||
},
|
||||
ajax: {
|
||||
url : '/Admin/PreorderAvailability/ProductList',
|
||||
type: 'POST',
|
||||
data: function (d) { d.__RequestVerificationToken = _token; }
|
||||
},
|
||||
columns: [
|
||||
/* 0 */ { data: 'ProductName', name: 'ProductName' },
|
||||
/* 1 */ { data: 'Sku', name: 'Sku', orderable: false,
|
||||
render: function (d) { return d ? '<code>' + d + '</code>' : ''; } },
|
||||
/* 2 */ { data: 'WindowStart', name: 'WindowStart', className: 'pa-date', orderable: false,
|
||||
render: function (d) { return fmtDate(d); } },
|
||||
/* 3 */ { data: 'WindowEnd', name: 'WindowEnd', className: 'pa-date', orderable: false,
|
||||
render: function (d) { return fmtDate(d); } },
|
||||
/* 4 */ { data: 'IsAvailableToday', orderable: false, className: 'text-center',
|
||||
render: function (d, t, row) { return fmtAvailable(row); } }
|
||||
]
|
||||
});
|
||||
|
||||
// ── INLINE DATE EDITING ──────────────────────────────────────────────────
|
||||
$(document).on('click', '#pa-grid tbody td.pa-date', function () {
|
||||
var $td = $(this);
|
||||
if ($td.find('input').length) return; // already open
|
||||
|
||||
var $row = $td.closest('tr');
|
||||
var rowData = paTable.row($row).data();
|
||||
if (!rowData) return;
|
||||
|
||||
var colIdx = paTable.cell($td).index().column;
|
||||
var isStart = colIdx === 2;
|
||||
var current = isStart ? (rowData.WindowStart || '') : (rowData.WindowEnd || '');
|
||||
var savedHtml = $td.html();
|
||||
|
||||
var $inp = $('<input type="date">').val(current);
|
||||
$td.html('').append($inp);
|
||||
$inp.focus();
|
||||
|
||||
function restore() { $td.html(savedHtml); }
|
||||
|
||||
function persist() {
|
||||
var newVal = $inp.val().trim(); // "yyyy-MM-dd" or ""
|
||||
var oldVal = current;
|
||||
|
||||
if (newVal === oldVal) { restore(); return; }
|
||||
|
||||
// Optimistically update local row data
|
||||
if (isStart) rowData.WindowStart = newVal || null;
|
||||
else rowData.WindowEnd = newVal || null;
|
||||
|
||||
$.ajax({
|
||||
url : '/Admin/PreorderAvailability/SaveWindow',
|
||||
type : 'POST',
|
||||
data : {
|
||||
__RequestVerificationToken : _token,
|
||||
productId : rowData.ProductId,
|
||||
windowStart : rowData.WindowStart || '',
|
||||
windowEnd : rowData.WindowEnd || ''
|
||||
},
|
||||
success: function (res) {
|
||||
if (res.success) {
|
||||
rowData.IsAvailableToday = res.isAvailableToday;
|
||||
paTable.row($row).data(rowData).invalidate().draw(false);
|
||||
} else {
|
||||
alert('Mentési hiba: ' + (res.error || 'Ismeretlen hiba'));
|
||||
restore();
|
||||
}
|
||||
},
|
||||
error: function () { restore(); }
|
||||
});
|
||||
}
|
||||
|
||||
$inp.on('blur', function () { persist(); });
|
||||
$inp.on('keydown', function (e) {
|
||||
if (e.key === 'Enter') { $inp.off('blur'); persist(); }
|
||||
if (e.key === 'Escape') { $inp.off('blur'); restore(); }
|
||||
});
|
||||
});
|
||||
|
||||
// ── AVAILABLE TODAY TABLE ────────────────────────────────────────────────
|
||||
var todayTable = $('#today-grid').DataTable({
|
||||
serverSide : true,
|
||||
processing : true,
|
||||
pageLength : 50,
|
||||
order : [[0, 'asc']],
|
||||
language : {
|
||||
processing : 'Betöltés...',
|
||||
search : 'Keresés:',
|
||||
lengthMenu : '_MENU_ sor/oldal',
|
||||
info : '_START_–_END_ / _TOTAL_ termék',
|
||||
infoEmpty : 'Egy termék sincs ma elérhető előrendelésre.',
|
||||
infoFiltered : '(szűrve _MAX_-ból)',
|
||||
paginate : { first: '««', previous: '«', next: '»', last: '»»' },
|
||||
emptyTable : 'Egy termék sincs ma elérhető előrendelésre.',
|
||||
zeroRecords : 'Nincs találat'
|
||||
},
|
||||
ajax: {
|
||||
url : '/Admin/PreorderAvailability/AvailableTodayList',
|
||||
type: 'POST',
|
||||
data: function (d) { d.__RequestVerificationToken = _token; },
|
||||
dataSrc: function (json) {
|
||||
// Update the badge on the tab
|
||||
var count = json.recordsTotal;
|
||||
if (count > 0) {
|
||||
$('#todayBadge').text(count).show();
|
||||
} else {
|
||||
$('#todayBadge').hide();
|
||||
}
|
||||
return json.data;
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
/* 0 */ { data: 'ProductName', name: 'ProductName' },
|
||||
/* 1 */ { data: 'Sku', orderable: false,
|
||||
render: function (d) { return d ? '<code>' + d + '</code>' : ''; } },
|
||||
/* 2 */ { data: 'WindowStart', orderable: false,
|
||||
render: function (d) { return fmtDate(d); } },
|
||||
/* 3 */ { data: 'WindowEnd', orderable: false,
|
||||
render: function (d) { return fmtDate(d); } }
|
||||
]
|
||||
});
|
||||
|
||||
// Load today table when that tab is first clicked
|
||||
var todayLoaded = false;
|
||||
$('#tab-today-link').on('shown.bs.tab', function () {
|
||||
if (!todayLoaded) { todayTable.ajax.reload(); todayLoaded = true; }
|
||||
});
|
||||
|
||||
// Always reload today table when switching to it (data may have changed)
|
||||
$('#tab-today-link').on('show.bs.tab', function () {
|
||||
if (todayLoaded) todayTable.ajax.reload();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Models;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||
using Nop.Web.Areas.Admin.Models.Customers;
|
||||
using Nop.Web.Framework.Components;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Components;
|
||||
|
||||
[ViewComponent(Name = "CustomerCreditWidget")]
|
||||
public class CustomerCreditWidgetViewComponent : NopViewComponent
|
||||
{
|
||||
private readonly ICustomerCreditService _customerCreditService;
|
||||
|
||||
public CustomerCreditWidgetViewComponent(ICustomerCreditService customerCreditService)
|
||||
{
|
||||
_customerCreditService = customerCreditService;
|
||||
}
|
||||
|
||||
public async Task<IViewComponentResult> InvokeAsync(string widgetZone, object additionalData)
|
||||
{
|
||||
if (additionalData is not CustomerModel customerModel) return Content("");
|
||||
|
||||
var customerId = customerModel.Id;
|
||||
var credit = await _customerCreditService.GetByCustomerIdAsync(customerId);
|
||||
var outstanding = await _customerCreditService.GetOutstandingBalanceAsync(customerId);
|
||||
|
||||
var model = new CustomerCreditWidgetModel
|
||||
{
|
||||
CustomerId = customerId,
|
||||
HasCreditLimit = credit != null,
|
||||
CreditLimit = credit?.CreditLimit ?? 0m,
|
||||
OutstandingBalance = outstanding,
|
||||
RemainingCredit = credit != null ? credit.CreditLimit - outstanding : (decimal?)null,
|
||||
Comment = credit?.Comment
|
||||
};
|
||||
|
||||
return View("~/Plugins/Misc.FruitBankPlugin/Views/CustomerCreditWidget.cshtml", model);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nop.Web.Framework.Components;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Components;
|
||||
|
||||
public class CustomerPreorderNavViewComponent : NopViewComponent
|
||||
{
|
||||
public IViewComponentResult Invoke(string widgetZone, object additionalData)
|
||||
{
|
||||
return View("~/Plugins/Misc.FruitBankPlugin/Views/CustomerPreorder/NavItem.cshtml");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
using FruitBank.Common.Enums;
|
||||
using LinqToDB;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nop.Core;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||
using Nop.Services.Customers;
|
||||
using Nop.Web.Framework.Controllers;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers;
|
||||
|
||||
public class CustomerPreorderController : BasePluginController
|
||||
{
|
||||
private readonly IWorkContext _workContext;
|
||||
private readonly ICustomerService _customerService;
|
||||
private readonly PreorderDbContext _preorderDbContext;
|
||||
private readonly FruitBankDbContext _dbContext;
|
||||
|
||||
public CustomerPreorderController(
|
||||
IWorkContext workContext,
|
||||
ICustomerService customerService,
|
||||
PreorderDbContext preorderDbContext,
|
||||
FruitBankDbContext dbContext)
|
||||
{
|
||||
_workContext = workContext;
|
||||
_customerService = customerService;
|
||||
_preorderDbContext = preorderDbContext;
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List()
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Challenge();
|
||||
|
||||
// Load this customer's preorders, newest first
|
||||
var preorders = await _preorderDbContext.Preorders
|
||||
.GetAllByCustomerIdAsync(customer.Id, false)
|
||||
.OrderByDescending(p => p.CreatedOnUtc)
|
||||
.ToListAsync();
|
||||
|
||||
var allItems = await _preorderDbContext.PreorderItems.GetAll()
|
||||
.Where(i => preorders.Select(p => p.Id).Contains(i.PreorderId))
|
||||
.ToListAsync();
|
||||
|
||||
// Resolve product names
|
||||
var productIds = allItems.Select(i => i.ProductId).Distinct().ToList();
|
||||
var productDtos = await _dbContext.ProductDtos
|
||||
.GetAll(false)
|
||||
.Where(p => productIds.Contains(p.Id))
|
||||
.ToListAsync();
|
||||
var productById = productDtos.ToDictionary(p => p.Id);
|
||||
|
||||
var rows = preorders.Select(p =>
|
||||
{
|
||||
var items = allItems.Where(i => i.PreorderId == p.Id).ToList();
|
||||
|
||||
// Derive status from quantities (enum reads unreliable in LinqToDB)
|
||||
var allFulfilled = items.Any() && items.All(i => i.FulfilledQuantity >= i.RequestedQuantity);
|
||||
var anyFulfilled = items.Any(i => i.FulfilledQuantity > 0);
|
||||
var allDropped = items.Any() && items.All(i => i.FulfilledQuantity == 0 &&
|
||||
i.RequestedQuantity > 0);
|
||||
|
||||
var effectiveStatus = (int)p.Status != 0 ? p.Status
|
||||
: allFulfilled ? PreorderStatus.Confirmed
|
||||
: anyFulfilled ? PreorderStatus.PartiallyFulfilled
|
||||
: PreorderStatus.Pending;
|
||||
|
||||
return new CustomerPreorderRow
|
||||
{
|
||||
PreorderId = p.Id,
|
||||
OrderId = p.OrderId,
|
||||
DateOfReceipt = p.DateOfReceipt,
|
||||
CreatedOnUtc = p.CreatedOnUtc,
|
||||
Status = effectiveStatus,
|
||||
CustomerNote = p.CustomerNote,
|
||||
Items = items.Select(i =>
|
||||
{
|
||||
productById.TryGetValue(i.ProductId, out var dto);
|
||||
return new CustomerPreorderItemRow
|
||||
{
|
||||
ProductName = dto?.Name ?? $"Termék #{i.ProductId}",
|
||||
IsMeasurable = dto?.IsMeasurable ?? false,
|
||||
RequestedQuantity = i.RequestedQuantity,
|
||||
FulfilledQuantity = i.FulfilledQuantity,
|
||||
UnitPriceInclTax = i.UnitPriceInclTax,
|
||||
Status = i.FulfilledQuantity == 0
|
||||
? PreorderItemStatus.Pending
|
||||
: i.FulfilledQuantity >= i.RequestedQuantity
|
||||
? PreorderItemStatus.Fulfilled
|
||||
: PreorderItemStatus.PartiallyFulfilled
|
||||
};
|
||||
}).ToList()
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
return View("~/Plugins/Misc.FruitBankPlugin/Views/CustomerPreorder/List.cshtml", rows);
|
||||
}
|
||||
|
||||
// ── Inner models ──────────────────────────────────────────────────────────
|
||||
|
||||
public class CustomerPreorderRow
|
||||
{
|
||||
public int PreorderId { get; set; }
|
||||
public int? OrderId { get; set; }
|
||||
public DateTime DateOfReceipt { get; set; }
|
||||
public DateTime CreatedOnUtc { get; set; }
|
||||
public PreorderStatus Status { get; set; }
|
||||
public string? CustomerNote { get; set; }
|
||||
public List<CustomerPreorderItemRow> Items { get; set; } = new();
|
||||
}
|
||||
|
||||
public class CustomerPreorderItemRow
|
||||
{
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
public bool IsMeasurable { get; set; }
|
||||
public int RequestedQuantity { get; set; }
|
||||
public int FulfilledQuantity { get; set; }
|
||||
public decimal UnitPriceInclTax { get; set; }
|
||||
public PreorderItemStatus Status { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nop.Web.Framework.Controllers;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers;
|
||||
|
||||
public class HelpController : BasePluginController
|
||||
{
|
||||
[HttpGet]
|
||||
public IActionResult Index()
|
||||
{
|
||||
return View("~/Plugins/Misc.FruitBankPlugin/Views/Help/Index.cshtml");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,630 @@
|
|||
using FruitBank.Common.Entities;
|
||||
using FruitBank.Common.Server;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nop.Core;
|
||||
using Nop.Core.Domain.Catalog;
|
||||
using Nop.Core.Domain.Orders;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||
using Nop.Services.Catalog;
|
||||
using Nop.Services.Customers;
|
||||
using Nop.Services.Localization;
|
||||
using Nop.Services.Orders;
|
||||
using Nop.Web.Framework.Controllers;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers;
|
||||
|
||||
[AutoValidateAntiforgeryToken]
|
||||
public class OrderController : BasePluginController
|
||||
{
|
||||
private readonly IWorkContext _workContext;
|
||||
private readonly IStoreContext _storeContext;
|
||||
private readonly ICustomerService _customerService;
|
||||
private readonly ILocalizationService _localizationService;
|
||||
private readonly FruitBankDbContext _dbContext;
|
||||
private readonly PreorderDbContext _preorderDbContext;
|
||||
private readonly FruitBankAttributeService _fruitBankAttributeService;
|
||||
private readonly CustomPriceCalculationService _customPriceCalculationService;
|
||||
private readonly IShoppingCartService _shoppingCartService;
|
||||
private readonly IProductService _productService;
|
||||
private readonly OpenAIApiService _aiApiService;
|
||||
private readonly CerebrasAPIService _cerebrasApiService;
|
||||
private readonly PreorderConversionService _preorderConversionService;
|
||||
|
||||
private const string PendingDeliveryKey = "OrderFlowPendingDeliveryDateTime";
|
||||
|
||||
public OrderController(
|
||||
IWorkContext workContext,
|
||||
IStoreContext storeContext,
|
||||
ICustomerService customerService,
|
||||
ILocalizationService localizationService,
|
||||
FruitBankDbContext dbContext,
|
||||
PreorderDbContext preorderDbContext,
|
||||
FruitBankAttributeService fruitBankAttributeService,
|
||||
IPriceCalculationService priceCalculationService,
|
||||
IShoppingCartService shoppingCartService,
|
||||
IProductService productService,
|
||||
OpenAIApiService aiApiService,
|
||||
CerebrasAPIService cerebrasApiService,
|
||||
PreorderConversionService preorderConversionService)
|
||||
{
|
||||
_workContext = workContext;
|
||||
_storeContext = storeContext;
|
||||
_customerService = customerService;
|
||||
_localizationService = localizationService;
|
||||
_dbContext = dbContext;
|
||||
_preorderDbContext = preorderDbContext;
|
||||
_fruitBankAttributeService = fruitBankAttributeService;
|
||||
_customPriceCalculationService = priceCalculationService as CustomPriceCalculationService;
|
||||
_shoppingCartService = shoppingCartService;
|
||||
_productService = productService;
|
||||
_aiApiService = aiApiService;
|
||||
_cerebrasApiService = cerebrasApiService;
|
||||
_preorderConversionService = preorderConversionService;
|
||||
}
|
||||
|
||||
// ── INDEX ─────────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Challenge();
|
||||
|
||||
return View("~/Plugins/Misc.FruitBankPlugin/Views/Order/Index.cshtml");
|
||||
}
|
||||
|
||||
// ── FLOW TYPE ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Mon/Tue/Wed → preorder regardless of delivery date.
|
||||
/// Thu/Fri/Sat/Sun + delivery this week → quickorder.
|
||||
/// Thu/Fri/Sat/Sun + delivery next week or later → preorder.
|
||||
/// </summary>
|
||||
public static string ComputeFlowType(DateTime deliveryDate)
|
||||
{
|
||||
var today = DateTime.Today;
|
||||
var todayDow = (int)today.DayOfWeek; // 0=Sun 1=Mon … 6=Sat
|
||||
|
||||
// This week's Thursday
|
||||
int daysSinceMon = todayDow == 0 ? 6 : todayDow - 1;
|
||||
var weekStart = today.AddDays(-daysSinceMon); // Monday
|
||||
var thisThursday = weekStart.AddDays(3); // Thursday
|
||||
var weekEnd = weekStart.AddDays(6); // Sunday
|
||||
|
||||
bool deliveryBeforeThursday = deliveryDate.Date < thisThursday;
|
||||
bool isLateWeek = todayDow == 0 || todayDow >= 4; // Thu-Sun
|
||||
bool deliveryThisWeek = deliveryDate.Date >= weekStart && deliveryDate.Date <= weekEnd;
|
||||
|
||||
// Quick Order: delivery needs current stock (before Thursday)
|
||||
// OR goods already arrived (Thu-Sun) and delivery still this week
|
||||
// Preorder: delivery is Thursday+ but today is still Mon/Tue/Wed (goods not yet here)
|
||||
return (deliveryBeforeThursday || (isLateWeek && deliveryThisWeek))
|
||||
? "quickorder"
|
||||
: "preorder";
|
||||
}
|
||||
|
||||
// ── GET / SET DELIVERY DATETIME ───────────────────────────────────────────
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetDeliveryDateTime()
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Json(new { success = false });
|
||||
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
var saved = await _fruitBankAttributeService
|
||||
.GetGenericAttributeValueAsync<Nop.Core.Domain.Customers.Customer, DateTime?>(
|
||||
customer.Id, PendingDeliveryKey, store.Id);
|
||||
|
||||
if (!saved.HasValue)
|
||||
return Json(new { success = true, hasValue = false });
|
||||
|
||||
var flowType = ComputeFlowType(saved.Value);
|
||||
|
||||
return Json(new
|
||||
{
|
||||
success = true,
|
||||
hasValue = true,
|
||||
date = saved.Value.ToString("yyyy-MM-dd"),
|
||||
time = saved.Value.ToString("HH:mm"),
|
||||
iso = saved.Value.ToString("yyyy-MM-ddTHH:mm"),
|
||||
flowType
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> SetDeliveryDateTime(string deliveryDateTime)
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Json(new { success = false, message = "Nincs bejelentkezve" });
|
||||
|
||||
if (string.IsNullOrWhiteSpace(deliveryDateTime) ||
|
||||
!DateTime.TryParse(deliveryDateTime, out var parsed))
|
||||
return Json(new { success = false, message = "Érvénytelen dátum/idő formátum" });
|
||||
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync<Nop.Core.Domain.Customers.Customer, DateTime>(
|
||||
customer.Id, PendingDeliveryKey, parsed, store.Id);
|
||||
|
||||
var flowType = ComputeFlowType(parsed);
|
||||
|
||||
Console.WriteLine($"[OrderFlow] SetDeliveryDateTime — customer #{customer.Id}, {parsed:u}, flowType={flowType}");
|
||||
return Json(new { success = true, flowType });
|
||||
}
|
||||
|
||||
// ── PRODUCTS — Quick Order flow (all available stock) ─────────────────────
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAllProducts(string deliveryDate = null, string deliveryTime = null)
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Json(new { success = false, message = "Nincs bejelentkezve" });
|
||||
|
||||
try
|
||||
{
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
var allProductDtos = (await _dbContext.ProductDtos.GetAll(true).ToListAsync())
|
||||
.Where(pd => pd.AvailableQuantity > 0);
|
||||
|
||||
var result = new List<object>();
|
||||
foreach (var product in allProductDtos)
|
||||
{
|
||||
var availableQty = product.StockQuantity + product.IncomingQuantity;
|
||||
if (availableQty <= 0) continue;
|
||||
|
||||
decimal? unitPrice = null;
|
||||
if (!product.IsMeasurable && _customPriceCalculationService != null)
|
||||
{
|
||||
var tproduct = await _productService.GetProductByIdAsync(product.Id);
|
||||
if (tproduct != null)
|
||||
{
|
||||
var pr = await _customPriceCalculationService.GetFinalPriceAsync(
|
||||
tproduct, customer, store, null, 0, true, 1, null, null);
|
||||
unitPrice = pr.finalPrice;
|
||||
}
|
||||
}
|
||||
|
||||
result.Add(new
|
||||
{
|
||||
id = product.Id,
|
||||
name = product.Name,
|
||||
quantity = 1,
|
||||
unitPrice,
|
||||
stockQuantity = availableQty,
|
||||
searchTerm = (string)null,
|
||||
isQuantityReduced = false,
|
||||
isMeasurable = product.IsMeasurable
|
||||
});
|
||||
}
|
||||
|
||||
return Json(new { success = true, products = result });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
// ── PRODUCTS — Preorder flow (curated window list) ────────────────────────
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetPreorderProducts()
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Json(new { success = false, message = "Nincs bejelentkezve" });
|
||||
|
||||
try
|
||||
{
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
var today = DateTime.UtcNow.Date;
|
||||
|
||||
var gaStart = await _dbContext.GenericAttributes.Table
|
||||
.Where(ga => ga.KeyGroup == nameof(Product)
|
||||
&& ga.Key == FruitBankConst.PreorderWindowStart
|
||||
&& ga.StoreId == store.Id)
|
||||
.ToListAsync();
|
||||
var gaEnd = await _dbContext.GenericAttributes.Table
|
||||
.Where(ga => ga.KeyGroup == nameof(Product)
|
||||
&& ga.Key == FruitBankConst.PreorderWindowEnd
|
||||
&& ga.StoreId == store.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var startById = gaStart.ToDictionary(g => g.EntityId, g => g.Value);
|
||||
var endById = gaEnd.ToDictionary(g => g.EntityId, g => g.Value);
|
||||
|
||||
var availableIds = startById.Keys.Intersect(endById.Keys)
|
||||
.Where(id =>
|
||||
{
|
||||
DateTime.TryParse(startById[id], out var ws);
|
||||
DateTime.TryParse(endById[id], out var we);
|
||||
return ws.Date <= today && today <= we.Date;
|
||||
})
|
||||
.ToHashSet();
|
||||
|
||||
if (!availableIds.Any())
|
||||
return Json(new { success = true, products = Array.Empty<object>() });
|
||||
|
||||
var productDtos = await _dbContext.ProductDtos
|
||||
.GetAll(true)
|
||||
.Where(p => availableIds.Contains(p.Id))
|
||||
.ToListAsync();
|
||||
|
||||
var result = new List<object>();
|
||||
foreach (var dto in productDtos.OrderBy(p => p.Name))
|
||||
{
|
||||
decimal? unitPrice = null;
|
||||
if (!dto.IsMeasurable && _customPriceCalculationService != null)
|
||||
{
|
||||
var product = await _dbContext.Products.GetByIdAsync(dto.Id);
|
||||
if (product != null)
|
||||
{
|
||||
var pr = await _customPriceCalculationService.GetFinalPriceAsync(
|
||||
product, customer, store, null, 0, true, 1, null, null);
|
||||
unitPrice = pr.finalPrice;
|
||||
}
|
||||
}
|
||||
|
||||
result.Add(new
|
||||
{
|
||||
id = dto.Id,
|
||||
name = dto.Name,
|
||||
isMeasurable = dto.IsMeasurable,
|
||||
unitPrice,
|
||||
stockQuantity = dto.AvailableQuantity
|
||||
});
|
||||
}
|
||||
|
||||
return Json(new { success = true, products = result });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
// ── SEARCH (Quick Order flow) ─────────────────────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> SearchProducts(string text, string deliveryDate = null, string deliveryTime = null)
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Json(new { success = false, message = "Nincs bejelentkezve" });
|
||||
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return Json(new { success = false, message = "Nincs szöveg megadva" });
|
||||
|
||||
try
|
||||
{
|
||||
var parsedProducts = await ParseProductsFromText(text);
|
||||
if (parsedProducts == null || !parsedProducts.Any())
|
||||
return Json(new { success = false, message = "Nem sikerült termékeket azonosítani", transcription = text });
|
||||
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
var enriched = await EnrichProductData(parsedProducts, customer, store);
|
||||
return Json(new { success = true, transcription = text, products = enriched });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
// ── VOICE (Quick Order flow) ──────────────────────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> TranscribeAndSearch(
|
||||
Microsoft.AspNetCore.Http.IFormFile audioFile,
|
||||
string deliveryDate = null, string deliveryTime = null)
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Json(new { success = false, message = "Nincs bejelentkezve" });
|
||||
|
||||
if (audioFile == null || audioFile.Length == 0)
|
||||
return Json(new { success = false, message = "Nem érkezett hangfájl" });
|
||||
|
||||
try
|
||||
{
|
||||
var text = await TranscribeAudioFile(audioFile, "hu");
|
||||
if (string.IsNullOrEmpty(text))
|
||||
return Json(new { success = false, message = "Nem sikerült a hangfelismerés" });
|
||||
|
||||
var parsedProducts = await ParseProductsFromText(text);
|
||||
if (parsedProducts == null || !parsedProducts.Any())
|
||||
return Json(new { success = false, message = "Nem sikerült termékeket azonosítani", transcription = text });
|
||||
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
var enriched = await EnrichProductData(parsedProducts, customer, store);
|
||||
return Json(new { success = true, transcription = text, products = enriched });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
// ── ADD TO CART (Quick Order flow) ────────────────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> AddToCart(int productId, int quantity)
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Json(new { success = false, message = "Nincs bejelentkezve" });
|
||||
|
||||
if (productId <= 0 || quantity <= 0)
|
||||
return Json(new { success = false, message = "Érvénytelen termék vagy mennyiség" });
|
||||
|
||||
try
|
||||
{
|
||||
var product = await _productService.GetProductByIdAsync(productId);
|
||||
if (product == null || product.Deleted || !product.Published)
|
||||
return Json(new { success = false, message = "A termék nem elérhető" });
|
||||
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
var warnings = await _shoppingCartService.AddToCartAsync(
|
||||
customer, product, ShoppingCartType.ShoppingCart, store.Id, quantity: quantity);
|
||||
|
||||
if (warnings.Any())
|
||||
return Json(new { success = false, message = string.Join("; ", warnings) });
|
||||
|
||||
var cartItems = await GetCartItemsJson(customer, store);
|
||||
return Json(new { success = true, cartItems });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET CART (Quick Order flow) ───────────────────────────────────────────
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetCartItems()
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Json(new { success = false });
|
||||
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
return Json(new { success = true, cartItems = await GetCartItemsJson(customer, store) });
|
||||
}
|
||||
|
||||
// ── PLACE PREORDER (Preorder flow) ────────────────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> PlacePreorder([FromBody] PlacePreorderRequest request)
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Json(new { success = false, message = "Nincs bejelentkezve" });
|
||||
|
||||
if (request?.Items == null || !request.Items.Any())
|
||||
return Json(new { success = false, message = "Nincs kiválasztott termék" });
|
||||
|
||||
if (!DateTime.TryParse(request.DeliveryDateTime, out var deliveryDateTime))
|
||||
return Json(new { success = false, message = "Érvénytelen szállítási dátum/idő" });
|
||||
|
||||
try
|
||||
{
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
|
||||
var preorder = new Preorder
|
||||
{
|
||||
CustomerId = customer.Id,
|
||||
StoreId = store.Id,
|
||||
DateOfReceipt = deliveryDateTime,
|
||||
CustomerNote = request.CustomerNote?.Trim()
|
||||
};
|
||||
|
||||
var items = new List<PreorderItem>();
|
||||
foreach (var req in request.Items.Where(i => i.Quantity > 0))
|
||||
{
|
||||
var product = await _dbContext.Products.GetByIdAsync(req.ProductId);
|
||||
if (product == null || product.Deleted || !product.Published) continue;
|
||||
|
||||
decimal unitPrice = 0;
|
||||
if (_customPriceCalculationService != null)
|
||||
{
|
||||
var pr = await _customPriceCalculationService.GetFinalPriceAsync(
|
||||
product, customer, store, null, 0, true, req.Quantity, null, null);
|
||||
unitPrice = pr.finalPrice;
|
||||
}
|
||||
|
||||
items.Add(new PreorderItem
|
||||
{
|
||||
ProductId = req.ProductId,
|
||||
RequestedQuantity = req.Quantity,
|
||||
UnitPriceInclTax = unitPrice
|
||||
});
|
||||
}
|
||||
|
||||
if (!items.Any())
|
||||
return Json(new { success = false, message = "Nincs érvényes termék az előrendelésben" });
|
||||
|
||||
var saved = await _preorderDbContext.InsertPreorderAsync(preorder, items);
|
||||
|
||||
// Clean up the pending datetime attribute
|
||||
await _fruitBankAttributeService
|
||||
.DeleteGenericAttributeAsync<Nop.Core.Domain.Customers.Customer>(
|
||||
customer.Id, PendingDeliveryKey, store.Id);
|
||||
|
||||
// Immediately check if any items can be fulfilled from current available stock.
|
||||
// Awaited inline (not fire-and-forget) so we can return the order ID if one is created.
|
||||
// shippingDocumentId = 0 signals this was triggered at preorder placement, not by a document.
|
||||
var productIds = items.Select(i => i.ProductId).Distinct().ToList();
|
||||
await _preorderConversionService.ConvertPreordersForProductsAsync(productIds, 0);
|
||||
|
||||
// Re-read to pick up OrderId if conversion created a real order
|
||||
var refreshed = await _preorderDbContext.Preorders.GetByIdAsync(saved.Id);
|
||||
|
||||
Console.WriteLine($"[OrderFlow] PlacePreorder #{saved.Id} — orderId={refreshed?.OrderId}");
|
||||
|
||||
return Json(new
|
||||
{
|
||||
success = true,
|
||||
preorderId = saved.Id,
|
||||
orderId = refreshed?.OrderId
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
// ── PRIVATE HELPERS ───────────────────────────────────────────────────────
|
||||
|
||||
private async Task<string> TranscribeAudioFile(Microsoft.AspNetCore.Http.IFormFile audioFile, string language)
|
||||
{
|
||||
var fileName = $"order_{DateTime.Now:yyyyMMdd_HHmmss}.webm";
|
||||
var uploadsFolder = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "uploads", "voice");
|
||||
if (!Directory.Exists(uploadsFolder)) Directory.CreateDirectory(uploadsFolder);
|
||||
|
||||
var filePath = Path.Combine(uploadsFolder, fileName);
|
||||
using (var stream = new FileStream(filePath, FileMode.Create))
|
||||
await audioFile.CopyToAsync(stream);
|
||||
|
||||
string text;
|
||||
using (var audioStream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
|
||||
text = await _aiApiService.TranscribeAudioAsync(audioStream, fileName, language, null);
|
||||
|
||||
if (!string.IsNullOrEmpty(text) && (text.EndsWith(".") || text.EndsWith("!") || text.EndsWith("?")))
|
||||
text = text[..^1];
|
||||
|
||||
try { System.IO.File.Delete(filePath); } catch { }
|
||||
return text;
|
||||
}
|
||||
|
||||
private async Task<List<ParsedProduct>> ParseProductsFromText(string text)
|
||||
{
|
||||
var systemPrompt = @"You are a product parser for a Hungarian fruit and vegetable wholesale company.
|
||||
Parse the product names and quantities from the user's input.
|
||||
CRITICAL RULES:
|
||||
1. Normalize product names to singular, lowercase
|
||||
2. Handle Hungarian number words
|
||||
3. Fix common transcription/typing errors
|
||||
4. Return ONLY valid JSON array
|
||||
OUTPUT FORMAT: [{""product"": ""narancs"", ""quantity"": 100}]";
|
||||
|
||||
var aiResponse = await _cerebrasApiService.GetSimpleResponseAsync(systemPrompt, $"Parse this: {text}");
|
||||
var jsonMatch = Regex.Match(aiResponse, @"\[.*\]", RegexOptions.Singleline);
|
||||
if (!jsonMatch.Success) return new List<ParsedProduct>();
|
||||
try
|
||||
{
|
||||
return System.Text.Json.JsonSerializer.Deserialize<List<ParsedProduct>>(jsonMatch.Value)
|
||||
?? new List<ParsedProduct>();
|
||||
}
|
||||
catch { return new List<ParsedProduct>(); }
|
||||
}
|
||||
|
||||
private async Task<List<object>> EnrichProductData(
|
||||
List<ParsedProduct> parsedProducts,
|
||||
Nop.Core.Domain.Customers.Customer customer,
|
||||
Nop.Core.Domain.Stores.Store store)
|
||||
{
|
||||
var enriched = new List<object>();
|
||||
var allDtos = await _dbContext.ProductDtos.GetAll(true).ToListAsync();
|
||||
|
||||
foreach (var parsed in parsedProducts)
|
||||
{
|
||||
var dbProducts = await _productService.SearchProductsAsync(
|
||||
keywords: parsed.Product, pageIndex: 0, pageSize: 20);
|
||||
|
||||
foreach (var product in dbProducts)
|
||||
{
|
||||
var dto = allDtos.FirstOrDefault(x => x.Id == product.Id);
|
||||
if (dto == null) continue;
|
||||
var available = product.StockQuantity + dto.IncomingQuantity;
|
||||
if (available <= 0) continue;
|
||||
|
||||
var finalQty = Math.Min(parsed.Quantity, available);
|
||||
var isReduced = finalQty < parsed.Quantity;
|
||||
decimal? price = null;
|
||||
if (!dto.IsMeasurable && _customPriceCalculationService != null)
|
||||
{
|
||||
var pr = await _customPriceCalculationService.GetFinalPriceAsync(
|
||||
product, customer, store, null, 0, true, finalQty, null, null);
|
||||
price = pr.finalPrice;
|
||||
}
|
||||
|
||||
enriched.Add(new
|
||||
{
|
||||
id = product.Id,
|
||||
name = product.Name,
|
||||
quantity = finalQty,
|
||||
requestedQuantity = parsed.Quantity,
|
||||
unitPrice = price,
|
||||
stockQuantity = available,
|
||||
searchTerm = parsed.Product,
|
||||
isQuantityReduced = isReduced,
|
||||
isMeasurable = dto.IsMeasurable
|
||||
});
|
||||
}
|
||||
}
|
||||
return enriched;
|
||||
}
|
||||
|
||||
private async Task<List<object>> GetCartItemsJson(
|
||||
Nop.Core.Domain.Customers.Customer customer,
|
||||
Nop.Core.Domain.Stores.Store store)
|
||||
{
|
||||
var cart = await _shoppingCartService.GetShoppingCartAsync(customer, ShoppingCartType.ShoppingCart, store.Id);
|
||||
var allDtos = await _dbContext.ProductDtos.GetAll(true).ToListAsync();
|
||||
var result = new List<object>();
|
||||
|
||||
foreach (var item in cart)
|
||||
{
|
||||
var product = await _productService.GetProductByIdAsync(item.ProductId);
|
||||
if (product == null) continue;
|
||||
var dto = allDtos.FirstOrDefault(x => x.Id == product.Id);
|
||||
var isMeasurable = dto?.IsMeasurable ?? false;
|
||||
decimal? price = null;
|
||||
if (!isMeasurable && _customPriceCalculationService != null)
|
||||
{
|
||||
var pr = await _customPriceCalculationService.GetFinalPriceAsync(
|
||||
product, customer, store, null, 0, true, item.Quantity, null, null);
|
||||
price = pr.finalPrice;
|
||||
}
|
||||
result.Add(new { id = item.Id, productId = item.ProductId, name = product.Name,
|
||||
quantity = item.Quantity, unitPrice = price, isMeasurable });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Inner models ──────────────────────────────────────────────────────────
|
||||
|
||||
public class PlacePreorderRequest
|
||||
{
|
||||
public string? DeliveryDateTime { get; set; }
|
||||
public string? CustomerNote { get; set; }
|
||||
public List<PreorderItemRequest> Items { get; set; } = new();
|
||||
}
|
||||
|
||||
public class PreorderItemRequest
|
||||
{
|
||||
public int ProductId { get; set; }
|
||||
public int Quantity { get; set; }
|
||||
}
|
||||
|
||||
private class ParsedProduct
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("product")]
|
||||
public string Product { get; set; }
|
||||
[System.Text.Json.Serialization.JsonPropertyName("quantity")]
|
||||
public int Quantity { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,290 @@
|
|||
using FruitBank.Common.Entities;
|
||||
using FruitBank.Common.Server;
|
||||
using LinqToDB;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nop.Core;
|
||||
using Nop.Core.Domain.Catalog;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||
using Nop.Services.Catalog;
|
||||
using Nop.Services.Customers;
|
||||
using Nop.Services.Localization;
|
||||
using Nop.Web.Framework.Controllers;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers;
|
||||
|
||||
[AutoValidateAntiforgeryToken]
|
||||
public class PreorderController : BasePluginController
|
||||
{
|
||||
private readonly IWorkContext _workContext;
|
||||
private readonly IStoreContext _storeContext;
|
||||
private readonly ICustomerService _customerService;
|
||||
private readonly ILocalizationService _localizationService;
|
||||
private readonly FruitBankDbContext _dbContext;
|
||||
private readonly PreorderDbContext _preorderDbContext;
|
||||
private readonly FruitBankAttributeService _fruitBankAttributeService;
|
||||
private readonly CustomPriceCalculationService _customPriceCalculationService;
|
||||
|
||||
private const string PendingDeliveryDateTimeKey = "PreorderPendingDeliveryDateTime";
|
||||
private const string Prefix = "Plugins.Misc.FruitBankPlugin.Preorder.";
|
||||
|
||||
public PreorderController(
|
||||
IWorkContext workContext,
|
||||
IStoreContext storeContext,
|
||||
ICustomerService customerService,
|
||||
ILocalizationService localizationService,
|
||||
FruitBankDbContext dbContext,
|
||||
PreorderDbContext preorderDbContext,
|
||||
FruitBankAttributeService fruitBankAttributeService,
|
||||
IPriceCalculationService priceCalculationService)
|
||||
{
|
||||
_workContext = workContext;
|
||||
_storeContext = storeContext;
|
||||
_customerService = customerService;
|
||||
_localizationService = localizationService;
|
||||
_dbContext = dbContext;
|
||||
_preorderDbContext = preorderDbContext;
|
||||
_fruitBankAttributeService = fruitBankAttributeService;
|
||||
_customPriceCalculationService = priceCalculationService as CustomPriceCalculationService;
|
||||
}
|
||||
|
||||
private Task<string> L(string keySuffix)
|
||||
=> _localizationService.GetResourceAsync(Prefix + keySuffix);
|
||||
|
||||
// ── INDEX ─────────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Challenge();
|
||||
|
||||
return View("~/Plugins/Misc.FruitBankPlugin/Views/Preorder/Index.cshtml");
|
||||
}
|
||||
|
||||
// ── GET SAVED DELIVERY DATETIME (page restore) ────────────────────────────
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetDeliveryDateTime()
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Json(new { success = false });
|
||||
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
var saved = await _fruitBankAttributeService
|
||||
.GetGenericAttributeValueAsync<Nop.Core.Domain.Customers.Customer, DateTime?>(
|
||||
customer.Id, PendingDeliveryDateTimeKey, store.Id);
|
||||
|
||||
if (!saved.HasValue)
|
||||
return Json(new { success = true, hasValue = false });
|
||||
|
||||
return Json(new
|
||||
{
|
||||
success = true,
|
||||
hasValue = true,
|
||||
date = saved.Value.ToString("yyyy-MM-dd"),
|
||||
time = saved.Value.ToString("HH:mm"),
|
||||
iso = saved.Value.ToString("yyyy-MM-ddTHH:mm")
|
||||
});
|
||||
}
|
||||
|
||||
// ── SET DELIVERY DATETIME ─────────────────────────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> SetDeliveryDateTime(string deliveryDateTime)
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Json(new { success = false, message = await L("NotLoggedIn") });
|
||||
|
||||
if (string.IsNullOrWhiteSpace(deliveryDateTime))
|
||||
return Json(new { success = false, message = await L("NoDeliveryDateTimeProvided") });
|
||||
|
||||
if (!DateTime.TryParse(deliveryDateTime, out var parsed))
|
||||
return Json(new { success = false, message = await L("InvalidDeliveryDateTime") });
|
||||
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
await _fruitBankAttributeService.InsertOrUpdateGenericAttributeAsync<Nop.Core.Domain.Customers.Customer, DateTime>(
|
||||
customer.Id, PendingDeliveryDateTimeKey, parsed, store.Id);
|
||||
|
||||
return Json(new { success = true });
|
||||
}
|
||||
|
||||
// ── GET AVAILABLE PRODUCTS (filtered by preorder window) ──────────────────
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAvailableProducts()
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Json(new { success = false, message = await L("NotLoggedIn") });
|
||||
|
||||
try
|
||||
{
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
var today = DateTime.UtcNow.Date;
|
||||
|
||||
// Load preorder window generic attributes — two queries, no N+1
|
||||
var gaStart = await _dbContext.GenericAttributes.Table
|
||||
.Where(ga => ga.KeyGroup == nameof(Product)
|
||||
&& ga.Key == FruitBankConst.PreorderWindowStart
|
||||
&& ga.StoreId == store.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var gaEnd = await _dbContext.GenericAttributes.Table
|
||||
.Where(ga => ga.KeyGroup == nameof(Product)
|
||||
&& ga.Key == FruitBankConst.PreorderWindowEnd
|
||||
&& ga.StoreId == store.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var startById = gaStart.ToDictionary(g => g.EntityId, g => g.Value);
|
||||
var endById = gaEnd.ToDictionary(g => g.EntityId, g => g.Value);
|
||||
|
||||
// Product IDs that are available today for preorder
|
||||
var availableProductIds = startById.Keys
|
||||
.Intersect(endById.Keys)
|
||||
.Where(id =>
|
||||
{
|
||||
DateTime.TryParse(startById[id], out var ws);
|
||||
DateTime.TryParse(endById[id], out var we);
|
||||
return ws.Date <= today && today <= we.Date;
|
||||
})
|
||||
.ToHashSet();
|
||||
|
||||
if (!availableProductIds.Any())
|
||||
return Json(new { success = true, products = Array.Empty<object>() });
|
||||
|
||||
// Load product DTOs for those IDs
|
||||
var productDtos = await _dbContext.ProductDtos
|
||||
.GetAll(true)
|
||||
.Where(p => availableProductIds.Contains(p.Id))
|
||||
.ToListAsync();
|
||||
|
||||
var result = new List<object>();
|
||||
|
||||
foreach (var dto in productDtos.OrderBy(p => p.Name))
|
||||
{
|
||||
decimal? unitPrice = null;
|
||||
if (!dto.IsMeasurable && _customPriceCalculationService != null)
|
||||
{
|
||||
var product = await _dbContext.Products.GetByIdAsync(dto.Id);
|
||||
if (product != null)
|
||||
{
|
||||
var priceResult = await _customPriceCalculationService.GetFinalPriceAsync(
|
||||
product, customer, store, null, 0, true, 1, null, null);
|
||||
unitPrice = priceResult.finalPrice;
|
||||
}
|
||||
}
|
||||
|
||||
result.Add(new
|
||||
{
|
||||
id = dto.Id,
|
||||
name = dto.Name,
|
||||
isMeasurable = dto.IsMeasurable,
|
||||
unitPrice,
|
||||
stockQuantity = dto.AvailableQuantity
|
||||
});
|
||||
}
|
||||
|
||||
return Json(new { success = true, products = result });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Preorder] GetAvailableProducts error: {ex.Message}");
|
||||
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
// ── PLACE PREORDER ────────────────────────────────────────────────────────
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> PlacePreorder([FromBody] PlacePreorderRequest request)
|
||||
{
|
||||
var customer = await _workContext.GetCurrentCustomerAsync();
|
||||
if (await _customerService.IsGuestAsync(customer))
|
||||
return Json(new { success = false, message = await L("NotLoggedIn") });
|
||||
|
||||
if (request?.Items == null || !request.Items.Any())
|
||||
return Json(new { success = false, message = await L("NoItemsSelected") });
|
||||
|
||||
if (!DateTime.TryParse(request.DeliveryDateTime, out var deliveryDateTime))
|
||||
return Json(new { success = false, message = await L("InvalidDeliveryDateTime") });
|
||||
|
||||
try
|
||||
{
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
|
||||
var preorder = new Preorder
|
||||
{
|
||||
CustomerId = customer.Id,
|
||||
StoreId = store.Id,
|
||||
DateOfReceipt = deliveryDateTime,
|
||||
CustomerNote = request.CustomerNote?.Trim()
|
||||
};
|
||||
|
||||
var items = new List<PreorderItem>();
|
||||
foreach (var req in request.Items.Where(i => i.Quantity > 0))
|
||||
{
|
||||
var product = await _dbContext.Products.GetByIdAsync(req.ProductId);
|
||||
if (product == null || product.Deleted || !product.Published)
|
||||
continue;
|
||||
|
||||
decimal unitPrice = 0;
|
||||
if (_customPriceCalculationService != null)
|
||||
{
|
||||
var pr = await _customPriceCalculationService.GetFinalPriceAsync(
|
||||
product, customer, store, null, 0, true, req.Quantity, null, null);
|
||||
unitPrice = pr.finalPrice;
|
||||
}
|
||||
|
||||
items.Add(new PreorderItem
|
||||
{
|
||||
ProductId = req.ProductId,
|
||||
RequestedQuantity = req.Quantity,
|
||||
UnitPriceInclTax = unitPrice
|
||||
});
|
||||
}
|
||||
|
||||
if (!items.Any())
|
||||
return Json(new { success = false, message = await L("NoValidItems") });
|
||||
|
||||
var saved = await _preorderDbContext.InsertPreorderAsync(preorder, items);
|
||||
|
||||
// Clean up the pending delivery datetime attribute
|
||||
await _fruitBankAttributeService
|
||||
.DeleteGenericAttributeAsync<Nop.Core.Domain.Customers.Customer>(
|
||||
customer.Id, PendingDeliveryDateTimeKey, store.Id);
|
||||
|
||||
Console.WriteLine($"[Preorder] Placed #{saved.Id} — customer #{customer.Id}, {items.Count} items, delivery {deliveryDateTime:u}");
|
||||
|
||||
return Json(new
|
||||
{
|
||||
success = true,
|
||||
preorderId = saved.Id,
|
||||
message = await L("PlacedSuccessfully")
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Preorder] PlacePreorder error: {ex.Message}");
|
||||
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
// ── INNER MODELS ──────────────────────────────────────────────────────────
|
||||
|
||||
public class PlacePreorderRequest
|
||||
{
|
||||
public string? DeliveryDateTime { get; set; }
|
||||
public string? CustomerNote { get; set; }
|
||||
public List<PreorderItemRequest> Items { get; set; } = new();
|
||||
}
|
||||
|
||||
public class PreorderItemRequest
|
||||
{
|
||||
public int ProductId { get; set; }
|
||||
public int Quantity { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
using FruitBank.Common.Entities;
|
||||
using LinqToDB;
|
||||
using Mango.Nop.Core.Loggers;
|
||||
using Mango.Nop.Data.Repositories;
|
||||
using Nop.Core.Caching;
|
||||
using Nop.Core.Configuration;
|
||||
using Nop.Core.Events;
|
||||
using Nop.Data;
|
||||
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||
|
||||
public class CustomerCreditDbTable : MgDbTableBase<CustomerCredit>
|
||||
{
|
||||
public CustomerCreditDbTable(
|
||||
IEventPublisher eventPublisher,
|
||||
INopDataProvider dataProvider,
|
||||
IShortTermCacheManager shortTermCacheManager,
|
||||
IStaticCacheManager staticCacheManager,
|
||||
AppSettings appSettings)
|
||||
: base(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings)
|
||||
{
|
||||
}
|
||||
|
||||
public Task<CustomerCredit?> GetByCustomerIdAsync(int customerId)
|
||||
=> GetAll().FirstOrDefaultAsync(x => x.CustomerId == customerId);
|
||||
}
|
||||
|
|
@ -40,7 +40,8 @@ public class FruitBankDbContext : MgDbContextBase,
|
|||
IShippingItemPalletDbSet<ShippingItemPalletDbTable>,
|
||||
IOrderItemPalletDbSet<OrderItemPalletDbTable>,
|
||||
IShippingDocumentToFilesDbSet<ShippingDocumentToFilesDbTable>,
|
||||
IFilesDbSet<FilesDbTable>
|
||||
IFilesDbSet<FilesDbTable>,
|
||||
ICustomerCreditDbSet<CustomerCreditDbTable>
|
||||
{
|
||||
private readonly FruitBankAttributeService _fruitBankAttributeService;
|
||||
private readonly IStoreContext _storeContext;
|
||||
|
|
@ -64,6 +65,7 @@ public class FruitBankDbContext : MgDbContextBase,
|
|||
public FilesDbTable Files { get; set; }
|
||||
public ShippingDocumentToFilesDbTable ShippingDocumentToFiles { get; set; }
|
||||
public StockQuantityHistoryDtoDbTable StockQuantityHistoryDtos { get; set; }
|
||||
public CustomerCreditDbTable CustomerCredits { get; set; }
|
||||
|
||||
public IRepository<Customer> Customers { get; set; }
|
||||
public IRepository<CustomerRole> CustomerRoles { get; set; }
|
||||
|
|
@ -79,7 +81,7 @@ public class FruitBankDbContext : MgDbContextBase,
|
|||
PartnerDbTable partnerDbTable, ShippingDbTable shippingDbTable, ShippingDocumentDbTable shippingDocumentDbTable, ShippingItemDbTable shippingItemDbTable,
|
||||
ShippingItemPalletDbTable shippingItemPalletDbTable, FilesDbTable filesDbTable, ShippingDocumentToFilesDbTable shippingDocumentToFilesDbTable,
|
||||
ProductDtoDbTable productDtoDbTable, OrderDtoDbTable orderDtoDbTable, OrderItemDtoDbTable orderItemDtoDbTable, OrderItemPalletDbTable orderItemPalletDbTable,
|
||||
StockQuantityHistoryDtoDbTable stockQuantityHistoryDtos,
|
||||
StockQuantityHistoryDtoDbTable stockQuantityHistoryDtos, CustomerCreditDbTable customerCreditDbTable,
|
||||
IProductService productService, IStaticCacheManager staticCacheManager,
|
||||
IRepository<Order> orderRepository,
|
||||
IRepository<OrderItem> orderItemRepository,
|
||||
|
|
@ -127,6 +129,7 @@ public class FruitBankDbContext : MgDbContextBase,
|
|||
StockQuantityHistories = stockQuantityHistories;
|
||||
StockQuantityHistoriesExt = stockQuantityHistoriesExt;
|
||||
StockQuantityHistoryDtos = stockQuantityHistoryDtos;
|
||||
CustomerCredits = customerCreditDbTable;
|
||||
}
|
||||
|
||||
public IQueryable<Customer> GetCustomersBySystemRoleName(string systemRoleName)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
using FruitBank.Common.Entities;
|
||||
using Mango.Nop.Data.Interfaces;
|
||||
using Nop.Data;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer.Interfaces;
|
||||
|
||||
public interface ICustomerCreditDbSet<TDbTable> : IMgDbTableBase where TDbTable : IRepository<CustomerCredit>
|
||||
{
|
||||
public TDbTable CustomerCredits { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
using FruitBank.Common.Entities;
|
||||
using Mango.Nop.Data.Interfaces;
|
||||
using Nop.Data;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer.Interfaces;
|
||||
|
||||
public interface IPreorderDbSet<TDbTable> : IMgDbTableBase where TDbTable : IRepository<Preorder>
|
||||
{
|
||||
public TDbTable Preorders { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
using FruitBank.Common.Entities;
|
||||
using Mango.Nop.Data.Interfaces;
|
||||
using Nop.Data;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer.Interfaces;
|
||||
|
||||
public interface IPreorderItemDbSet<TDbTable> : IMgDbTableBase where TDbTable : IRepository<PreorderItem>
|
||||
{
|
||||
public TDbTable PreorderItems { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
using AyCode.Core.Loggers;
|
||||
using FruitBank.Common.Entities;
|
||||
using FruitBank.Common.Enums;
|
||||
using LinqToDB;
|
||||
using Mango.Nop.Core.Loggers;
|
||||
using Mango.Nop.Data.Repositories;
|
||||
using Nop.Core.Caching;
|
||||
using Nop.Core.Domain.Catalog;
|
||||
using Nop.Core.Domain.Customers;
|
||||
using Nop.Core.Domain.Orders;
|
||||
using Nop.Core.Events;
|
||||
using Nop.Data;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer.Interfaces;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||
|
||||
public class PreorderDbContext :
|
||||
IPreorderDbSet<PreorderDbTable>,
|
||||
IPreorderItemDbSet<PreorderItemDbTable>
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public PreorderDbTable Preorders { get; set; }
|
||||
public PreorderItemDbTable PreorderItems { get; set; }
|
||||
|
||||
// Read-only access to related NopCommerce repositories needed during conversion
|
||||
public IRepository<Customer> Customers { get; set; }
|
||||
public IRepository<Product> Products { get; set; }
|
||||
public IRepository<Order> Orders { get; set; }
|
||||
public IRepository<OrderItem> OrderItems { get; set; }
|
||||
|
||||
public PreorderDbContext(
|
||||
PreorderDbTable preorderDbTable,
|
||||
PreorderItemDbTable preorderItemDbTable,
|
||||
IRepository<Customer> customerRepository,
|
||||
IRepository<Product> productRepository,
|
||||
IRepository<Order> orderRepository,
|
||||
IRepository<OrderItem> orderItemRepository,
|
||||
IEnumerable<IAcLogWriterBase> logWriters)
|
||||
{
|
||||
Preorders = preorderDbTable;
|
||||
PreorderItems = preorderItemDbTable;
|
||||
Customers = customerRepository;
|
||||
Products = productRepository;
|
||||
Orders = orderRepository;
|
||||
OrderItems = orderItemRepository;
|
||||
_logger = new Logger<PreorderDbContext>(logWriters.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Insert a complete preorder with all its items in one operation.
|
||||
/// Returns the saved preorder (with Id populated).
|
||||
/// </summary>
|
||||
public async Task<Preorder> InsertPreorderAsync(Preorder preorder, IList<PreorderItem> items)
|
||||
{
|
||||
preorder.CreatedOnUtc = DateTime.UtcNow;
|
||||
preorder.UpdatedOnUtc = DateTime.UtcNow;
|
||||
preorder.Status = PreorderStatus.Pending;
|
||||
|
||||
await Preorders.InsertAsync(preorder);
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
item.PreorderId = preorder.Id;
|
||||
item.FulfilledQuantity = 0;
|
||||
item.Status = PreorderItemStatus.Pending;
|
||||
await PreorderItems.InsertAsync(item);
|
||||
}
|
||||
|
||||
_logger.Info($"PreorderDbContext: inserted Preorder #{preorder.Id} with {items.Count} items for customer #{preorder.CustomerId}");
|
||||
return preorder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all pending preorder items for a set of productIds, ordered by PreorderId (FCFS).
|
||||
/// Used by PreorderConversionService after IncomingQuantity is written.
|
||||
/// </summary>
|
||||
public async Task<List<PreorderItem>> GetPendingItemsForProductsAsync(IList<int> productIds)
|
||||
{
|
||||
// Fetch all items for these products first, then filter by status in memory
|
||||
// LinqToDB cannot translate enum comparisons to SQL in this codebase
|
||||
var all = await PreorderItems.Table
|
||||
.Where(i => productIds.Contains(i.ProductId))
|
||||
.OrderBy(i => i.PreorderId)
|
||||
.ToListAsync();
|
||||
|
||||
return all.Where(i =>
|
||||
i.Status == PreorderItemStatus.Pending ||
|
||||
i.Status == PreorderItemStatus.PartiallyFulfilled)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// After conversion: check if all items in a preorder are resolved and update the preorder's status.
|
||||
/// </summary>
|
||||
public async Task RefreshPreorderStatusAsync(int preorderId)
|
||||
{
|
||||
var preorder = await Preorders.GetByIdAsync(preorderId);
|
||||
if (preorder == null) return;
|
||||
|
||||
var items = await PreorderItems.GetAllByPreorderIdAsync(preorderId).ToListAsync();
|
||||
|
||||
var hasDropped = items.Any(i => i.Status == PreorderItemStatus.Dropped);
|
||||
var hasPartial = items.Any(i => i.Status == PreorderItemStatus.PartiallyFulfilled);
|
||||
var hasPending = items.Any(i => i.Status == PreorderItemStatus.Pending);
|
||||
var allFulfilled = items.All(i => i.Status == PreorderItemStatus.Fulfilled);
|
||||
|
||||
preorder.Status = (hasDropped || hasPartial) && !hasPending ? PreorderStatus.PartiallyFulfilled
|
||||
: allFulfilled ? PreorderStatus.Confirmed
|
||||
: PreorderStatus.Pending;
|
||||
|
||||
preorder.UpdatedOnUtc = DateTime.UtcNow;
|
||||
await Preorders.UpdateAsync(preorder);
|
||||
|
||||
_logger.Info($"PreorderDbContext: Preorder #{preorderId} status → {preorder.Status}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mark a preorder as cancelled (customer or admin action).
|
||||
/// </summary>
|
||||
public async Task CancelPreorderAsync(int preorderId)
|
||||
{
|
||||
var preorder = await Preorders.GetByIdAsync(preorderId);
|
||||
if (preorder == null) return;
|
||||
|
||||
preorder.Status = PreorderStatus.Cancelled;
|
||||
preorder.UpdatedOnUtc = DateTime.UtcNow;
|
||||
await Preorders.UpdateAsync(preorder);
|
||||
|
||||
var items = await PreorderItems.GetAllByPreorderIdAsync(preorderId).ToListAsync();
|
||||
var cancellableStatuses = new[] { PreorderItemStatus.Pending, PreorderItemStatus.PartiallyFulfilled };
|
||||
foreach (var item in items.Where(i => cancellableStatuses.Contains(i.Status)))
|
||||
{
|
||||
item.Status = PreorderItemStatus.Dropped;
|
||||
await PreorderItems.UpdateAsync(item);
|
||||
}
|
||||
|
||||
_logger.Info($"PreorderDbContext: Preorder #{preorderId} cancelled");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
using FruitBank.Common.Entities;
|
||||
using FruitBank.Common.Enums;
|
||||
using LinqToDB;
|
||||
using Mango.Nop.Core.Loggers;
|
||||
using Mango.Nop.Data.Repositories;
|
||||
using Nop.Core.Caching;
|
||||
using Nop.Core.Configuration;
|
||||
using Nop.Core.Events;
|
||||
using Nop.Data;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||
|
||||
public class PreorderDbTable : MgDbTableBase<Preorder>
|
||||
{
|
||||
public PreorderDbTable(
|
||||
IEventPublisher eventPublisher,
|
||||
INopDataProvider dataProvider,
|
||||
IShortTermCacheManager shortTermCacheManager,
|
||||
IStaticCacheManager staticCacheManager,
|
||||
AppSettings appSettings)
|
||||
: base(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings)
|
||||
{
|
||||
}
|
||||
|
||||
public IQueryable<Preorder> GetAll(bool loadRelations)
|
||||
{
|
||||
return loadRelations
|
||||
? GetAll()
|
||||
.LoadWith(p => p.PreorderItems)
|
||||
: GetAll();
|
||||
}
|
||||
|
||||
public Task<Preorder?> GetByIdAsync(int id, bool loadRelations)
|
||||
=> GetAll(loadRelations).FirstOrDefaultAsync(p => p.Id == id);
|
||||
|
||||
public IQueryable<Preorder> GetAllByCustomerIdAsync(int customerId, bool loadRelations)
|
||||
=> GetAll(loadRelations).Where(p => p.CustomerId == customerId);
|
||||
|
||||
public IQueryable<Preorder> GetAllPendingAsync(bool loadRelations)
|
||||
{
|
||||
var pendingStatuses = new[] { PreorderStatus.Pending, PreorderStatus.PartiallyFulfilled };
|
||||
return GetAll(loadRelations).Where(p => pendingStatuses.Contains(p.Status));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
using FruitBank.Common.Entities;
|
||||
using FruitBank.Common.Enums;
|
||||
using LinqToDB;
|
||||
using Mango.Nop.Core.Loggers;
|
||||
using Mango.Nop.Data.Repositories;
|
||||
using Nop.Core.Caching;
|
||||
using Nop.Core.Configuration;
|
||||
using Nop.Core.Events;
|
||||
using Nop.Data;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||
|
||||
public class PreorderItemDbTable : MgDbTableBase<PreorderItem>
|
||||
{
|
||||
public PreorderItemDbTable(
|
||||
IEventPublisher eventPublisher,
|
||||
INopDataProvider dataProvider,
|
||||
IShortTermCacheManager shortTermCacheManager,
|
||||
IStaticCacheManager staticCacheManager,
|
||||
AppSettings appSettings)
|
||||
: base(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings)
|
||||
{
|
||||
}
|
||||
|
||||
public IQueryable<PreorderItem> GetAllByPreorderIdAsync(int preorderId)
|
||||
=> GetAll().Where(i => i.PreorderId == preorderId);
|
||||
|
||||
public IQueryable<PreorderItem> GetAllByProductIdAsync(int productId)
|
||||
=> GetAll().Where(i => i.ProductId == productId);
|
||||
|
||||
/// <summary>
|
||||
/// All pending/partially-fulfilled items for a product, ordered by their parent preorder's
|
||||
/// CreatedOnUtc for first-come-first-served allocation.
|
||||
/// </summary>
|
||||
public IQueryable<PreorderItem> GetPendingByProductIdOrderedAsync(int productId)
|
||||
{
|
||||
var pendingStatuses = new[] { PreorderItemStatus.Pending, PreorderItemStatus.PartiallyFulfilled };
|
||||
return GetAll()
|
||||
.Where(i => i.ProductId == productId && pendingStatuses.Contains(i.Status))
|
||||
.OrderBy(i => i.PreorderId);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -331,7 +331,9 @@ public class MgOrderModelFactory<TOrderListModelExt, TOrderModelExt> : OrderMode
|
|||
public virtual async Task<TOrderListModelExt> PrepareOrderListModelExtendedAsync(OrderSearchModelExtended searchModel, Func<OrderListModel, TOrderModelExt, Task> dataItemCopiedCallback)
|
||||
{
|
||||
var customerCompany = searchModel.BillingCompany;
|
||||
var customer = await _customerService.GetCustomerByIdAsync(Convert.ToInt32(customerCompany));
|
||||
var customer = int.TryParse(customerCompany, out var customerId) && customerId > 0
|
||||
? await _customerService.GetCustomerByIdAsync(customerId)
|
||||
: null;
|
||||
//var customer = customers.FirstOrDefault(c => c.Company != null && c.Company.Equals(customerCompany, StringComparison.InvariantCultureIgnoreCase));
|
||||
//var customer = customers.FirstOrDefault(c => c.Company != null && c.Company.Equals(customerCompany, StringComparison.InvariantCultureIgnoreCase));
|
||||
OrderListModel prefiltered;
|
||||
|
|
|
|||
|
|
@ -1,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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ using Microsoft.AspNetCore.SignalR.Protocol;
|
|||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Nop.Core.Domain.Orders;
|
||||
using Nop.Core.Infrastructure;
|
||||
using Nop.Data;
|
||||
|
|
@ -89,6 +90,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>();
|
||||
|
|
@ -99,8 +104,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>();
|
||||
|
|
@ -127,9 +139,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:
|
||||
|
|
@ -138,6 +157,7 @@ public class PluginNopStartup : INopStartup
|
|||
|
||||
// Register the file storage service
|
||||
services.AddScoped<FileStorageService>();
|
||||
services.AddScoped<ICustomerCreditService, CustomerCreditService>();
|
||||
|
||||
services.AddControllersWithViews(options =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Language Name="English" IsDefault="false" IsRightToLeft="false">
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
Plugin Configuration Page — Plugins.FruitBankPlugin.Fields.*
|
||||
Import: Admin > Configuration > Languages > [English] > Import resources
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
|
||||
<!-- General -->
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.IsEnabled">
|
||||
<Value><![CDATA[Plugin enabled]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.MaxTokens">
|
||||
<Value><![CDATA[Max tokens]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.Temperature">
|
||||
<Value><![CDATA[Temperature]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.RequestTimeoutSeconds">
|
||||
<Value><![CDATA[Request timeout (seconds)]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Cerebras -->
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.CerebrasApiKey">
|
||||
<Value><![CDATA[Cerebras API key]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.CerebrasModelName">
|
||||
<Value><![CDATA[Cerebras model]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.CerebrasApiBaseUrl">
|
||||
<Value><![CDATA[Cerebras API base URL]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- OpenAI -->
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.OpenAIApiKey">
|
||||
<Value><![CDATA[OpenAI API key]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.OpenAIModelName">
|
||||
<Value><![CDATA[OpenAI model]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.OpenAIApiBaseUrl">
|
||||
<Value><![CDATA[OpenAI API base URL]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Z.ai GLM-OCR -->
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.ZaiApiKey">
|
||||
<Value><![CDATA[Z.ai API key]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.ZaiModel">
|
||||
<Value><![CDATA[Z.ai model]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
</Language>
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Language Name="Hungarian" IsDefault="false" IsRightToLeft="false">
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
Plugin konfigurációs oldal — Plugins.FruitBankPlugin.Fields.*
|
||||
Import: Admin > Konfiguráció > Nyelvek > [Magyar] > Erőforrások importálása
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
|
||||
<!-- Általános -->
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.IsEnabled">
|
||||
<Value><![CDATA[Plugin engedélyezve]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.MaxTokens">
|
||||
<Value><![CDATA[Maximum token szám]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.Temperature">
|
||||
<Value><![CDATA[Kreativitás (temperature)]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.RequestTimeoutSeconds">
|
||||
<Value><![CDATA[Időtúllépés (másodperc)]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Cerebras -->
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.CerebrasApiKey">
|
||||
<Value><![CDATA[Cerebras API kulcs]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.CerebrasModelName">
|
||||
<Value><![CDATA[Cerebras modell]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.CerebrasApiBaseUrl">
|
||||
<Value><![CDATA[Cerebras API alapcím]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- OpenAI -->
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.OpenAIApiKey">
|
||||
<Value><![CDATA[OpenAI API kulcs]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.OpenAIModelName">
|
||||
<Value><![CDATA[OpenAI modell]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.OpenAIApiBaseUrl">
|
||||
<Value><![CDATA[OpenAI API alapcím]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Z.ai GLM-OCR -->
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.ZaiApiKey">
|
||||
<Value><![CDATA[Z.ai API kulcs]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.FruitBankPlugin.Fields.ZaiModel">
|
||||
<Value><![CDATA[Z.ai modell]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
</Language>
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Language Name="English" IsDefault="false" IsRightToLeft="false">
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
Customer Credit — Plugins.Misc.FruitBankPlugin.CustomerCredit.*
|
||||
Import: Admin > Configuration > Languages > [English] > Import resources
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
|
||||
<!-- Page -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle">
|
||||
<Value><![CDATA[Customer Credit Management]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.BackToCustomer">
|
||||
<Value><![CDATA[Back to customer]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.EditTitle">
|
||||
<Value><![CDATA[Set Credit Limit]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Summary cards -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit">
|
||||
<Value><![CDATA[Credit Limit]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimitHint">
|
||||
<Value><![CDATA[Set to 0 to block all orders. Leave the record absent to allow unlimited.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance">
|
||||
<Value><![CDATA[Outstanding Balance]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit">
|
||||
<Value><![CDATA[Remaining Credit]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited">
|
||||
<Value><![CDATA[Unlimited]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Form -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment">
|
||||
<Value><![CDATA[Notes]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.Save">
|
||||
<Value><![CDATA[Save]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- List page -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.CustomerName">
|
||||
<Value><![CDATA[Customer Name]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.CustomerEmail">
|
||||
<Value><![CDATA[Email]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Unpaid orders table -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.UnpaidOrdersTitle">
|
||||
<Value><![CDATA[Unpaid / Pending Orders]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.NoUnpaidOrders">
|
||||
<Value><![CDATA[No unpaid orders.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderId">
|
||||
<Value><![CDATA[Order #]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderDate">
|
||||
<Value><![CDATA[Date]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderTotal">
|
||||
<Value><![CDATA[Total]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderStatus">
|
||||
<Value><![CDATA[Order Status]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.PaymentStatus">
|
||||
<Value><![CDATA[Payment Status]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.Total">
|
||||
<Value><![CDATA[Total]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Enforcement -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderBlocked">
|
||||
<Value><![CDATA[Your order cannot be placed because your outstanding balance has reached your credit limit. Please settle your existing balance first.]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
</Language>
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Language Name="Hungarian" IsDefault="false" IsRightToLeft="false">
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
Customer Credit — Plugins.Misc.FruitBankPlugin.CustomerCredit.*
|
||||
Import: Admin > Konfiguráció > Nyelvek > [Magyar] > Erőforrások importálása
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
|
||||
<!-- Oldal -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle">
|
||||
<Value><![CDATA[Ügyfél hitelkeret kezelés]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.BackToCustomer">
|
||||
<Value><![CDATA[Vissza az ügyfélhez]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.EditTitle">
|
||||
<Value><![CDATA[Hitelkeret beállítása]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Összefoglaló kártyák -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit">
|
||||
<Value><![CDATA[Hitelkeret]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimitHint">
|
||||
<Value><![CDATA[0 esetén minden rendelés le van tiltva. Ha nincs rekord, a limit korlátlan.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance">
|
||||
<Value><![CDATA[Kintlévő egyenleg]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit">
|
||||
<Value><![CDATA[Szabad keret]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited">
|
||||
<Value><![CDATA[Korlátlan]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Űrlap -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment">
|
||||
<Value><![CDATA[Megjegyzés]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.Save">
|
||||
<Value><![CDATA[Mentés]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Lista oldal -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.CustomerName">
|
||||
<Value><![CDATA[Ügyfél neve]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.CustomerEmail">
|
||||
<Value><![CDATA[Email]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Kifizetetlen rendelések táblázat -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.UnpaidOrdersTitle">
|
||||
<Value><![CDATA[Kifizetetlen / függő rendelések]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.NoUnpaidOrders">
|
||||
<Value><![CDATA[Nincs kifizetetlen rendelés.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderId">
|
||||
<Value><![CDATA[Rendelés #]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderDate">
|
||||
<Value><![CDATA[Dátum]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderTotal">
|
||||
<Value><![CDATA[Összeg]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderStatus">
|
||||
<Value><![CDATA[Rendelés állapot]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.PaymentStatus">
|
||||
<Value><![CDATA[Fizetési állapot]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.Total">
|
||||
<Value><![CDATA[Összesen]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Hitelkeret túllépés hibaüzenet -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderBlocked">
|
||||
<Value><![CDATA[A rendelést nem lehet leadni, mert a kintlévő egyenlege elérte a hitelkeretét. Kérjük, először rendezze meglévő tartozását.]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
</Language>
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Language Name="English" IsDefault="false" IsRightToLeft="false">
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
Preorder page — Plugins.Misc.FruitBankPlugin.Preorder.*
|
||||
Import: Admin > Configuration > Languages > [English] > Import resources
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
|
||||
<!-- General -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.PageTitle">
|
||||
<Value><![CDATA[Preorder]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.MenuLabel">
|
||||
<Value><![CDATA[Preorder]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Delivery step -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Title">
|
||||
<Value><![CDATA[When do you want to receive your preorder?]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Subtitle">
|
||||
<Value><![CDATA[Choose a delivery day and time (we'll confirm availability)]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.DayLabel">
|
||||
<Value><![CDATA[Delivery day]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.TimeLabel">
|
||||
<Value><![CDATA[Delivery time]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.TimeHint">
|
||||
<Value><![CDATA[Choose an exact time]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ConfirmButton">
|
||||
<Value><![CDATA[Show available products]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ChangeLabel">
|
||||
<Value><![CDATA[Delivery:]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ChangeButton">
|
||||
<Value><![CDATA[Change]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Today">
|
||||
<Value><![CDATA[Today]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Tomorrow">
|
||||
<Value><![CDATA[Tomorrow]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Saving">
|
||||
<Value><![CDATA[Saving...]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Product list -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.InfoBanner">
|
||||
<Value><![CDATA[Preorders are wishes — we will confirm availability when the shipment arrives and notify you of any changes.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.LoadingProducts">
|
||||
<Value><![CDATA[Loading available products...]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NoProductsAvailable">
|
||||
<Value><![CDATA[No products are currently available for preorder. Please check back later.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.ProductsLabel">
|
||||
<Value><![CDATA[Available for preorder — set quantities:]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.MeasurableBadge">
|
||||
<Value><![CDATA[Requires weighing]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.PricePerPiece">
|
||||
<Value><![CDATA[Ft/pcs]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.PieceUnit">
|
||||
<Value><![CDATA[pcs]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.StockLabel">
|
||||
<Value><![CDATA[Incoming stock:]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Note and submit -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NoteLabel">
|
||||
<Value><![CDATA[Additional note (optional)]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NotePlaceholder">
|
||||
<Value><![CDATA[Any special requests or notes for this preorder...]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SelectionNone">
|
||||
<Value><![CDATA[No products selected yet]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SelectionItems">
|
||||
<Value><![CDATA[product(s) selected]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SubmitButton">
|
||||
<Value><![CDATA[Place preorder]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.Submitting">
|
||||
<Value><![CDATA[Placing preorder...]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Summary panel -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SummaryTitle">
|
||||
<Value><![CDATA[Your preorder]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SummaryEmpty">
|
||||
<Value><![CDATA[Set quantities above to build your preorder.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SummaryNote">
|
||||
<Value><![CDATA[Prices for weighed items will be finalised after measurement. Preorder quantities may change depending on actual shipment.]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Success -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SuccessTitle">
|
||||
<Value><![CDATA[Preorder placed!]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SuccessMessage">
|
||||
<Value><![CDATA[Your preorder #{0} has been received. We will notify you when the shipment arrives.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.BackToHome">
|
||||
<Value><![CDATA[Back to home]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Error messages -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.ErrorPrefix">
|
||||
<Value><![CDATA[Error: ]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NotLoggedIn">
|
||||
<Value><![CDATA[Not logged in]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NoItemsSelected">
|
||||
<Value><![CDATA[No items selected]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NoValidItems">
|
||||
<Value><![CDATA[No valid items in preorder]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NoDeliveryDateTimeProvided">
|
||||
<Value><![CDATA[No delivery date/time provided]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.InvalidDeliveryDateTime">
|
||||
<Value><![CDATA[Invalid delivery date/time format]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.PlacedSuccessfully">
|
||||
<Value><![CDATA[Preorder placed successfully]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
</Language>
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Language Name="Hungarian" IsDefault="false" IsRightToLeft="false">
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
Előrendelés oldal — Plugins.Misc.FruitBankPlugin.Preorder.*
|
||||
Import: Admin > Konfiguráció > Nyelvek > [Magyar] > Erőforrások importálása
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
|
||||
<!-- Általános -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.PageTitle">
|
||||
<Value><![CDATA[Előrendelés]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.MenuLabel">
|
||||
<Value><![CDATA[Előrendelés]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Szállítási időpont lépés -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Title">
|
||||
<Value><![CDATA[Mikor kéred a rendelést?]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Subtitle">
|
||||
<Value><![CDATA[Válassz szállítási napot és időpontot (az elérhetőséget megerősítjük)]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.DayLabel">
|
||||
<Value><![CDATA[Kívánt szállítási nap]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.TimeLabel">
|
||||
<Value><![CDATA[Kívánt időpont]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.TimeHint">
|
||||
<Value><![CDATA[Válassz pontos időpontot]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ConfirmButton">
|
||||
<Value><![CDATA[Elérhető termékek mutatása]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ChangeLabel">
|
||||
<Value><![CDATA[Szállítás:]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ChangeButton">
|
||||
<Value><![CDATA[Módosítás]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Today">
|
||||
<Value><![CDATA[Ma]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Tomorrow">
|
||||
<Value><![CDATA[Holnap]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Saving">
|
||||
<Value><![CDATA[Mentés...]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Terméklista -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.InfoBanner">
|
||||
<Value><![CDATA[Az előrendelés egy kívánságlista — az áruk megerősítése a szállítmány beérkezésekor történik, és az esetleges változásokról értesítünk.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.LoadingProducts">
|
||||
<Value><![CDATA[Elérhető termékek betöltése...]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NoProductsAvailable">
|
||||
<Value><![CDATA[Jelenleg nincs előrendelhető termék. Kérjük, látogass vissza később.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.ProductsLabel">
|
||||
<Value><![CDATA[Előrendelhető termékek — add meg a mennyiségeket:]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.MeasurableBadge">
|
||||
<Value><![CDATA[Súlymérést igényel]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.PricePerPiece">
|
||||
<Value><![CDATA[Ft/db]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.PieceUnit">
|
||||
<Value><![CDATA[db]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.StockLabel">
|
||||
<Value><![CDATA[Várható készlet:]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Megjegyzés és leadás -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NoteLabel">
|
||||
<Value><![CDATA[Megjegyzés (nem kötelező)]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NotePlaceholder">
|
||||
<Value><![CDATA[Esetleges megjegyzések az előrendeléssel kapcsolatban...]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SelectionNone">
|
||||
<Value><![CDATA[Még nincs kiválasztott termék]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SelectionItems">
|
||||
<Value><![CDATA[termék kiválasztva]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SubmitButton">
|
||||
<Value><![CDATA[Előrendelés leadása]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.Submitting">
|
||||
<Value><![CDATA[Előrendelés mentése...]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Összefoglaló panel -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SummaryTitle">
|
||||
<Value><![CDATA[Előrendelésed]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SummaryEmpty">
|
||||
<Value><![CDATA[Add meg a mennyiségeket a termékeknél.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SummaryNote">
|
||||
<Value><![CDATA[A súlymérést igénylő tételeknél az ár a mérés után véglegesedik. A mennyiségek a tényleges szállítmánytól függően változhatnak.]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Sikeres leadás -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SuccessTitle">
|
||||
<Value><![CDATA[Előrendelés leadva!]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.SuccessMessage">
|
||||
<Value><![CDATA[#{0} számú előrendelésed beérkezett. A szállítmány megerősítésekor értesítünk.]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.BackToHome">
|
||||
<Value><![CDATA[Vissza a főoldalra]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
<!-- Hibaüzenetek -->
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.ErrorPrefix">
|
||||
<Value><![CDATA[Hiba: ]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NotLoggedIn">
|
||||
<Value><![CDATA[Nincs bejelentkezve]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NoItemsSelected">
|
||||
<Value><![CDATA[Nincs kiválasztott termék]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NoValidItems">
|
||||
<Value><![CDATA[Nincs érvényes termék az előrendelésben]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.NoDeliveryDateTimeProvided">
|
||||
<Value><![CDATA[Nincs szállítási időpont megadva]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.InvalidDeliveryDateTime">
|
||||
<Value><![CDATA[Érvénytelen szállítási dátum/idő formátum]]></Value>
|
||||
</LocaleResource>
|
||||
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.Preorder.PlacedSuccessfully">
|
||||
<Value><![CDATA[Előrendelés sikeresen leadva]]></Value>
|
||||
</LocaleResource>
|
||||
|
||||
</Language>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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},
|
||||
};
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
using Nop.Web.Framework.Models;
|
||||
using Nop.Web.Framework.Mvc.ModelBinding;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Models;
|
||||
|
||||
public record CustomerCreditWidgetModel : BaseNopModel
|
||||
{
|
||||
public int CustomerId { get; set; }
|
||||
public bool HasCreditLimit { get; set; }
|
||||
|
||||
[NopResourceDisplayName("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit")]
|
||||
public decimal CreditLimit { get; set; }
|
||||
|
||||
[NopResourceDisplayName("Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance")]
|
||||
public decimal OutstandingBalance { get; set; }
|
||||
|
||||
[NopResourceDisplayName("Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit")]
|
||||
public decimal? RemainingCredit { get; set; }
|
||||
|
||||
public string? Comment { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
using System;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Models.Orders
|
||||
{
|
||||
/// <summary>
|
||||
/// Lightweight DTO returned by the FruitBankOrderList endpoint.
|
||||
/// Contains only what the grid needs – avoids serialising heavy OrderModel navigation properties.
|
||||
/// </summary>
|
||||
public record FruitBankOrderRowDto
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public string CustomOrderNumber { get; init; }
|
||||
public string CustomerCompany { get; init; }
|
||||
public int CustomerId { get; init; }
|
||||
|
||||
// FruitBank-specific fields
|
||||
public string InnvoiceTechId { get; init; }
|
||||
public bool IsAllOrderItemAvgWeightValid { get; init; }
|
||||
public bool IsMeasurable { get; init; }
|
||||
public int MeasuringStatus { get; init; }
|
||||
public string MeasuringStatusString { get; init; }
|
||||
public DateTime? DateOfReceipt { get; init; }
|
||||
|
||||
// NopCommerce order fields
|
||||
public int OrderStatusId { get; init; }
|
||||
public string OrderStatus { get; init; }
|
||||
public int PaymentStatusId { get; init; }
|
||||
public string PaymentStatus { get; init; }
|
||||
public int ShippingStatusId { get; init; }
|
||||
public string ShippingStatus { get; init; }
|
||||
public string StoreName { get; init; }
|
||||
public DateTime CreatedOn { get; init; }
|
||||
public string OrderTotal { get; init; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)*
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
using FruitBank.Common.Entities;
|
||||
using Nop.Core.Domain.Orders;
|
||||
using Nop.Core.Domain.Payments;
|
||||
using Nop.Data;
|
||||
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||
|
||||
public class CustomerCreditService : ICustomerCreditService
|
||||
{
|
||||
private readonly CustomerCreditDbTable _customerCreditDbTable;
|
||||
private readonly IRepository<Order> _orderRepository;
|
||||
|
||||
public CustomerCreditService(
|
||||
CustomerCreditDbTable customerCreditDbTable,
|
||||
IRepository<Order> orderRepository)
|
||||
{
|
||||
_customerCreditDbTable = customerCreditDbTable;
|
||||
_orderRepository = orderRepository;
|
||||
}
|
||||
|
||||
public Task<CustomerCredit?> GetByCustomerIdAsync(int customerId)
|
||||
=> _customerCreditDbTable.GetByCustomerIdAsync(customerId);
|
||||
|
||||
public async Task SaveAsync(CustomerCredit entity)
|
||||
{
|
||||
entity.UpdatedOnUtc = DateTime.UtcNow;
|
||||
|
||||
if (entity.Id <= 0)
|
||||
{
|
||||
entity.CreatedOnUtc = DateTime.UtcNow;
|
||||
await _customerCreditDbTable.InsertAsync(entity);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _customerCreditDbTable.UpdateAsync(entity);
|
||||
}
|
||||
}
|
||||
|
||||
public Task DeleteAsync(CustomerCredit entity)
|
||||
=> _customerCreditDbTable.DeleteAsync(entity);
|
||||
|
||||
public async Task<decimal> GetOutstandingBalanceAsync(int customerId)
|
||||
{
|
||||
return await _orderRepository.Table
|
||||
.Where(o =>
|
||||
o.CustomerId == customerId &&
|
||||
o.OrderStatusId != (int)OrderStatus.Cancelled &&
|
||||
(o.PaymentStatusId == (int)PaymentStatus.Pending ||
|
||||
o.PaymentStatusId == (int)PaymentStatus.PartiallyRefunded))
|
||||
.SumAsync(o => (decimal?)o.OrderTotal) ?? 0m;
|
||||
}
|
||||
|
||||
public async Task<decimal?> GetRemainingCreditAsync(int customerId)
|
||||
{
|
||||
var credit = await GetByCustomerIdAsync(customerId);
|
||||
if (credit == null) return null;
|
||||
|
||||
var outstanding = await GetOutstandingBalanceAsync(customerId);
|
||||
return credit.CreditLimit - outstanding;
|
||||
}
|
||||
|
||||
public async Task<bool> IsOrderAllowedAsync(int customerId, decimal newOrderTotal)
|
||||
{
|
||||
var credit = await GetByCustomerIdAsync(customerId);
|
||||
if (credit == null) return true;
|
||||
|
||||
var outstanding = await GetOutstandingBalanceAsync(customerId);
|
||||
return outstanding + newOrderTotal <= credit.CreditLimit;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
using FruitBank.Common.Interfaces;
|
||||
using FruitBank.Common.Enums;
|
||||
using FruitBank.Common.Interfaces;
|
||||
using FruitBank.Common.Server;
|
||||
using Mango.Nop.Core.Dtos;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
|
@ -8,6 +9,7 @@ using Nop.Core;
|
|||
using Nop.Core.Domain.Catalog;
|
||||
using Nop.Core.Domain.Common;
|
||||
using Nop.Core.Domain.Customers;
|
||||
using Nop.Core.Domain.Messages;
|
||||
using Nop.Core.Domain.Orders;
|
||||
using Nop.Core.Domain.Tax;
|
||||
using Nop.Core.Events;
|
||||
|
|
@ -20,11 +22,13 @@ using Nop.Services.Common;
|
|||
using Nop.Services.Customers;
|
||||
using Nop.Services.Events;
|
||||
using Nop.Services.Localization;
|
||||
using Nop.Services.Messages;
|
||||
using Nop.Services.Orders;
|
||||
using Nop.Services.Plugins;
|
||||
using Nop.Web.Framework.Events;
|
||||
using Nop.Web.Framework.Menu;
|
||||
using Nop.Web.Models.Sitemap;
|
||||
using NUglify.JavaScript.Syntax;
|
||||
using System.Linq;
|
||||
using System.Xml.Linq;
|
||||
|
||||
|
|
@ -47,6 +51,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
|
|||
private readonly FruitBankDbContext _dbContext;
|
||||
private readonly IAttributeParser<CustomerAttribute, CustomerAttributeValue> _attributeParser;
|
||||
private readonly ICustomerService _customerService;
|
||||
private readonly IWorkflowMessageService _workflowMessageService;
|
||||
private readonly FruitBankNotificationService _fruitBankNotificationService;
|
||||
|
||||
public EventConsumer(
|
||||
IGenericAttributeService genericAttributeService,
|
||||
|
|
@ -64,7 +70,9 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
|
|||
FruitBankAttributeService fruitBankAttributeService,
|
||||
FruitBankDbContext dbContext,
|
||||
IAttributeParser<CustomerAttribute, CustomerAttributeValue> attributeParser,
|
||||
ICustomerService customerService
|
||||
ICustomerService customerService,
|
||||
IWorkflowMessageService workflowMessageService,
|
||||
FruitBankNotificationService fruitBankNotificationService
|
||||
) : base(pluginManager)
|
||||
{
|
||||
_genericAttributeService = genericAttributeService;
|
||||
|
|
@ -82,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
|
||||
|
|
|
|||
|
|
@ -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ésed mérhető tételeket tartalmaz. A végleges ár a mérés után kerül megerősítésre.</p>"
|
||||
: string.Empty;
|
||||
|
||||
return await SendNotificationAsync(ORDER_STARTED_TEMPLATE_NAME, order, measurableNote);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends the "order audited" customer notification.
|
||||
/// For measurable orders, confirms that weights have been recorded and
|
||||
/// the final price is as shown on the order.
|
||||
/// Fires once when MeasuringStatus transitions to Audited.
|
||||
/// </summary>
|
||||
public async Task<int> SendOrderAuditedCustomerNotificationAsync(Order order, bool isMeasurable)
|
||||
{
|
||||
var measurableNote = isMeasurable
|
||||
? "<p>A mért tételek súlyait rögzítettük, a végleges ár a rendelésen feltüntetett összeg.</p>"
|
||||
: string.Empty;
|
||||
|
||||
return await SendNotificationAsync(ORDER_AUDITED_TEMPLATE_NAME, order, measurableNote);
|
||||
}
|
||||
|
||||
// ── shared core ─────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<int> SendNotificationAsync(string templateName, Order order, string measurableNote)
|
||||
{
|
||||
var store = await storeContext.GetCurrentStoreAsync();
|
||||
|
||||
var templates = await messageTemplateService.GetMessageTemplatesByNameAsync(templateName, store.Id);
|
||||
var messageTemplate = templates?.FirstOrDefault();
|
||||
|
||||
if (messageTemplate is null || !messageTemplate.IsActive)
|
||||
return 0;
|
||||
|
||||
var emailAccount = await emailAccountService.GetEmailAccountByIdAsync(messageTemplate.EmailAccountId)
|
||||
?? await emailAccountService.GetEmailAccountByIdAsync(emailAccountSettings.DefaultEmailAccountId);
|
||||
|
||||
var tokens = new List<Token>();
|
||||
await messageTokenProvider.AddStoreTokensAsync(tokens, store, emailAccount, order.CustomerLanguageId);
|
||||
await messageTokenProvider.AddOrderTokensAsync(tokens, order, order.CustomerLanguageId);
|
||||
|
||||
var customer = await customerService.GetCustomerByIdAsync(order.CustomerId);
|
||||
await messageTokenProvider.AddCustomerTokensAsync(tokens, customer);
|
||||
|
||||
tokens.Add(new Token("Order.MeasurableNote", measurableNote, true));
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
using FruitBank.Common.Entities;
|
||||
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
|
||||
|
||||
public interface ICustomerCreditService
|
||||
{
|
||||
/// <summary>Gets the credit record for a customer, or null if none exists (= unlimited).</summary>
|
||||
Task<CustomerCredit?> GetByCustomerIdAsync(int customerId);
|
||||
|
||||
/// <summary>Insert or update a customer credit record.</summary>
|
||||
Task SaveAsync(CustomerCredit entity);
|
||||
|
||||
/// <summary>Delete the credit record for a customer, restoring unlimited access.</summary>
|
||||
Task DeleteAsync(CustomerCredit entity);
|
||||
|
||||
/// <summary>
|
||||
/// Sum of OrderTotal for all pending/unpaid, non-cancelled orders for the customer.
|
||||
/// </summary>
|
||||
Task<decimal> GetOutstandingBalanceAsync(int customerId);
|
||||
|
||||
/// <summary>
|
||||
/// CreditLimit - OutstandingBalance. Returns null if no credit record exists (= unlimited).
|
||||
/// </summary>
|
||||
Task<decimal?> GetRemainingCreditAsync(int customerId);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the customer is allowed to place a new order with the given total.
|
||||
/// Rule: no credit record = always allowed.
|
||||
/// Otherwise: OutstandingBalance + newOrderTotal must be <= CreditLimit.
|
||||
/// </summary>
|
||||
Task<bool> IsOrderAllowedAsync(int customerId, decimal newOrderTotal);
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -822,14 +822,16 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
|
|||
"'products' (array of objects with the following fields: " +
|
||||
"'name' (string), " +
|
||||
"'quantity' (int - the number of cartons, boxes or packages), " +
|
||||
"'netWeight' (double - the net kilograms), " +
|
||||
"'grossWeight' (double - the gross kilograms)," +
|
||||
"'netWeight' (double - the net kilograms in European format, example: 1.372 kgs should be 1372,00 kgs), " +
|
||||
"'grossWeight' (double - the gross kilogramsin European format, example: 1.372 kgs should be 1372,00 kgs)," +
|
||||
"'unitCost (double - the unit price of the product on the document)'.\r \n \n" +
|
||||
"";
|
||||
|
||||
string systemPrompt = "You are an AI assistant of FRUITBANK that extracts text and structured data from images. " +
|
||||
"Carefully analyze the image content to extract all relevant information accurately. " +
|
||||
"Provide the extracted data in a well-formatted JSON structure as specified.";
|
||||
"Keep in mind, that all the information are in EU standards, so if you find '.' in numbers that is thousand separator, not decimatal point" +
|
||||
"Provide the extracted data in a well-formatted JSON structure as specified. POINT IN NUMBERS IS THOUSAND SEPARATOR." +
|
||||
"IMPORTANT: if you find point in numbers, return them without the point, as '.' is thousand separator, not decimal point. Example: 1.731 kgs is 1731 kgs, NOT 1,731 kgs.";
|
||||
|
||||
var payload = new
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
using Mango.Nop.Core.Loggers;
|
||||
using Nop.Services.Configuration;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
#nullable enable
|
||||
namespace Nop.Plugin.Misc.FruitBankPlugin.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Z.ai GLM-OCR service — szállítólevelek, rendelési dokumentumok strukturált szövegkinyerésére.
|
||||
/// Endpoint: POST https://api.z.ai/api/paas/v4/layout_parsing
|
||||
/// Konfiguráció: FruitBankSettings.ZaiApiKey (+ opcionális ZaiModel, default: "glm-ocr")
|
||||
///
|
||||
/// Output formátum: Markdown + HTML vegyes szöveg (md_results mező).
|
||||
/// A táblázatokat <table>/<thead>/<td> tagekben adja vissza — LLM-nek közvetlenül átadható.
|
||||
/// </summary>
|
||||
public class ZaiService
|
||||
{
|
||||
private const string LayoutParsingEndpoint = "https://api.z.ai/api/paas/v4/layout_parsing";
|
||||
private const string DefaultModel = "glm-ocr";
|
||||
|
||||
private readonly ISettingService _settingService;
|
||||
private readonly FruitBankSettings _settings;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<ZaiService> _logger;
|
||||
|
||||
public ZaiService(
|
||||
ISettingService settingService,
|
||||
HttpClient httpClient,
|
||||
ILogger<ZaiService> logger)
|
||||
{
|
||||
_settingService = settingService;
|
||||
_settings = _settingService.LoadSetting<FruitBankSettings>();
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private string ApiKey => _settings.ZaiApiKey
|
||||
?? throw new InvalidOperationException("ZAI API kulcs nincs konfigurálva (FruitBankSettings.ZaiApiKey).");
|
||||
|
||||
private string Model => string.IsNullOrWhiteSpace(_settings.ZaiModel)
|
||||
? DefaultModel
|
||||
: _settings.ZaiModel;
|
||||
|
||||
// ── Publikus API ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// OCR elemzés nyilvánosan elérhető URL alapján (kép vagy PDF).
|
||||
/// </summary>
|
||||
/// <param name="fileUrl">Nyilvánosan elérhető HTTP(S) URL. Kép: max 10 MB, PDF: max 50 MB / 100 oldal.</param>
|
||||
public async Task<ZaiOcrResult> AnalyzeUrlAsync(string fileUrl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileUrl))
|
||||
return ZaiOcrResult.Failure("A fileUrl paraméter üres.");
|
||||
|
||||
var body = JsonSerializer.Serialize(new { model = Model, file = fileUrl });
|
||||
return await CallApiAsync(body);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OCR elemzés memóriából (Stream).
|
||||
/// A stream tartalma base64-re konvertálódik, majd data URI-ként kerül az API-hoz.
|
||||
/// </summary>
|
||||
/// <param name="stream">Kép vagy PDF stream.</param>
|
||||
/// <param name="mimeType">MIME típus, pl. "image/jpeg", "application/pdf".</param>
|
||||
public async Task<ZaiOcrResult> AnalyzeStreamAsync(Stream stream, string mimeType)
|
||||
{
|
||||
if (stream == null || stream.Length == 0)
|
||||
return ZaiOcrResult.Failure("Az átadott stream üres.");
|
||||
|
||||
byte[] bytes;
|
||||
using (var ms = new MemoryStream())
|
||||
{
|
||||
await stream.CopyToAsync(ms);
|
||||
bytes = ms.ToArray();
|
||||
}
|
||||
|
||||
return await AnalyzeBase64Async(Convert.ToBase64String(bytes), mimeType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OCR elemzés base64 kódolt adat alapján.
|
||||
/// Ha az adat még nem tartalmazza a "data:" prefixet, automatikusan data URI-vá alakítja.
|
||||
/// </summary>
|
||||
/// <param name="base64Data">Nyers base64 vagy teljes data URI.</param>
|
||||
/// <param name="mimeType">MIME típus (csak nyers base64 esetén szükséges).</param>
|
||||
public async Task<ZaiOcrResult> AnalyzeBase64Async(string base64Data, string mimeType = "image/jpeg")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(base64Data))
|
||||
return ZaiOcrResult.Failure("A base64Data paraméter üres.");
|
||||
|
||||
var dataUri = base64Data.StartsWith("data:", StringComparison.OrdinalIgnoreCase)
|
||||
? base64Data
|
||||
: $"data:{mimeType};base64,{base64Data}";
|
||||
|
||||
var body = JsonSerializer.Serialize(new { model = Model, file = dataUri });
|
||||
return await CallApiAsync(body);
|
||||
}
|
||||
|
||||
// ── Segédmetódus: MIME típus meghatározása fájlnév alapján ──────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Fájlkiterjesztés alapján visszaadja a megfelelő MIME típust.
|
||||
/// </summary>
|
||||
public static string GetMimeType(string fileName)
|
||||
{
|
||||
var ext = Path.GetExtension(fileName).ToLowerInvariant();
|
||||
return ext switch
|
||||
{
|
||||
".pdf" => "application/pdf",
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
".tif" or ".tiff" => "image/tiff",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
|
||||
// ── Belső API hívás ──────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<ZaiOcrResult> CallApiAsync(string jsonBody)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, LayoutParsingEndpoint);
|
||||
request.Headers.Add("Authorization", $"Bearer {ApiKey}");
|
||||
request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.Error("ZAI API hiba {StatusCode}: {Body}", new Exception(responseBody));
|
||||
return ZaiOcrResult.Failure($"API hiba {(int)response.StatusCode}: {responseBody}");
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(responseBody);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var markdown = TryGetMarkdown(root);
|
||||
|
||||
// Token statisztika logolása (debug szinten, hogy ne spammelje a logot)
|
||||
if (root.TryGetProperty("usage", out var usage))
|
||||
{
|
||||
var prompt = usage.TryGetProperty("prompt_tokens", out var pt) ? pt.GetInt32() : 0;
|
||||
var completion = usage.TryGetProperty("completion_tokens", out var ct) ? ct.GetInt32() : 0;
|
||||
_logger.Debug("ZAI GLM-OCR token felhasználás: {Prompt} + {Completion} = {Total}" +$"{prompt}, {completion}, {prompt} + {completion}");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(markdown))
|
||||
{
|
||||
_logger.Warning("ZAI GLM-OCR: md_results mező üres. Raw válasz: {Body}", responseBody);
|
||||
return ZaiOcrResult.Failure("Az OCR eredmény üres (md_results mező hiányzik a válaszból).");
|
||||
}
|
||||
|
||||
_logger.Debug($"ZAI GLM-OCR sikeres, karakter { markdown.Length}");
|
||||
return ZaiOcrResult.Success(markdown, responseBody);
|
||||
}
|
||||
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
|
||||
{
|
||||
_logger.Error("ZAI GLM-OCR időtúllépés", ex);
|
||||
return ZaiOcrResult.Failure("Időtúllépés: a GLM-OCR API nem válaszolt időben. Nagy PDF-eknél növeld a HttpClient timeout-ját.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("ZAI GLM-OCR hívás kivétellel végződött", ex);
|
||||
return ZaiOcrResult.Failure($"Hálózati hiba: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Az md_results mezőt keresi elsőként (layout_parsing API), majd fallback-eket próbál
|
||||
/// a chat completion API formátumhoz — így a service toleráns az esetleges API verziókkal szemben.
|
||||
/// </summary>
|
||||
private static string TryGetMarkdown(JsonElement root)
|
||||
{
|
||||
// Elsődleges: layout_parsing endpoint saját mezője
|
||||
if (root.TryGetProperty("md_results", out var mdResults))
|
||||
{
|
||||
var val = mdResults.GetString();
|
||||
if (!string.IsNullOrEmpty(val)) return val;
|
||||
}
|
||||
|
||||
// Fallback 1: chat completion stílusú choices tömb
|
||||
if (root.TryGetProperty("choices", out var choices) &&
|
||||
choices.GetArrayLength() > 0 &&
|
||||
choices[0].TryGetProperty("message", out var msg) &&
|
||||
msg.TryGetProperty("content", out var content))
|
||||
{
|
||||
var val = content.GetString();
|
||||
if (!string.IsNullOrEmpty(val)) return val;
|
||||
}
|
||||
|
||||
// Fallback 2: egyszerű result mező
|
||||
if (root.TryGetProperty("result", out var result))
|
||||
{
|
||||
var val = result.GetString();
|
||||
if (!string.IsNullOrEmpty(val)) return val;
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Result record ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// A ZaiService által visszaadott OCR eredmény.
|
||||
/// </summary>
|
||||
public sealed class ZaiOcrResult
|
||||
{
|
||||
public bool IsSuccess { get; private init; }
|
||||
|
||||
/// <summary>
|
||||
/// A teljes dokumentum Markdown+HTML vegyes formátumban.
|
||||
/// Táblázatokat <table>/<th>/<td> tagek tartalmazzák — LLM promptba közvetlenül illeszthető.
|
||||
/// </summary>
|
||||
public string Markdown { get; private init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// A nyers JSON válasz (diagnosztikához / layout_details feldolgozáshoz).
|
||||
/// </summary>
|
||||
public string? RawResponse { get; private init; }
|
||||
|
||||
public string? ErrorMessage { get; private init; }
|
||||
|
||||
public static ZaiOcrResult Success(string markdown, string raw) =>
|
||||
new() { IsSuccess = true, Markdown = markdown, RawResponse = raw };
|
||||
|
||||
public static ZaiOcrResult Failure(string error) =>
|
||||
new() { IsSuccess = false, ErrorMessage = error };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
@model Nop.Plugin.Misc.FruitBankPlugin.Models.CustomerCreditWidgetModel
|
||||
|
||||
@{
|
||||
var remaining = Model.RemainingCredit;
|
||||
var statusClass = !Model.HasCreditLimit ? "text-muted"
|
||||
: remaining <= 0 ? "text-danger"
|
||||
: remaining < Model.CreditLimit * 0.2m ? "text-warning"
|
||||
: "text-success";
|
||||
}
|
||||
|
||||
<div class="card card-default">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-credit-card"></i>
|
||||
@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle")
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3">
|
||||
<label>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit")</label>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<span class="form-control-plaintext">
|
||||
@(Model.HasCreditLimit ? Model.CreditLimit.ToString("N0") + " Ft" : T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited").Text)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3">
|
||||
<label>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance")</label>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<span class="form-control-plaintext">
|
||||
@Model.OutstandingBalance.ToString("N0") Ft
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3">
|
||||
<label>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit")</label>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<span class="form-control-plaintext @statusClass">
|
||||
<strong>
|
||||
@if (!Model.HasCreditLimit)
|
||||
{
|
||||
@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited")
|
||||
}
|
||||
else
|
||||
{
|
||||
@(remaining!.Value.ToString("N0"))
|
||||
<span>Ft</span>
|
||||
}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Comment))
|
||||
{
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3">
|
||||
<label>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment")</label>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<span class="form-control-plaintext text-muted">@Model.Comment</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-md-9 offset-md-3">
|
||||
<a href="/Admin/CustomerCredit/Details/@Model.CustomerId" class="btn btn-default">
|
||||
<i class="fas fa-edit"></i>
|
||||
@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.EditTitle")
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,286 @@
|
|||
@using FruitBank.Common.Enums
|
||||
@using Nop.Plugin.Misc.FruitBankPlugin.Controllers
|
||||
@model List<CustomerPreorderController.CustomerPreorderRow>
|
||||
|
||||
@{
|
||||
Layout = "_ColumnsTwo";
|
||||
ViewBag.Title = "Előrendeléseim";
|
||||
}
|
||||
|
||||
<link rel="stylesheet" href="~/Plugins/Misc.FruitBankPlugin/css/quick-order.css" />
|
||||
|
||||
<div class="page account-page my-preorders-page">
|
||||
<div class="page-title">
|
||||
<h1>Előrendeléseim</h1>
|
||||
</div>
|
||||
<div class="page-body">
|
||||
|
||||
@if (!Model.Any())
|
||||
{
|
||||
<div class="no-data">
|
||||
<p>Még nem adtál le előrendelést.</p>
|
||||
<a href="@Url.Action("Index", "Order")" class="button-1">Rendelés indítása</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var preorder in Model)
|
||||
{
|
||||
var statusClass = preorder.Status switch
|
||||
{
|
||||
PreorderStatus.Confirmed => "po-status-confirmed",
|
||||
PreorderStatus.PartiallyFulfilled => "po-status-partial",
|
||||
PreorderStatus.Cancelled => "po-status-cancelled",
|
||||
_ => "po-status-pending"
|
||||
};
|
||||
var statusLabel = preorder.Status switch
|
||||
{
|
||||
PreorderStatus.Confirmed => "Megerősítve",
|
||||
PreorderStatus.PartiallyFulfilled => "Részben teljesítve",
|
||||
PreorderStatus.Cancelled => "Törölve / Lejárt",
|
||||
_ => "Függőben"
|
||||
};
|
||||
|
||||
<div class="po-customer-card">
|
||||
<div class="po-card-header">
|
||||
<div class="po-card-meta">
|
||||
<span class="po-card-id">#@preorder.PreorderId előrendelés</span>
|
||||
<span class="po-card-date">
|
||||
<i class="fa fa-calendar"></i>
|
||||
Kért szállítás: <strong>@preorder.DateOfReceipt.ToLocalTime().ToString("yyyy. MM. dd. HH:mm")</strong>
|
||||
</span>
|
||||
<span class="po-card-created">
|
||||
Leadva: @preorder.CreatedOnUtc.ToLocalTime().ToString("yyyy. MM. dd.")
|
||||
</span>
|
||||
</div>
|
||||
<div class="po-card-status-wrap">
|
||||
<span class="po-status-badge @statusClass">@statusLabel</span>
|
||||
@if (preorder.OrderId.HasValue)
|
||||
{
|
||||
<a href="@Url.RouteUrl("OrderDetails", new { orderId = preorder.OrderId })"
|
||||
class="po-order-link">
|
||||
<i class="fa fa-external-link"></i> Rendelés #@preorder.OrderId
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(preorder.CustomerNote))
|
||||
{
|
||||
<div class="po-card-note">
|
||||
<i class="fa fa-comment-o"></i>
|
||||
@preorder.CustomerNote
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="po-card-items">
|
||||
<table class="po-items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Termék</th>
|
||||
<th class="text-center">Kérve</th>
|
||||
<th class="text-center">Teljesítve</th>
|
||||
<th class="text-right">Egységár</th>
|
||||
<th class="text-center po-status-col">Állapot</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var item in preorder.Items)
|
||||
{
|
||||
var itemStatusLabel = item.Status switch
|
||||
{
|
||||
PreorderItemStatus.Fulfilled => "✓ Teljesítve",
|
||||
PreorderItemStatus.PartiallyFulfilled => "◑ Részben",
|
||||
PreorderItemStatus.Dropped => "✕ Ejtve",
|
||||
_ => "⏳ Vár"
|
||||
};
|
||||
var itemStatusClass = item.Status switch
|
||||
{
|
||||
PreorderItemStatus.Fulfilled => "item-fulfilled",
|
||||
PreorderItemStatus.PartiallyFulfilled => "item-partial",
|
||||
PreorderItemStatus.Dropped => "item-dropped",
|
||||
_ => "item-pending"
|
||||
};
|
||||
var unitPrice = item.IsMeasurable
|
||||
? "Súlymérés"
|
||||
: item.UnitPriceInclTax.ToString("N0") + " Ft/db";
|
||||
|
||||
<tr class="@itemStatusClass">
|
||||
<td>
|
||||
@item.ProductName
|
||||
@if (item.IsMeasurable)
|
||||
{
|
||||
<span class="measurable-tag" title="Súlymérést igényel">⚖️</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-center">@item.RequestedQuantity db</td>
|
||||
<td class="text-center">@item.FulfilledQuantity db</td>
|
||||
<td class="text-right">@unitPrice</td>
|
||||
<td class="text-center">
|
||||
<span class="item-status-label @itemStatusClass">@itemStatusLabel</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* ── Page ─────────────────────────────────────────────────────── */
|
||||
.my-preorders-page .page-title h1 {
|
||||
font-size: 24px;
|
||||
color: #1a3c22;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 48px 20px;
|
||||
color: #6b7c6e;
|
||||
}
|
||||
|
||||
.no-data p { margin-bottom: 16px; font-size: 15px; }
|
||||
|
||||
/* ── Preorder card ────────────────────────────────────────────── */
|
||||
.po-customer-card {
|
||||
background: #fff;
|
||||
border: 1px solid #dde8da;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.po-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
background: #f5f7f2;
|
||||
border-bottom: 1px solid #dde8da;
|
||||
}
|
||||
|
||||
.po-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
font-size: 13px;
|
||||
color: #6b7c6e;
|
||||
}
|
||||
|
||||
.po-card-id {
|
||||
font-weight: 700;
|
||||
color: #1a3c22;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.po-card-date strong { color: #1a3c22; }
|
||||
|
||||
.po-card-status-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.po-status-badge {
|
||||
display: inline-block;
|
||||
padding: 3px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.po-status-pending { background: #fff3cd; color: #856404; }
|
||||
.po-status-confirmed { background: #d4edda; color: #155724; }
|
||||
.po-status-partial { background: #fff8ee; color: #c87500; }
|
||||
.po-status-cancelled { background: #f8d7da; color: #721c24; }
|
||||
|
||||
.po-order-link {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #2d7a3a;
|
||||
text-decoration: none;
|
||||
border: 1px solid #2d7a3a;
|
||||
border-radius: 4px;
|
||||
padding: 2px 10px;
|
||||
}
|
||||
|
||||
.po-order-link:hover { background: #2d7a3a; color: #fff; }
|
||||
|
||||
/* Note */
|
||||
.po-card-note {
|
||||
padding: 10px 20px;
|
||||
font-size: 13px;
|
||||
color: #6b7c6e;
|
||||
background: #fffdf7;
|
||||
border-bottom: 1px solid #dde8da;
|
||||
}
|
||||
|
||||
.po-card-note .fa { margin-right: 6px; color: #f4a236; }
|
||||
|
||||
/* Items table */
|
||||
.po-card-items { padding: 0; }
|
||||
|
||||
.po-items-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.po-items-table th {
|
||||
padding: 8px 14px;
|
||||
background: #f0f4ee;
|
||||
color: #1a3c22;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
border-bottom: 1px solid #dde8da;
|
||||
}
|
||||
|
||||
.po-items-table td {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #f0f4ee;
|
||||
color: #2c3e2e;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.po-items-table tr:last-child td { border-bottom: none; }
|
||||
|
||||
.po-items-table tr.item-fulfilled { background: #f6fbf4; }
|
||||
.po-items-table tr.item-partial { background: #fffbf0; }
|
||||
.po-items-table tr.item-dropped { background: #fdf6f6; color: #999; }
|
||||
|
||||
.item-status-label {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.item-status-label.item-fulfilled { background: #d4edda; color: #155724; }
|
||||
.item-status-label.item-partial { background: #fff8ee; color: #c87500; }
|
||||
.item-status-label.item-dropped { background: #f8d7da; color: #721c24; }
|
||||
.item-status-label.item-pending { background: #fff3cd; color: #856404; }
|
||||
|
||||
.measurable-tag { margin-left: 4px; font-size: 13px; }
|
||||
|
||||
.text-center { text-align: center; }
|
||||
.text-right { text-align: right; }
|
||||
|
||||
@@media (max-width: 600px) {
|
||||
.po-status-col { display: none; }
|
||||
.po-items-table th:last-child,
|
||||
.po-items-table td:last-child { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<li class="customer-navigation-item @(Context.Request.Path.Value?.Contains("elorerendeles") == true ? "active" : "")">
|
||||
<a href="@Url.Action("List", "CustomerPreorder")">
|
||||
Előrendeléseim
|
||||
</a>
|
||||
</li>
|
||||
<li class="customer-navigation-item @(Context.Request.Path.Value?.Contains("segitseg") == true ? "active" : "")">
|
||||
<a href="@Url.Action("Index", "Help")">
|
||||
<i class="fa fa-question-circle" style="margin-right:5px;color:#2d7a3a;"></i> Segítség
|
||||
</a>
|
||||
</li>
|
||||
|
|
@ -0,0 +1,533 @@
|
|||
@{
|
||||
Layout = "_Root";
|
||||
ViewBag.Title = "Segítség";
|
||||
}
|
||||
|
||||
<style>
|
||||
.help-page {
|
||||
max-width: 780px;
|
||||
margin: 0 auto;
|
||||
padding: 0 0 60px;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
}
|
||||
|
||||
.help-hero {
|
||||
background: linear-gradient(135deg, #1a3c22 0%, #2d7a3a 100%);
|
||||
border-radius: 12px;
|
||||
padding: 36px 32px;
|
||||
margin-bottom: 36px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.help-hero-icon {
|
||||
font-size: 48px;
|
||||
opacity: 0.9;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.help-hero h1 {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.help-hero p {
|
||||
font-size: 15px;
|
||||
opacity: 0.85;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Section ─────────────────────────────────────────────── */
|
||||
.help-section {
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.help-section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1a3c22;
|
||||
border-left: 4px solid #2d7a3a;
|
||||
padding-left: 14px;
|
||||
margin-bottom: 18px;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
/* ── Step cards ──────────────────────────────────────────── */
|
||||
.help-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.help-step {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
background: #f5f7f2;
|
||||
border: 1px solid #dde8da;
|
||||
border-radius: 10px;
|
||||
padding: 16px 18px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.help-step-num {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: #2d7a3a;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.help-step-num.amber { background: #f4a236; color: #1a3c22; }
|
||||
|
||||
.help-step-body {}
|
||||
.help-step-title { font-weight: 700; color: #1a3c22; margin-bottom: 4px; font-size: 14px; }
|
||||
.help-step-desc { font-size: 13px; color: #4a5e4d; line-height: 1.6; }
|
||||
|
||||
/* ── Two-column flow cards ───────────────────────────────── */
|
||||
.help-flow-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.help-flow-card {
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
border: 2px solid;
|
||||
}
|
||||
|
||||
.help-flow-card.green {
|
||||
background: #eaf3de;
|
||||
border-color: #2d7a3a;
|
||||
}
|
||||
|
||||
.help-flow-card.amber {
|
||||
background: #fff8ee;
|
||||
border-color: #f4a236;
|
||||
}
|
||||
|
||||
.hfc-icon {
|
||||
font-size: 28px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.help-flow-card.green .hfc-icon { color: #2d7a3a; }
|
||||
.help-flow-card.amber .hfc-icon { color: #c87500; }
|
||||
|
||||
.hfc-title {
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.help-flow-card.green .hfc-title { color: #1a3c22; }
|
||||
.help-flow-card.amber .hfc-title { color: #7a4200; }
|
||||
|
||||
.hfc-when {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.help-flow-card.green .hfc-when { color: #2d7a3a; }
|
||||
.help-flow-card.amber .hfc-when { color: #c87500; }
|
||||
|
||||
.hfc-desc {
|
||||
font-size: 13px;
|
||||
color: #444;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.hfc-example {
|
||||
font-size: 12px;
|
||||
background: rgba(255,255,255,0.6);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
color: #555;
|
||||
line-height: 1.5;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Table ───────────────────────────────────────────────── */
|
||||
.help-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
.help-table th {
|
||||
background: #1a3c22;
|
||||
color: #fff;
|
||||
padding: 9px 13px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .4px;
|
||||
}
|
||||
|
||||
.help-table th:first-child { border-radius: 6px 0 0 0; }
|
||||
.help-table th:last-child { border-radius: 0 6px 0 0; }
|
||||
|
||||
.help-table td {
|
||||
padding: 10px 13px;
|
||||
border-bottom: 1px solid #dde8da;
|
||||
color: #2c3e2e;
|
||||
vertical-align: top;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.help-table tr:last-child td { border-bottom: none; }
|
||||
.help-table tr:nth-child(even) td { background: #f5f7f2; }
|
||||
|
||||
/* ── FAQ ─────────────────────────────────────────────────── */
|
||||
.help-faq { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.help-faq-item {
|
||||
border: 1px solid #dde8da;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.help-faq-q {
|
||||
width: 100%;
|
||||
background: #f5f7f2;
|
||||
border: none;
|
||||
padding: 13px 16px;
|
||||
text-align: left;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1a3c22;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.help-faq-q:hover { background: #eaf3de; }
|
||||
.help-faq-q .fa-chevron-down { margin-left: auto; font-size: 11px; color: #6b7c6e; transition: transform 0.2s; }
|
||||
.help-faq-q.open .fa-chevron-down { transform: rotate(180deg); }
|
||||
.help-faq-q .fa:first-child { color: #2d7a3a; }
|
||||
|
||||
.help-faq-a {
|
||||
display: none;
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
color: #444;
|
||||
line-height: 1.7;
|
||||
border-top: 1px solid #dde8da;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* ── Banner ──────────────────────────────────────────────── */
|
||||
.help-banner {
|
||||
background: #fff8ee;
|
||||
border: 1px solid #f4c87a;
|
||||
border-left: 4px solid #f4a236;
|
||||
border-radius: 8px;
|
||||
padding: 14px 18px;
|
||||
font-size: 13px;
|
||||
color: #7a4200;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
line-height: 1.6;
|
||||
margin: 14px 0;
|
||||
}
|
||||
.help-banner .fa { color: #f4a236; flex-shrink: 0; margin-top: 1px; font-size: 16px; }
|
||||
|
||||
.help-banner.green {
|
||||
background: #eaf3de;
|
||||
border-color: #a8d08d;
|
||||
border-left-color: #2d7a3a;
|
||||
color: #1a3c22;
|
||||
}
|
||||
.help-banner.green .fa { color: #2d7a3a; }
|
||||
|
||||
/* ── CTA ─────────────────────────────────────────────────── */
|
||||
.help-cta {
|
||||
background: linear-gradient(135deg, #1a3c22 0%, #2d7a3a 100%);
|
||||
border-radius: 12px;
|
||||
padding: 28px 32px;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.help-cta h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.help-cta p {
|
||||
font-size: 14px;
|
||||
opacity: 0.85;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.help-cta-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #f4a236;
|
||||
color: #1a3c22 !important;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
padding: 12px 28px;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
transition: background 0.18s;
|
||||
}
|
||||
.help-cta-btn:hover { background: #e8922a; }
|
||||
|
||||
@@media (max-width: 600px) {
|
||||
.help-hero { flex-direction: column; text-align: center; padding: 24px 20px; }
|
||||
.help-flow-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="help-page">
|
||||
|
||||
<!-- ── Hero ──────────────────────────────────────────────────────── -->
|
||||
<div class="help-hero">
|
||||
<div class="help-hero-icon"><i class="fa fa-question-circle"></i></div>
|
||||
<div>
|
||||
<h1>Hogyan rendeljek a FruitBankon?</h1>
|
||||
<p>Minden, amit a rendelési folyamatról tudni kell — egyszerűen elmagyarázva.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 1. A két rendelési mód ─────────────────────────────────────── -->
|
||||
<div class="help-section">
|
||||
<div class="help-section-title">A két rendelési mód</div>
|
||||
|
||||
<div class="help-flow-grid">
|
||||
<div class="help-flow-card green">
|
||||
<div class="hfc-icon"><i class="fa fa-shopping-basket"></i></div>
|
||||
<div class="hfc-title">Rendelés</div>
|
||||
<div class="hfc-when">Azonnali teljesítés</div>
|
||||
<div class="hfc-desc">A raktáron lévő árukból azonnal leadhatsz rendelést. A termékeket szabad szöveges keresővel vagy <strong>hangutasítással</strong> adhatod a kosárhoz.</div>
|
||||
<div class="hfc-example">Pl. „Narancs 100 doboz, alma 50 kg" — bemond vagy begépeled, a rendszer megtalálja a termékeket.</div>
|
||||
</div>
|
||||
<div class="help-flow-card amber">
|
||||
<div class="hfc-icon"><i class="fa fa-calendar-plus-o"></i></div>
|
||||
<div class="hfc-title">Előrendelés</div>
|
||||
<div class="hfc-when">Jövő heti áru</div>
|
||||
<div class="hfc-desc">Ha az áru még úton van (jövő héten érkezik), leadhatsz egy kívánságlistát. Amint megérkezik a szállítmány, <strong>automatikusan rendelés lesz belőle</strong> és e-mailben értesítünk.</div>
|
||||
<div class="hfc-example">Pl. Hétfőn rendeled a csütörtökön érkező narancsot — a rendszer feljegyzi és automatikusan intézi.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-banner">
|
||||
<i class="fa fa-info-circle"></i>
|
||||
<div>A rendszer <strong>automatikusan</strong> dönti el, melyik módot mutatja — nem kell manuálisan választani. A kiválasztott szállítási nap alapján azonnal jelzi, mire számíthatsz.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 2. Mikor melyik mód ─────────────────────────────────────────── -->
|
||||
<div class="help-section">
|
||||
<div class="help-section-title">Mikor melyik mód jelenik meg?</div>
|
||||
|
||||
<table class="help-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mai nap</th>
|
||||
<th>Kért szállítási nap</th>
|
||||
<th>Mód</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Hétfő / Kedd / Szerda</td>
|
||||
<td>Bármely nap</td>
|
||||
<td><strong style="color:#c87500;">Előrendelés</strong> — a heti áru még úton van</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Csütörtök / Péntek / Szombat / Vasárnap</td>
|
||||
<td>Következő héten (hétfő–szerda)</td>
|
||||
<td><strong style="color:#2d7a3a;">Rendelés</strong> — raktárkészletből azonnal</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Csütörtök / Péntek / Szombat / Vasárnap</td>
|
||||
<td>Ezen a héten (csütörtök–vasárnap)</td>
|
||||
<td><strong style="color:#2d7a3a;">Rendelés</strong> — az áru már megérkezett</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Bármely nap</td>
|
||||
<td>Jövő hét csütörtöktől</td>
|
||||
<td><strong style="color:#c87500;">Előrendelés</strong> — jövő heti szállítmányból</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- ── 3. Lépések ─────────────────────────────────────────────────── -->
|
||||
<div class="help-section">
|
||||
<div class="help-section-title">A rendelés menete lépésről lépésre</div>
|
||||
|
||||
<div class="help-steps">
|
||||
<div class="help-step">
|
||||
<div class="help-step-num">1</div>
|
||||
<div class="help-step-body">
|
||||
<div class="help-step-title">Válassz szállítási napot és időpontot</div>
|
||||
<div class="help-step-desc">Kattints a kívánt napra a naptárban, majd állítsd be a szállítási időpontot. A rendszer azonnal jelzi, hogy rendelés vagy előrendelés lesz-e belőle.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="help-step">
|
||||
<div class="help-step-num">2</div>
|
||||
<div class="help-step-body">
|
||||
<div class="help-step-title">Add meg a termékeket</div>
|
||||
<div class="help-step-desc"><strong>Rendelésnél:</strong> keress szöveggel (pl. „narancs 100") vagy nyomj a mikrofon gombra és mondd be hangosan. A rendszer megtalálja a termékeket és javasolja a mennyiséget.<br><strong>Előrendelésnél:</strong> a rendszer megmutatja az előrendelhető termékeket — csak add meg a kívánt mennyiségeket.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="help-step">
|
||||
<div class="help-step-num">3</div>
|
||||
<div class="help-step-body">
|
||||
<div class="help-step-title">Ellenőrizd a kosarat / összesítőt</div>
|
||||
<div class="help-step-desc">Jobb oldalon látod az összes hozzáadott terméket és a becsült összeget. A súlymérést igénylő tételeknél az ár a mérés után véglegesedik.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="help-step">
|
||||
<div class="help-step-num">4</div>
|
||||
<div class="help-step-body">
|
||||
<div class="help-step-title">Add le a rendelést</div>
|
||||
<div class="help-step-desc"><strong>Rendelésnél:</strong> kattints a „Tovább a pénztárhoz" gombra és erősítsd meg a rendelést.<br><strong>Előrendelésnél:</strong> kattints az „Előrendelés leadása" gombra. Visszaigazolást kapsz e-mailben, majd a szállítmány megérkezésekor értesítünk a végeredményről.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 4. Hangalapú rendelés ──────────────────────────────────────── -->
|
||||
<div class="help-section">
|
||||
<div class="help-section-title">Hangalapú rendelés — hogyan használd?</div>
|
||||
|
||||
<div class="help-banner green">
|
||||
<i class="fa fa-microphone"></i>
|
||||
<div>A hangalapú bevitel <strong>raktármunkások számára</strong> tervezett funkció — gyors és kézszabad rendelés mobilon, táblagépen egyaránt.</div>
|
||||
</div>
|
||||
|
||||
<div class="help-steps">
|
||||
<div class="help-step">
|
||||
<div class="help-step-num">1</div>
|
||||
<div class="help-step-body">
|
||||
<div class="help-step-title">Nyomj a mikrofon gombra</div>
|
||||
<div class="help-step-desc">A böngésző engedélyt kér a mikrofonhoz — engedélyezd. A rendszer automatikusan érzékeli, mikor kezdesz el és mikor fejezed be a beszédet.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="help-step">
|
||||
<div class="help-step-num">2</div>
|
||||
<div class="help-step-body">
|
||||
<div class="help-step-title">Mondd be a termékeket és a rekeszek számát</div>
|
||||
<div class="help-step-desc">Pl. „Narancs száz, alma ötven, banán harminc." Mondd határozottan, a termékek nevét és mennyiségét együtt. A rendszer automatikusan leáll, ha hallgatás érzékel.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="help-step">
|
||||
<div class="help-step-num">3</div>
|
||||
<div class="help-step-body">
|
||||
<div class="help-step-title">Ellenőrizd a találatokat</div>
|
||||
<div class="help-step-desc">A rendszer megjeleníti, mit értett. Ha valamit rosszul azonosított, állítsd be a mennyiséget kézzel, vagy keress rá szöveggel. Majd add a kosárhoz.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 5. Előrendelés részletei ──────────────────────────────────── -->
|
||||
<div class="help-section">
|
||||
<div class="help-section-title">Előrendelés — amit tudni kell</div>
|
||||
|
||||
<div class="help-faq">
|
||||
<div class="help-faq-item">
|
||||
<button class="help-faq-q" type="button">
|
||||
<i class="fa fa-circle" style="font-size:8px;"></i>
|
||||
Garantált az előrendelés teljesítése?
|
||||
<i class="fa fa-chevron-down"></i>
|
||||
</button>
|
||||
<div class="help-faq-a">
|
||||
Nem — az előrendelés egy <strong>kívánságlista</strong>, nem kötelező érvényű megrendelés. Ha a szállítmány nem hoz elegendő árut (pl. kevesebb érkezett a vártnál), a rendszer az érkezési sorrend alapján osztja el a készletet. Mindig értesítünk e-mailben, hogy miből mennyi teljesült.
|
||||
</div>
|
||||
</div>
|
||||
<div class="help-faq-item">
|
||||
<button class="help-faq-q" type="button">
|
||||
<i class="fa fa-circle" style="font-size:8px;"></i>
|
||||
Mi történik, ha csak részben teljesül?
|
||||
<i class="fa fa-chevron-down"></i>
|
||||
</button>
|
||||
<div class="help-faq-a">
|
||||
Automatikusan létrejön egy rendelés a teljesített tételekkel, és e-mailben értesítünk a részletekről — miből mennyi érkezett, és mi maradt ki. A kiesett tételek nem kerülnek automatikusan a következő szállítmányra.
|
||||
</div>
|
||||
</div>
|
||||
<div class="help-faq-item">
|
||||
<button class="help-faq-q" type="button">
|
||||
<i class="fa fa-circle" style="font-size:8px;"></i>
|
||||
Mikor jön létre a tényleges rendelés?
|
||||
<i class="fa fa-chevron-down"></i>
|
||||
</button>
|
||||
<div class="help-faq-a">
|
||||
Amint az adminisztrátor feldolgozza a szállítói dokumentumokat és rögzíti az érkező árut, a rendszer automatikusan létrehozza a rendelést. Ez általában a szállítást megelőző napon, szerdán vagy csütörtökön történik.
|
||||
</div>
|
||||
</div>
|
||||
<div class="help-faq-item">
|
||||
<button class="help-faq-q" type="button">
|
||||
<i class="fa fa-circle" style="font-size:8px;"></i>
|
||||
Módosíthatom az előrendelésemet?
|
||||
<i class="fa fa-chevron-down"></i>
|
||||
</button>
|
||||
<div class="help-faq-a">
|
||||
Az előrendelés módosítása jelenleg fejlesztés alatt van. Addig lépj kapcsolatba velünk telefonon vagy e-mailben, és segítünk a módosításban.
|
||||
</div>
|
||||
</div>
|
||||
<div class="help-faq-item">
|
||||
<button class="help-faq-q" type="button">
|
||||
<i class="fa fa-circle" style="font-size:8px;"></i>
|
||||
Hol látom az előrendeléseimet?
|
||||
<i class="fa fa-chevron-down"></i>
|
||||
</button>
|
||||
<div class="help-faq-a">
|
||||
A <a href="@Url.Action("List", "CustomerPreorder")" style="color:#2d7a3a;font-weight:600;">Saját fiók → Előrendeléseim</a> oldalon látod az összes leadott előrendelést, azok állapotát és a létrejött rendelésekre mutató hivatkozást.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── CTA ────────────────────────────────────────────────────────── -->
|
||||
<div class="help-cta">
|
||||
<h3>Készen állsz a rendelésre?</h3>
|
||||
<p>Válassz szállítási napot, és a rendszer vezet végig a folyamaton.</p>
|
||||
<a href="@Url.Action("Index", "Order")" class="help-cta-btn">
|
||||
<i class="fa fa-bolt"></i> Rendelés indítása
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script asp-location="Footer">
|
||||
$(function () {
|
||||
// FAQ accordion
|
||||
$('.help-faq-q').click(function () {
|
||||
var $a = $(this).next('.help-faq-a');
|
||||
var isOpen = $a.is(':visible');
|
||||
$('.help-faq-a').slideUp(180);
|
||||
$('.help-faq-q').removeClass('open');
|
||||
if (!isOpen) {
|
||||
$a.slideDown(180);
|
||||
$(this).addClass('open');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
<div class="form-group row">
|
||||
|
||||
<div class="col-12 col-md-7">
|
||||
<div class="col-12 col-md-9">
|
||||
<div class="card card-default mb-2">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-file-invoice"></i>
|
||||
|
|
@ -38,22 +38,27 @@
|
|||
</div>
|
||||
<hr />
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-2">
|
||||
<button type="button" class="btn btn-warning btn-block" data-toggle="modal" data-target="#allowRevisionModal">
|
||||
<i class="fa fa-redo"></i> Újramérés engedélyezése
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-2">
|
||||
<button type="button" class="btn btn-success btn-block" data-toggle="modal" data-target="#sendOrderEmailModal">
|
||||
<i class="fas fa-envelope"></i> Email küldése ügyfélnek
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button type="button" class="btn btn-primary btn-block" data-toggle="modal" data-target="#sendMessageModal">
|
||||
<i class="fas fa-paper-plane"></i> Üzenet küldése
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-2">
|
||||
<button type="button" class="btn btn-info btn-block" data-toggle="modal" data-target="#addOrderNoteModal">
|
||||
<i class="fas fa-sticky-note"></i> Jegyzet hozzáadása
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-2">
|
||||
<button type="button"
|
||||
class="btn btn-danger btn-block"
|
||||
data-toggle="modal"
|
||||
|
|
@ -70,7 +75,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-5">
|
||||
<div class="col-12 col-md-3">
|
||||
<div class="card card-default mb-3">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-file-invoice"></i>
|
||||
|
|
@ -107,6 +112,36 @@
|
|||
|
||||
</div>
|
||||
|
||||
<!-- Send Order Email Modal -->
|
||||
<div class="modal fade" id="sendOrderEmailModal" tabindex="-1" role="dialog" aria-labelledby="sendOrderEmailModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="sendOrderEmailModalLabel">
|
||||
<i class="fas fa-envelope"></i> Rendelési email küldése ügyfélnek
|
||||
</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Ez a gomb elküldi a jelenlegi rendelés összefoglalóját az ügyfél email címére a rendelésfeladási email sablon alapján.</p>
|
||||
<div id="sendOrderEmailStatus" class="alert" style="display: none; margin-top: 15px;">
|
||||
<i class="fas fa-info-circle"></i> <span id="sendOrderEmailStatusMessage"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">
|
||||
<i class="fa fa-times"></i> Mégse
|
||||
</button>
|
||||
<button type="button" id="sendOrderEmailBtn" class="btn btn-success">
|
||||
<i class="fas fa-envelope"></i> Email küldése
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Allow Revision Modal -->
|
||||
<div class="modal fade" id="allowRevisionModal" tabindex="-1" role="dialog" aria-labelledby="allowRevisionModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
|
|
@ -1244,6 +1279,54 @@
|
|||
statusDiv.show();
|
||||
}
|
||||
|
||||
// ========== SEND ORDER EMAIL TO CUSTOMER ==========
|
||||
|
||||
var sendOrderEmailUrl = '@Url.Action("SendOrderEmailToCustomer", "CustomOrder")';
|
||||
|
||||
$('#sendOrderEmailBtn').click(function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
var btn = $(this);
|
||||
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Küldés...');
|
||||
showSendOrderEmailStatus('Email küldése folyamatban...', 'info');
|
||||
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: sendOrderEmailUrl,
|
||||
data: {
|
||||
orderId: @Model.OrderId,
|
||||
__RequestVerificationToken: $('input[name="__RequestVerificationToken"]').val()
|
||||
},
|
||||
dataType: 'json',
|
||||
success: function(response) {
|
||||
btn.prop('disabled', false).html('<i class="fas fa-envelope"></i> Email küldése');
|
||||
if (response.success) {
|
||||
showSendOrderEmailStatus(response.message, 'success');
|
||||
setTimeout(function() { $('#sendOrderEmailModal').modal('hide'); }, 2000);
|
||||
} else {
|
||||
showSendOrderEmailStatus('Hiba: ' + (response.message || 'Ismeretlen hiba'), 'danger');
|
||||
}
|
||||
},
|
||||
error: function(xhr) {
|
||||
btn.prop('disabled', false).html('<i class="fas fa-envelope"></i> Email küldése');
|
||||
showSendOrderEmailStatus('Hiba: ' + (xhr.responseText || 'Szerver hiba'), 'danger');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function showSendOrderEmailStatus(message, type) {
|
||||
var statusDiv = $('#sendOrderEmailStatus');
|
||||
statusDiv.removeClass('alert-info alert-success alert-warning alert-danger').addClass('alert-' + type);
|
||||
$('#sendOrderEmailStatusMessage').text(message);
|
||||
statusDiv.show();
|
||||
}
|
||||
|
||||
$('#sendOrderEmailModal').on('hidden.bs.modal', function() {
|
||||
$('#sendOrderEmailStatus').hide();
|
||||
$('#sendOrderEmailBtn').prop('disabled', false).html('<i class="fas fa-envelope"></i> Email küldése');
|
||||
});
|
||||
|
||||
// Clear split order status when modal is closed
|
||||
$('#splitOrderModal').on('hidden.bs.modal', function () {
|
||||
$("#splitOrderStatus").hide();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,460 @@
|
|||
@using System.Text.Encodings.Web
|
||||
@{
|
||||
Layout = "_Root";
|
||||
ViewBag.Title = T("Plugins.Misc.FruitBankPlugin.Preorder.PageTitle").Text;
|
||||
}
|
||||
|
||||
<link rel="stylesheet" href="~/Plugins/Misc.FruitBankPlugin/css/quick-order.css" />
|
||||
<link rel="stylesheet" href="~/Plugins/Misc.FruitBankPlugin/css/preorder.css" />
|
||||
|
||||
<div class="quick-order-page">
|
||||
|
||||
<!-- ── STEP 1: Delivery date + time ─────────────────────────────────── -->
|
||||
<div id="deliveryStep" class="qo-delivery-step">
|
||||
<div class="ds-header">
|
||||
<i class="fa fa-calendar"></i>
|
||||
<div>
|
||||
<div class="ds-title">@T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Title")</div>
|
||||
<div class="ds-subtitle">@T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Subtitle")</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ds-body">
|
||||
<div class="ds-section-label">@T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.DayLabel")</div>
|
||||
<div class="ds-day-buttons" id="dayButtons"></div>
|
||||
|
||||
<div class="ds-section-label" style="margin-top:20px;">
|
||||
@T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.TimeLabel")
|
||||
</div>
|
||||
<div class="ds-time-wrapper">
|
||||
<input type="time" id="deliveryTimePicker" class="ds-time-input" value="08:00" min="05:00" max="22:00" />
|
||||
<span class="ds-time-hint">@T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.TimeHint")</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ds-footer">
|
||||
<button type="button" class="ds-confirm-btn" id="deliveryConfirmBtn" disabled>
|
||||
<i class="fa fa-arrow-right"></i> @T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ConfirmButton")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Delivery chip ────────────────────────────────────────────────── -->
|
||||
<div id="deliveryChip" class="qo-delivery-chip" style="display:none;">
|
||||
<i class="fa fa-calendar-check-o"></i>
|
||||
<span class="dc-label">@T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ChangeLabel")</span>
|
||||
<strong id="deliveryChipText"></strong>
|
||||
<button type="button" class="dc-change-btn" id="deliveryChangeBtn">
|
||||
<i class="fa fa-pencil"></i> @T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ChangeButton")
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── STEP 2: Product selection + submit ───────────────────────────── -->
|
||||
<div id="mainContent" style="display:none;">
|
||||
|
||||
<!-- Info banner -->
|
||||
<div class="po-info-banner">
|
||||
<i class="fa fa-info-circle"></i>
|
||||
@T("Plugins.Misc.FruitBankPlugin.Preorder.InfoBanner")
|
||||
</div>
|
||||
|
||||
<div class="qo-layout">
|
||||
|
||||
<!-- LEFT: product list + note + submit -->
|
||||
<div class="qo-products-panel">
|
||||
|
||||
<!-- Loading -->
|
||||
<div id="productsLoadingState" class="products-empty-state">
|
||||
<i class="fa fa-spinner fa-spin"></i>
|
||||
<p>@T("Plugins.Misc.FruitBankPlugin.Preorder.LoadingProducts")</p>
|
||||
</div>
|
||||
|
||||
<!-- No products -->
|
||||
<div id="noProductsCard" class="no-results-card" style="display:none;">
|
||||
<i class="fa fa-calendar-times-o"></i>
|
||||
<p>@T("Plugins.Misc.FruitBankPlugin.Preorder.NoProductsAvailable")</p>
|
||||
</div>
|
||||
|
||||
<!-- Product grid -->
|
||||
<div id="productSection" style="display:none;">
|
||||
<div class="matches-label">
|
||||
<i class="fa fa-cubes"></i>
|
||||
<span>@T("Plugins.Misc.FruitBankPlugin.Preorder.ProductsLabel")</span>
|
||||
</div>
|
||||
<div id="productGrid" class="product-grid"></div>
|
||||
|
||||
<!-- Customer note -->
|
||||
<div class="po-note-section">
|
||||
<label class="po-note-label" for="customerNote">
|
||||
<i class="fa fa-comment-o"></i>
|
||||
@T("Plugins.Misc.FruitBankPlugin.Preorder.NoteLabel")
|
||||
</label>
|
||||
<textarea id="customerNote" class="po-note-input"
|
||||
placeholder="@T("Plugins.Misc.FruitBankPlugin.Preorder.NotePlaceholder")"
|
||||
rows="3" maxlength="1000"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="po-submit-row">
|
||||
<div id="selectionSummary" class="po-selection-summary"></div>
|
||||
<button type="button" id="submitPreorderBtn" class="po-submit-btn" disabled>
|
||||
<i class="fa fa-paper-plane"></i>
|
||||
@T("Plugins.Misc.FruitBankPlugin.Preorder.SubmitButton")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: summary panel -->
|
||||
<div class="qo-cart-panel">
|
||||
<div class="qo-section-title">
|
||||
<i class="fa fa-list-ul"></i>
|
||||
@T("Plugins.Misc.FruitBankPlugin.Preorder.SummaryTitle")
|
||||
<span id="itemCountBadge" class="cart-count-badge">0</span>
|
||||
</div>
|
||||
|
||||
<div id="summaryEmpty" class="cart-empty">
|
||||
<i class="fa fa-list-ul"></i>
|
||||
<p>@T("Plugins.Misc.FruitBankPlugin.Preorder.SummaryEmpty")</p>
|
||||
</div>
|
||||
|
||||
<div id="summaryList" class="cart-items-list" style="display:none;"></div>
|
||||
|
||||
<div id="summaryNote" class="cart-total-row" style="display:none;">
|
||||
<div class="cart-total-note">
|
||||
<i class="fa fa-info-circle"></i>
|
||||
<small>@T("Plugins.Misc.FruitBankPlugin.Preorder.SummaryNote")</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── SUCCESS STATE ─────────────────────────────────────────────────── -->
|
||||
<div id="successState" style="display:none;" class="po-success-state">
|
||||
<div class="po-success-icon"><i class="fa fa-check-circle"></i></div>
|
||||
<h2>@T("Plugins.Misc.FruitBankPlugin.Preorder.SuccessTitle")</h2>
|
||||
<p id="successMessage"></p>
|
||||
<a href="@Url.RouteUrl("Homepage")" class="po-back-btn">
|
||||
<i class="fa fa-home"></i> @T("Plugins.Misc.FruitBankPlugin.Preorder.BackToHome")
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@Html.AntiForgeryToken()
|
||||
|
||||
<script asp-location="Footer">
|
||||
var poStr = {
|
||||
dsToday : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Today").Text))',
|
||||
dsTomorrow : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Tomorrow").Text))',
|
||||
dsSaving : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.Saving").Text))',
|
||||
dsConfirm : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.DeliveryStep.ConfirmButton").Text))',
|
||||
measurable : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.MeasurableBadge").Text))',
|
||||
pricePerPc : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.PricePerPiece").Text))',
|
||||
pieceUnit : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.PieceUnit").Text))',
|
||||
stockLabel : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.StockLabel").Text))',
|
||||
selNone : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.SelectionNone").Text))',
|
||||
selItems : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.SelectionItems").Text))',
|
||||
submitting : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.Submitting").Text))',
|
||||
successMsg : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.SuccessMessage").Text))',
|
||||
errorPfx : '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.ErrorPrefix").Text))',
|
||||
huDayNames : ['vas\u00e1rnap','h\u00e9tf\u0151','kedd','szerda','cs\u00fct\u00f6rt\u00f6k','p\u00e9ntek','szombat']
|
||||
};
|
||||
</script>
|
||||
|
||||
<script asp-location="Footer">
|
||||
var selectedDeliveryDate = null;
|
||||
var selectedDeliveryTime = null;
|
||||
var selectedDayLabel = null;
|
||||
var products = []; // loaded from server
|
||||
var quantities = {}; // productId → quantity (0 = not selected)
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────
|
||||
$(document).ready(function () {
|
||||
renderDayButtons();
|
||||
|
||||
$(document).on('click', '.ds-day-btn', function () {
|
||||
$('.ds-day-btn').removeClass('selected');
|
||||
$(this).addClass('selected');
|
||||
selectedDeliveryDate = $(this).data('date');
|
||||
selectedDayLabel = $(this).data('label');
|
||||
checkDeliveryReady();
|
||||
});
|
||||
|
||||
$('#deliveryTimePicker').on('input change', function () {
|
||||
selectedDeliveryTime = $(this).val() || null;
|
||||
checkDeliveryReady();
|
||||
});
|
||||
selectedDeliveryTime = $('#deliveryTimePicker').val() || null;
|
||||
|
||||
$('#deliveryConfirmBtn').click(confirmDelivery);
|
||||
|
||||
$('#deliveryChangeBtn').click(function () {
|
||||
$('#deliveryChip').hide();
|
||||
$('#mainContent').hide();
|
||||
$('#deliveryStep').show();
|
||||
});
|
||||
|
||||
$('#submitPreorderBtn').click(submitPreorder);
|
||||
|
||||
// Restore saved delivery datetime if revisiting
|
||||
$.ajax({
|
||||
url: '@Url.Action("GetDeliveryDateTime", "Preorder")',
|
||||
type: 'GET',
|
||||
success: function (result) {
|
||||
if (!result.success || !result.hasValue) return;
|
||||
selectedDeliveryDate = result.date;
|
||||
selectedDeliveryTime = result.time;
|
||||
var $btn = $('.ds-day-btn[data-date="' + result.date + '"]');
|
||||
if ($btn.length) {
|
||||
$btn.addClass('selected');
|
||||
selectedDayLabel = $btn.data('label');
|
||||
} else {
|
||||
selectedDayLabel = result.date;
|
||||
}
|
||||
$('#deliveryTimePicker').val(result.time);
|
||||
showMainContent();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Delivery step ─────────────────────────────────────────────────────────
|
||||
|
||||
function renderDayButtons() {
|
||||
var container = $('#dayButtons').empty();
|
||||
var today = new Date();
|
||||
for (var i = 0; i < 14; i++) { // 2-week window for preorders
|
||||
var d = new Date(today);
|
||||
d.setDate(today.getDate() + i);
|
||||
var iso = d.toISOString().split('T')[0];
|
||||
var dayName;
|
||||
if (i === 0) dayName = poStr.dsToday;
|
||||
else if (i === 1) dayName = poStr.dsTomorrow;
|
||||
else dayName = poStr.huDayNames[d.getDay()];
|
||||
var dateStr = (d.getMonth() + 1) + '. ' + d.getDate() + '.';
|
||||
var btn = $('<button type="button" class="ds-day-btn">')
|
||||
.attr('data-date', iso)
|
||||
.attr('data-label', dayName + ' ' + dateStr)
|
||||
.html('<span class="ds-day-name">' + dayName + '</span><span class="ds-day-date">' + dateStr + '</span>');
|
||||
container.append(btn);
|
||||
}
|
||||
}
|
||||
|
||||
function checkDeliveryReady() {
|
||||
$('#deliveryConfirmBtn').prop('disabled', !(selectedDeliveryDate && selectedDeliveryTime));
|
||||
}
|
||||
|
||||
function confirmDelivery() {
|
||||
var btn = $('#deliveryConfirmBtn');
|
||||
btn.prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> ' + poStr.dsSaving);
|
||||
|
||||
var deliveryDateTime = selectedDeliveryDate + 'T' + selectedDeliveryTime;
|
||||
$.ajax({
|
||||
url : '@Url.Action("SetDeliveryDateTime", "Preorder")',
|
||||
type: 'POST',
|
||||
data: {
|
||||
deliveryDateTime: deliveryDateTime,
|
||||
__RequestVerificationToken: $('input[name="__RequestVerificationToken"]').val()
|
||||
},
|
||||
success: function (result) {
|
||||
if (!result.success) {
|
||||
alert(poStr.errorPfx + (result.message || ''));
|
||||
btn.prop('disabled', false).html('<i class="fa fa-arrow-right"></i> ' + poStr.dsConfirm);
|
||||
return;
|
||||
}
|
||||
showMainContent();
|
||||
},
|
||||
error: function () {
|
||||
btn.prop('disabled', false).html('<i class="fa fa-arrow-right"></i> ' + poStr.dsConfirm);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showMainContent() {
|
||||
var chipText = selectedDayLabel + ' \u2014 ' + selectedDeliveryTime;
|
||||
$('#deliveryChipText').text(chipText);
|
||||
$('#deliveryStep').hide();
|
||||
$('#deliveryChip').show();
|
||||
$('#mainContent').show();
|
||||
loadProducts();
|
||||
}
|
||||
|
||||
// ── Products ──────────────────────────────────────────────────────────────
|
||||
|
||||
function loadProducts() {
|
||||
$('#productsLoadingState').show();
|
||||
$('#noProductsCard').hide();
|
||||
$('#productSection').hide();
|
||||
|
||||
$.ajax({
|
||||
url : '@Url.Action("GetAvailableProducts", "Preorder")',
|
||||
type: 'GET',
|
||||
success: function (result) {
|
||||
$('#productsLoadingState').hide();
|
||||
if (!result.success || !result.products || result.products.length === 0) {
|
||||
$('#noProductsCard').show();
|
||||
return;
|
||||
}
|
||||
products = result.products;
|
||||
quantities = {};
|
||||
renderProducts();
|
||||
$('#productSection').show();
|
||||
},
|
||||
error: function () {
|
||||
$('#productsLoadingState').hide();
|
||||
$('#noProductsCard').show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderProducts() {
|
||||
var grid = $('#productGrid').empty();
|
||||
$.each(products, function (_, p) {
|
||||
quantities[p.id] = quantities[p.id] || 0;
|
||||
|
||||
var priceHtml = p.isMeasurable
|
||||
? '<span class="measurable-badge"><i class="fa fa-balance-scale"></i> ' + poStr.measurable + '</span>'
|
||||
: (p.unitPrice > 0 ? '<span class="pm-price">' + fmt(p.unitPrice) + ' ' + poStr.pricePerPc + '</span>' : '');
|
||||
|
||||
var card = $('<div>').addClass('product-card po-product-card').attr('data-id', p.id);
|
||||
card.html(
|
||||
'<div class="pc-body">' +
|
||||
'<div class="pc-name"><i class="fa fa-cube"></i> ' + p.name + '</div>' +
|
||||
'<div class="pc-meta">' +
|
||||
'<span class="pc-stock">' + poStr.stockLabel + ' ' + p.stockQuantity + ' ' + poStr.pieceUnit + '</span>' +
|
||||
priceHtml +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="pc-actions">' +
|
||||
'<div class="qty-stepper">' +
|
||||
'<button type="button" class="qty-btn qty-minus" tabindex="-1"><i class="fa fa-minus"></i></button>' +
|
||||
'<input type="number" class="qty-input po-qty" value="0" min="0" max="' + p.stockQuantity + '">' +
|
||||
'<button type="button" class="qty-btn qty-plus" tabindex="-1"><i class="fa fa-plus"></i></button>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
|
||||
card.find('.qty-minus').click(function () {
|
||||
var inp = $(this).siblings('.qty-input');
|
||||
var val = parseInt(inp.val()) || 0;
|
||||
if (val > 0) { inp.val(val - 1); onQtyChange(p.id, val - 1, card); }
|
||||
});
|
||||
card.find('.qty-plus').click(function () {
|
||||
var inp = $(this).siblings('.qty-input');
|
||||
var val = parseInt(inp.val()) || 0;
|
||||
if (val < p.stockQuantity) { inp.val(val + 1); onQtyChange(p.id, val + 1, card); }
|
||||
});
|
||||
card.find('.qty-input').on('input change blur', function () {
|
||||
var val = parseInt($(this).val());
|
||||
if (isNaN(val) || val < 0) val = 0;
|
||||
if (val > p.stockQuantity) val = p.stockQuantity;
|
||||
$(this).val(val);
|
||||
onQtyChange(p.id, val, card);
|
||||
});
|
||||
|
||||
grid.append(card);
|
||||
});
|
||||
}
|
||||
|
||||
function onQtyChange(productId, qty, $card) {
|
||||
quantities[productId] = qty;
|
||||
// Highlight selected cards
|
||||
$card.toggleClass('po-selected', qty > 0);
|
||||
updateSummary();
|
||||
}
|
||||
|
||||
function updateSummary() {
|
||||
var selectedItems = products.filter(function (p) { return (quantities[p.id] || 0) > 0; });
|
||||
var count = selectedItems.length;
|
||||
|
||||
$('#itemCountBadge').text(count);
|
||||
$('#submitPreorderBtn').prop('disabled', count === 0);
|
||||
|
||||
// Selection summary text
|
||||
if (count === 0) {
|
||||
$('#selectionSummary').text(poStr.selNone);
|
||||
} else {
|
||||
$('#selectionSummary').text(count + ' ' + poStr.selItems);
|
||||
}
|
||||
|
||||
// Right panel
|
||||
if (count === 0) {
|
||||
$('#summaryEmpty').show();
|
||||
$('#summaryList, #summaryNote').hide();
|
||||
return;
|
||||
}
|
||||
$('#summaryEmpty').hide();
|
||||
$('#summaryList, #summaryNote').show();
|
||||
|
||||
var list = $('#summaryList').empty();
|
||||
var hasMeasurable = false;
|
||||
$.each(selectedItems, function (_, p) {
|
||||
var qty = quantities[p.id];
|
||||
var priceHtml = p.isMeasurable
|
||||
? '<span class="measurable-badge-sm"><i class="fa fa-balance-scale"></i></span>'
|
||||
: '<strong class="line-total">' + fmt(p.unitPrice * qty) + ' Ft</strong>';
|
||||
if (p.isMeasurable) hasMeasurable = true;
|
||||
|
||||
list.append(
|
||||
'<div class="cart-item">' +
|
||||
'<div class="ci-name">' + p.name + '</div>' +
|
||||
'<div class="ci-details">' +
|
||||
'<span class="ci-qty">' + qty + ' ' + poStr.pieceUnit + '</span>' +
|
||||
priceHtml +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
});
|
||||
|
||||
if (hasMeasurable) $('#summaryNote').show();
|
||||
else $('#summaryNote').hide();
|
||||
}
|
||||
|
||||
// ── Submit ────────────────────────────────────────────────────────────────
|
||||
|
||||
function submitPreorder() {
|
||||
var selectedItems = products.filter(function (p) { return (quantities[p.id] || 0) > 0; });
|
||||
if (!selectedItems.length) return;
|
||||
|
||||
var btn = $('#submitPreorderBtn');
|
||||
btn.prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> ' + poStr.submitting);
|
||||
|
||||
var payload = {
|
||||
deliveryDateTime : selectedDeliveryDate + 'T' + selectedDeliveryTime,
|
||||
customerNote : $('#customerNote').val().trim(),
|
||||
items : selectedItems.map(function (p) {
|
||||
return { productId: p.id, quantity: quantities[p.id] };
|
||||
})
|
||||
};
|
||||
|
||||
$.ajax({
|
||||
url : '@Url.Action("PlacePreorder", "Preorder")',
|
||||
type : 'POST',
|
||||
contentType: 'application/json',
|
||||
data : JSON.stringify(payload),
|
||||
headers : { 'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').val() },
|
||||
success : function (result) {
|
||||
if (result.success) {
|
||||
$('#mainContent, #deliveryChip').hide();
|
||||
$('#successMessage').text(poStr.successMsg.replace('{0}', result.preorderId));
|
||||
$('#successState').show();
|
||||
} else {
|
||||
alert(poStr.errorPfx + (result.message || ''));
|
||||
btn.prop('disabled', false)
|
||||
.html('<i class="fa fa-paper-plane"></i> @Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.SubmitButton").Text))');
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
alert(poStr.errorPfx);
|
||||
btn.prop('disabled', false)
|
||||
.html('<i class="fa fa-paper-plane"></i> @Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.Preorder.SubmitButton").Text))');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function fmt(val) {
|
||||
if (!val) return '—';
|
||||
return Math.round(val).toLocaleString('hu-HU');
|
||||
}
|
||||
</script>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
Loading…
Reference in New Issue