CustomerCredit, new order

This commit is contained in:
Adam 2026-03-27 17:14:40 +01:00
parent 8e1b3f2a5d
commit 51f546caec
26 changed files with 2335 additions and 38 deletions

View File

@ -1759,7 +1759,235 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
}
}
// ═══════════════════════════════════════════════════════════════════
// FruitBank Order Grid new server-side DataTables endpoint
// ═══════════════════════════════════════════════════════════════════
/// <summary>
/// Returns the new FruitBank order list view (replaces the default NopCommerce grid).
/// </summary>
[CheckPermission(StandardPermission.Orders.ORDERS_VIEW)]
public async Task<IActionResult> NewList(
List<int> orderStatuses = null,
List<int> paymentStatuses = null,
List<int> shippingStatuses = null)
{
var model = await _orderModelFactory.PrepareOrderSearchModelAsync(new OrderSearchModelExtended
{
OrderStatusIds = orderStatuses,
PaymentStatusIds = paymentStatuses,
ShippingStatusIds = shippingStatuses,
Length = 50,
AvailablePageSizes = "20,50,100,500",
SortColumn = "Id",
SortColumnDirection = "desc",
});
model.SetGridSort("Id", "desc");
model.SetGridPageSize(50, "20,50,100,500");
return View(
"~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Order/FruitBankOrderList.cshtml",
model);
}
/// <summary>
/// DataTables server-side endpoint for the FruitBank order grid.
/// Handles NopCommerce base filters + FruitBank-specific filters + per-column search + sort + pagination.
/// </summary>
[HttpPost]
[CheckPermission(StandardPermission.Orders.ORDERS_VIEW)]
public async Task<IActionResult> FruitBankOrderList()
{
// ── 1. Parse DataTables protocol params ────────────────────────
_ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1);
_ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0);
_ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 50 : Math.Min(length, 500);
// Sort column
_ = int.TryParse(Request.Form["order[0][column]"].FirstOrDefault(), out int sortColIdx);
var sortDir = Request.Form["order[0][dir]"].FirstOrDefault() ?? "desc";
var sortColName = Request.Form[$"columns[{sortColIdx}][data]"].FirstOrDefault() ?? "Id";
// Per-column search values keyed by column data-field name
var colSearch = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (int ci = 0; Request.Form.ContainsKey($"columns[{ci}][data]"); ci++)
{
var cData = Request.Form[$"columns[{ci}][data]"].FirstOrDefault();
var cVal = Request.Form[$"columns[{ci}][search][value]"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(cData) && !string.IsNullOrWhiteSpace(cVal))
colSearch[cData] = cVal.Trim();
}
// ── 2. Parse custom filter params ─────────────────────────────
DateTime? startDate = null, endDate = null;
if (DateTime.TryParse(Request.Form["StartDate"].FirstOrDefault(), out var sd)) startDate = sd;
if (DateTime.TryParse(Request.Form["EndDate"].FirstOrDefault(), out var ed)) endDate = ed;
var orderStatusIds = Request.Form["OrderStatusIds"]
.Where(v => int.TryParse(v, out _)).Select(int.Parse).Where(id => id > 0).ToList();
var paymentStatusIds = Request.Form["PaymentStatusIds"]
.Where(v => int.TryParse(v, out _)).Select(int.Parse).Where(id => id > 0).ToList();
var shippingStatusIds = Request.Form["ShippingStatusIds"]
.Where(v => int.TryParse(v, out _)).Select(int.Parse).Where(id => id > 0).ToList();
var billingCompany = Request.Form["BillingCompany"].FirstOrDefault(); // holds customer ID (string)
bool? isMeasurableFilter = null;
var imStr = Request.Form["IsMeasurable"].FirstOrDefault();
if (imStr == "true") isMeasurableFilter = true;
if (imStr == "false") isMeasurableFilter = false;
bool? hasInnvoiceFilter = null;
var hiStr = Request.Form["HasInnvoiceTechId"].FirstOrDefault();
if (hiStr == "true") hasInnvoiceFilter = true;
if (hiStr == "false") hasInnvoiceFilter = false;
// ── 3. Fetch data via factory (applies NopCommerce base filters)
// We ask for a large page so all matching records come back in one shot;
// FruitBank-specific filtering + pagination happen below in-process.
var searchModel = new OrderSearchModelExtended
{
StartDate = startDate,
EndDate = endDate,
OrderStatusIds = orderStatusIds.Any() ? orderStatusIds : null,
PaymentStatusIds = paymentStatusIds.Any() ? paymentStatusIds : null,
ShippingStatusIds = shippingStatusIds.Any() ? shippingStatusIds : null,
BillingCompany = billingCompany,
SortColumn = "Id",
SortColumnDirection = "desc"
};
// SetGridPageSize is the proper NopCommerce way to override Page/PageSize
searchModel.SetGridPageSize(5000, "5000");
OrderListModelExtended orderListModel;
try
{
orderListModel = await _orderModelFactory.PrepareOrderListModelExtendedAsync(searchModel);
}
catch (Exception ex)
{
_logger.Error($"FruitBankOrderList factory error: {ex.Message}", ex);
return Json(new { draw, recordsTotal = 0, recordsFiltered = 0, data = Array.Empty<object>() });
}
var rows = orderListModel.Data?.ToList() ?? new List<OrderModelExtended>();
int total = orderListModel.RecordsTotal;
// ── 4. Map to lightweight DTO ──────────────────────────────────
var dtos = rows.Select(o => new Nop.Plugin.Misc.FruitBankPlugin.Models.Orders.FruitBankOrderRowDto
{
Id = o.Id,
CustomOrderNumber = o.CustomOrderNumber,
CustomerCompany = o.CustomerCompany,
CustomerId = o.CustomerId,
InnvoiceTechId = o.InnvoiceTechId,
IsAllOrderItemAvgWeightValid = o.IsAllOrderItemAvgWeightValid,
IsMeasurable = o.IsMeasurable,
MeasuringStatus = (int)o.MeasuringStatus,
MeasuringStatusString = o.MeasuringStatusString,
DateOfReceipt = o.DateOfReceipt,
OrderStatusId = o.OrderStatusId,
OrderStatus = o.OrderStatus,
PaymentStatusId = o.PaymentStatusId,
PaymentStatus = o.PaymentStatus,
ShippingStatusId = o.ShippingStatusId,
ShippingStatus = o.ShippingStatus,
StoreName = o.StoreName,
CreatedOn = o.CreatedOn,
OrderTotal = o.OrderTotal
}).ToList();
// ── 5. Apply FruitBank-specific top-level filters ──────────────
if (isMeasurableFilter.HasValue)
dtos = dtos.Where(o => o.IsMeasurable == isMeasurableFilter.Value).ToList();
if (hasInnvoiceFilter.HasValue)
dtos = hasInnvoiceFilter.Value
? dtos.Where(o => !string.IsNullOrEmpty(o.InnvoiceTechId)).ToList()
: dtos.Where(o => string.IsNullOrEmpty(o.InnvoiceTechId)).ToList();
// ── 6. Apply per-column search ─────────────────────────────────
foreach (var (col, val) in colSearch)
{
dtos = col.ToLowerInvariant() switch
{
"customordernumber" => dtos.Where(o => o.CustomOrderNumber?.Contains(val, StringComparison.OrdinalIgnoreCase) == true).ToList(),
"customercompany" => dtos.Where(o => o.CustomerCompany?.Contains(val, StringComparison.OrdinalIgnoreCase) == true).ToList(),
"orderstatusid" => int.TryParse(val, out int osId) ? dtos.Where(o => o.OrderStatusId == osId).ToList() : dtos,
"measuringstatus" => int.TryParse(val, out int msId) ? dtos.Where(o => o.MeasuringStatus == msId).ToList() : dtos,
"ismeasurable" => bool.TryParse(val, out bool bm) ? dtos.Where(o => o.IsMeasurable == bm).ToList() : dtos,
// InnVoice column sends 'has' or 'none' strings
"innvoicetechid" => val == "has" ? dtos.Where(o => !string.IsNullOrEmpty(o.InnvoiceTechId)).ToList()
: val == "none" ? dtos.Where(o => string.IsNullOrEmpty(o.InnvoiceTechId)).ToList()
: dtos,
_ => dtos
};
}
int recordsFiltered = dtos.Count;
// ── 7. Sort ────────────────────────────────────────────────────
bool asc = sortDir == "asc";
dtos = sortColName.ToLowerInvariant() switch
{
"id" => asc ? dtos.OrderBy(o => o.Id).ToList() : dtos.OrderByDescending(o => o.Id).ToList(),
"customordernumber" => asc ? dtos.OrderBy(o => o.CustomOrderNumber).ToList() : dtos.OrderByDescending(o => o.CustomOrderNumber).ToList(),
"customercompany" => asc ? dtos.OrderBy(o => o.CustomerCompany).ToList() : dtos.OrderByDescending(o => o.CustomerCompany).ToList(),
"dateofreceipt" => asc ? dtos.OrderBy(o => o.DateOfReceipt ?? DateTime.MinValue).ToList() : dtos.OrderByDescending(o => o.DateOfReceipt ?? DateTime.MinValue).ToList(),
"createdon" => asc ? dtos.OrderBy(o => o.CreatedOn).ToList() : dtos.OrderByDescending(o => o.CreatedOn).ToList(),
"orderstatusid" => asc ? dtos.OrderBy(o => o.OrderStatusId).ToList() : dtos.OrderByDescending(o => o.OrderStatusId).ToList(),
"measuringstatus" => asc ? dtos.OrderBy(o => o.MeasuringStatus).ToList() : dtos.OrderByDescending(o => o.MeasuringStatus).ToList(),
_ => dtos.OrderByDescending(o => o.Id).ToList()
};
// ── 8. Paginate ────────────────────────────────────────────────
var page = dtos.Skip(start).Take(length).ToList();
return Json(new { draw, recordsTotal = total, recordsFiltered, data = page });
}
/// <summary>
/// Inline-edit save endpoint. Currently supports DateOfReceipt.
/// </summary>
[HttpPost]
[CheckPermission(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE)]
public async Task<IActionResult> UpdateOrderField(int orderId, string field, string value)
{
try
{
var order = await _orderService.GetOrderByIdAsync(orderId);
if (order == null)
return Json(new { success = false, error = "Rendelés nem található" });
switch (field?.ToUpperInvariant())
{
case "DATEOFRECEIPT":
if (string.IsNullOrWhiteSpace(value))
{
await _genericAttributeService.SaveAttributeAsync<DateTime?>(order, "DateOfReceipt", null);
return Json(new { success = true, displayValue = (string)null });
}
if (DateTime.TryParse(value, out var newDate))
{
await _genericAttributeService.SaveAttributeAsync(order, "DateOfReceipt", newDate);
return Json(new { success = true, displayValue = newDate.ToString("yyyy. MM. dd. HH:mm") });
}
return Json(new { success = false, error = "Érvénytelen dátum formátum" });
default:
return Json(new { success = false, error = $"Ismeretlen mező: {field}" });
}
}
catch (Exception ex)
{
_logger.Error($"UpdateOrderField error orderId={orderId} field={field}: {ex.Message}", ex);
return Json(new { success = false, error = ex.Message });
}
}
}
}

View File

@ -0,0 +1,285 @@
using FruitBank.Common.Entities;
using Microsoft.AspNetCore.Mvc;
using Nop.Core.Domain.Customers;
using Nop.Core.Domain.Orders;
using Nop.Core.Domain.Payments;
using Nop.Data;
using Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Plugin.Misc.FruitBankPlugin.Services;
using Nop.Services.Customers;
using Nop.Services.Localization;
using Nop.Services.Security;
using Nop.Web.Framework;
using Nop.Web.Framework.Controllers;
using Nop.Web.Framework.Mvc.Filters;
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers;
[AuthorizeAdmin]
[Area(AreaNames.ADMIN)]
public class CustomerCreditController : BasePluginController
{
private readonly ICustomerCreditService _customerCreditService;
private readonly ICustomerService _customerService;
private readonly IRepository<Order> _orderRepository;
private readonly IRepository<Customer> _customerRepository;
private readonly CustomerCreditDbTable _customerCreditDbTable;
private readonly IPermissionService _permissionService;
private readonly ILocalizationService _localizationService;
public CustomerCreditController(
ICustomerCreditService customerCreditService,
ICustomerService customerService,
IRepository<Order> orderRepository,
IRepository<Customer> customerRepository,
CustomerCreditDbTable customerCreditDbTable,
IPermissionService permissionService,
ILocalizationService localizationService)
{
_customerCreditService = customerCreditService;
_customerService = customerService;
_orderRepository = orderRepository;
_customerRepository = customerRepository;
_customerCreditDbTable = customerCreditDbTable;
_permissionService = permissionService;
_localizationService = localizationService;
}
// ── LIST PAGE ─────────────────────────────────────────────────────────────
[HttpGet]
[Route("Admin/CustomerCredit/List")]
public async Task<IActionResult> List()
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return AccessDeniedView();
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/CustomerCredit/List.cshtml");
}
// ── DATATABLES SERVER-SIDE ENDPOINT ──────────────────────────────────────
[HttpPost]
[Route("Admin/CustomerCredit/CustomerCreditList")]
public async Task<IActionResult> CustomerCreditList()
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { draw = 1, recordsTotal = 0, recordsFiltered = 0, data = Array.Empty<object>() });
_ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1);
_ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0);
_ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 25 : Math.Min(length, 500);
_ = int.TryParse(Request.Form["order[0][column]"].FirstOrDefault(), out int sortColIdx);
var sortDir = Request.Form["order[0][dir]"].FirstOrDefault() ?? "desc";
var sortColName = Request.Form[$"columns[{sortColIdx}][data]"].FirstOrDefault() ?? "OutstandingBalance";
var globalSearch = Request.Form["search[value]"].FirstOrDefault()?.Trim() ?? "";
// 1. Customers — single query
var customers = await _customerRepository.Table
.Where(c => !c.Deleted && c.Active && c.Email != null)
.Select(c => new { c.Id, c.Email, c.FirstName, c.LastName })
.ToListAsync();
// 2. Credit records — single query
var credits = await _customerCreditDbTable.GetAll().ToListAsync();
var creditByCustomer = credits.ToDictionary(x => x.CustomerId);
// 3. Outstanding balances — single grouped query, no N+1
var outstandingByCustomer = await _orderRepository.Table
.Where(o =>
o.OrderStatusId != (int)OrderStatus.Cancelled &&
(o.PaymentStatusId == (int)PaymentStatus.Pending ||
o.PaymentStatusId == (int)PaymentStatus.PartiallyRefunded))
.GroupBy(o => o.CustomerId)
.Select(g => new { CustomerId = g.Key, Total = g.Sum(o => (decimal?)o.OrderTotal) ?? 0m })
.ToListAsync();
var outstandingDict = outstandingByCustomer.ToDictionary(x => x.CustomerId, x => x.Total);
// 4. Build rows
var rows = customers.Select(c =>
{
creditByCustomer.TryGetValue(c.Id, out var credit);
outstandingDict.TryGetValue(c.Id, out var outstanding);
var hasLimit = credit != null;
var remaining = hasLimit ? credit!.CreditLimit - outstanding : (decimal?)null;
return new CustomerCreditListRow
{
CustomerId = c.Id,
CustomerEmail = c.Email ?? string.Empty,
CustomerName = $"{c.FirstName} {c.LastName}".Trim(),
HasCreditLimit = hasLimit,
CreditLimit = credit?.CreditLimit ?? 0m,
OutstandingBalance = outstanding,
RemainingCredit = remaining,
Comment = credit?.Comment
};
}).ToList();
int recordsTotal = rows.Count;
// 5. Global search
if (!string.IsNullOrWhiteSpace(globalSearch))
{
rows = rows.Where(r =>
r.CustomerName.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) ||
r.CustomerEmail.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) ||
(r.Comment?.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) ?? false)
).ToList();
}
int recordsFiltered = rows.Count;
// 6. Sort
bool asc = sortDir == "asc";
rows = sortColName.ToLowerInvariant() switch
{
"customername" => asc ? rows.OrderBy(r => r.CustomerName).ToList() : rows.OrderByDescending(r => r.CustomerName).ToList(),
"customeremail" => asc ? rows.OrderBy(r => r.CustomerEmail).ToList() : rows.OrderByDescending(r => r.CustomerEmail).ToList(),
"creditlimit" => asc ? rows.OrderBy(r => r.CreditLimit).ToList() : rows.OrderByDescending(r => r.CreditLimit).ToList(),
"outstandingbalance" => asc ? rows.OrderBy(r => r.OutstandingBalance).ToList() : rows.OrderByDescending(r => r.OutstandingBalance).ToList(),
"remainingcredit" => asc ? rows.OrderBy(r => r.RemainingCredit ?? decimal.MaxValue).ToList() : rows.OrderByDescending(r => r.RemainingCredit ?? decimal.MinValue).ToList(),
_ => rows.OrderByDescending(r => r.OutstandingBalance).ToList()
};
// 7. Paginate
var page = rows.Skip(start).Take(length).ToList();
return Json(new { draw, recordsTotal, recordsFiltered, data = page });
}
// ── INLINE EDIT: CREDIT LIMIT ─────────────────────────────────────────────
[HttpPost]
[Route("Admin/CustomerCredit/UpdateCreditLimit")]
public async Task<IActionResult> UpdateCreditLimit(int customerId, string? creditLimit, bool removeLimit, string? comment)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, error = "Access denied" });
try
{
var existing = await _customerCreditService.GetByCustomerIdAsync(customerId);
var outstanding = await _customerCreditService.GetOutstandingBalanceAsync(customerId);
// Empty input or explicit removeLimit flag → delete record = unlimited
if (removeLimit || string.IsNullOrWhiteSpace(creditLimit))
{
if (existing != null)
await _customerCreditService.DeleteAsync(existing);
return Json(new
{
success = true,
hasLimit = false,
creditLimit = (decimal?)null,
outstanding,
remaining = (decimal?)null
});
}
// Parse the value (JS sends invariant decimal)
if (!decimal.TryParse(creditLimit,
System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture,
out var limit) || limit < 0)
return Json(new { success = false, error = "Érvénytelen összeg" });
var entity = existing ?? new CustomerCredit { CustomerId = customerId };
entity.CreditLimit = limit;
if (comment != null) entity.Comment = comment;
await _customerCreditService.SaveAsync(entity);
return Json(new
{
success = true,
hasLimit = true,
creditLimit = limit,
outstanding,
remaining = limit - outstanding
});
}
catch (Exception ex)
{
return Json(new { success = false, error = ex.Message });
}
}
// ── DETAILS ───────────────────────────────────────────────────────────────
[HttpGet]
[Route("Admin/CustomerCredit/Details/{customerId:int}")]
public async Task<IActionResult> Details(int customerId)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return AccessDeniedView();
var customer = await _customerService.GetCustomerByIdAsync(customerId);
if (customer == null)
return NotFound();
var credit = await _customerCreditService.GetByCustomerIdAsync(customerId);
var outstanding = await _customerCreditService.GetOutstandingBalanceAsync(customerId);
var unpaidOrders = await _orderRepository.Table
.Where(o =>
o.CustomerId == customerId &&
o.OrderStatusId != (int)OrderStatus.Cancelled &&
(o.PaymentStatusId == (int)PaymentStatus.Pending ||
o.PaymentStatusId == (int)PaymentStatus.PartiallyRefunded))
.OrderByDescending(o => o.CreatedOnUtc)
.ToListAsync();
var model = new CustomerCreditModel
{
CustomerId = customerId,
CustomerEmail = customer.Email,
CustomerName = $"{customer.FirstName} {customer.LastName}".Trim(),
CreditId = credit?.Id ?? 0,
CreditLimit = credit?.CreditLimit ?? 0m,
Comment = credit?.Comment,
OutstandingBalance = outstanding,
RemainingCredit = credit != null ? credit.CreditLimit - outstanding : (decimal?)null,
HasCreditLimit = credit != null,
UnpaidOrders = unpaidOrders.Select(o => new CustomerCreditOrderRow
{
OrderId = o.Id,
OrderTotal = o.OrderTotal,
CreatedOnUtc = o.CreatedOnUtc,
OrderStatus = o.OrderStatus.ToString(),
PaymentStatus = o.PaymentStatus.ToString()
}).ToList()
};
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/CustomerCredit/Details.cshtml", model);
}
// ── SAVE (from Details page) ──────────────────────────────────────────────
[HttpPost]
[Route("Admin/CustomerCredit/Save")]
public async Task<IActionResult> Save(CustomerCreditModel model)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return AccessDeniedView();
if (!ModelState.IsValid)
return RedirectToAction("Details", new { customerId = model.CustomerId });
var entity = model.CreditId > 0
? await _customerCreditService.GetByCustomerIdAsync(model.CustomerId) ?? new CustomerCredit()
: new CustomerCredit();
entity.CustomerId = model.CustomerId;
entity.CreditLimit = model.CreditLimit;
entity.Comment = model.Comment;
await _customerCreditService.SaveAsync(entity);
return RedirectToAction("Details", new { customerId = model.CustomerId });
}
}

View File

@ -0,0 +1,13 @@
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models;
public class CustomerCreditListRow
{
public int CustomerId { get; set; }
public string CustomerEmail { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty;
public bool HasCreditLimit { get; set; }
public decimal CreditLimit { get; set; }
public decimal OutstandingBalance { get; set; }
public decimal? RemainingCredit { get; set; }
public string? Comment { get; set; }
}

View File

@ -0,0 +1,30 @@
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models;
public class CustomerCreditModel
{
public int CustomerId { get; set; }
public string CustomerEmail { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty;
// Credit record
public int CreditId { get; set; }
public decimal CreditLimit { get; set; }
public string? Comment { get; set; }
public bool HasCreditLimit { get; set; }
// Calculated
public decimal OutstandingBalance { get; set; }
public decimal? RemainingCredit { get; set; }
// Unpaid orders table
public List<CustomerCreditOrderRow> UnpaidOrders { get; set; } = new();
}
public class CustomerCreditOrderRow
{
public int OrderId { get; set; }
public decimal OrderTotal { get; set; }
public DateTime CreatedOnUtc { get; set; }
public string OrderStatus { get; set; } = string.Empty;
public string PaymentStatus { get; set; } = string.Empty;
}

View File

@ -0,0 +1,151 @@
@model Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models.CustomerCreditModel
@using Nop.Web.Framework.UI
@{
// Layout = "_FruitBankAdminLayout";
var remaining = Model.RemainingCredit;
var statusClass = !Model.HasCreditLimit ? "status-unlimited"
: remaining <= 0 ? "status-blocked"
: remaining < Model.CreditLimit * 0.2m ? "status-warning"
: "status-ok";
}
<style>
.credit-summary { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
.credit-card { flex: 1; min-width: 160px; background: #fff; border: 1px solid #e0e0e0; border-radius: 8px; padding: 1rem 1.25rem; }
.credit-card .label { font-size: 0.78rem; color: #666; margin-bottom: .3rem; }
.credit-card .value { font-size: 1.4rem; font-weight: 700; }
.status-ok .value { color: #2d7a3a; }
.status-warning .value { color: #f4a236; }
.status-blocked .value { color: #c0392b; }
.status-unlimited .value { color: #555; }
.back-link { margin-bottom: 1rem; display: inline-block; }
</style>
<a class="back-link" href="/Admin/Customer/Edit/@Model.CustomerId">
<i class="fa fa-arrow-left"></i> @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.BackToCustomer")
</a>
<div class="content-header clearfix">
<h1 class="pull-left">
@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle") — @Model.CustomerName (@Model.CustomerEmail)
</h1>
</div>
@* ── Summary cards ── *@
<div class="credit-summary">
<div class="credit-card">
<div class="label">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit")</div>
<div class="value">@(Model.HasCreditLimit ? Model.CreditLimit.ToString("N0") + " Ft" : "—")</div>
</div>
<div class="credit-card">
<div class="label">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance")</div>
<div class="value">@Model.OutstandingBalance.ToString("N0") Ft</div>
</div>
<div class="credit-card @statusClass">
<div class="label">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit")</div>
<div class="value">
@if (!Model.HasCreditLimit)
{
@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited")
}
else
{
@remaining!.Value.ToString("N0")
<span style="font-size:.9rem">Ft</span>
}
</div>
</div>
</div>
@* ── Edit form ── *@
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.EditTitle")</h3>
</div>
<div class="panel-body">
<form asp-action="Save" asp-controller="CustomerCredit" asp-area="Admin" method="post">
<input type="hidden" name="CustomerId" value="@Model.CustomerId" />
<input type="hidden" name="CreditId" value="@Model.CreditId" />
<div class="form-group row">
<div class="col-md-3 col-form-label">
<label>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit")</label>
</div>
<div class="col-md-9">
<input type="number" name="CreditLimit" value="@Model.CreditLimit" min="0" step="1000" class="form-control" style="max-width:240px" />
<small class="form-text text-muted">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimitHint")</small>
</div>
</div>
<div class="form-group row">
<div class="col-md-3 col-form-label">
<label>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment")</label>
</div>
<div class="col-md-9">
<textarea name="Comment" class="form-control" rows="3" style="max-width:480px">@Model.Comment</textarea>
</div>
</div>
<div class="form-group row">
<div class="col-md-9 offset-md-3">
<button type="submit" class="btn btn-primary">
<i class="fa fa-save"></i> @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Save")
</button>
</div>
</div>
</form>
</div>
</div>
@* ── Unpaid orders table ── *@
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.UnpaidOrdersTitle") (@Model.UnpaidOrders.Count)</h3>
</div>
<div class="panel-body">
@if (!Model.UnpaidOrders.Any())
{
<p class="text-muted">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.NoUnpaidOrders")</p>
}
else
{
<table class="table table-bordered table-hover">
<thead>
<tr>
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderId")</th>
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderDate")</th>
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderTotal")</th>
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderStatus")</th>
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.PaymentStatus")</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var o in Model.UnpaidOrders)
{
<tr>
<td>#@o.OrderId</td>
<td>@o.CreatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd HH:mm")</td>
<td><strong>@o.OrderTotal.ToString("N0") Ft</strong></td>
<td>@o.OrderStatus</td>
<td>@o.PaymentStatus</td>
<td>
<a href="/Admin/Order/Edit/@o.OrderId" class="btn btn-xs btn-default" target="_blank">
<i class="fa fa-external-link"></i>
</a>
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="2"><strong>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Total")</strong></td>
<td><strong>@Model.OutstandingBalance.ToString("N0") Ft</strong></td>
<td colspan="3"></td>
</tr>
</tfoot>
</table>
}
</div>
</div>

View File

@ -0,0 +1,200 @@
@{
ViewBag.PageTitle = "Hitelkeretek";
NopHtml.SetActiveMenuItemSystemName("CustomerCredit.List");
}
<div class="content-header clearfix">
<h1 class="float-left">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle")</h1>
</div>
<section class="content">
<div class="container-fluid">
<div class="card card-default">
<div class="card-body p-0">
@Html.AntiForgeryToken()
<table id="cc-grid" class="table table-bordered table-hover m-0" style="width:100%">
<thead>
<tr>
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CustomerName")</th>
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CustomerEmail")</th>
<th title="Kattintásra szerkeszthető — törléshez hagyd üresen">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit") <small class="text-muted">✏️</small></th>
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance")</th>
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit")</th>
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment")</th>
<th></th>
</tr>
</thead>
</table>
</div>
</div>
</div>
</section>
<style>
/* ── Editable credit limit cell ─────────────────────────────── */
#cc-grid tbody td.cc-editable {
cursor: pointer;
}
#cc-grid tbody td.cc-editable:hover {
background-color: #fff8e1;
}
#cc-grid tbody td.cc-editable input[type="number"] {
width: 130px;
font-size: 13px;
padding: 2px 6px;
border: 1px solid #80bdff;
border-radius: 3px;
}
/* ── Status colours ─────────────────────────────────────────── */
.cc-remaining-ok { color: #2d7a3a; font-weight: 600; }
.cc-remaining-warning { color: #e67e22; font-weight: 600; }
.cc-remaining-blocked { color: #c0392b; font-weight: 600; }
.cc-remaining-none { color: #888; }
/* ── Stripe + hover ─────────────────────────────────────────── */
#cc-grid tbody tr:nth-child(even) { background-color: #f9f9f9; }
#cc-grid tbody tr:hover { background-color: #eaf2ff; }
</style>
<script>
$(function () {
var _token = $('input[name="__RequestVerificationToken"]').val();
function fmt(val) {
if (val == null) return '—';
return Number(val).toLocaleString('hu-HU') + ' Ft';
}
function renderRemaining(row) {
if (!row.HasCreditLimit) return '<span class="cc-remaining-none">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited").Text</span>';
var r = row.RemainingCredit;
var cls = r <= 0 ? 'cc-remaining-blocked'
: r < row.CreditLimit * 0.2 ? 'cc-remaining-warning'
: 'cc-remaining-ok';
return '<span class="' + cls + '">' + fmt(r) + '</span>';
}
function renderCreditLimit(row) {
if (!row.HasCreditLimit) return '<span class="text-muted">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited").Text</span>';
return fmt(row.CreditLimit);
}
var table = $('#cc-grid').DataTable({
serverSide : true,
processing : true,
orderCellsTop: true,
pageLength : 25,
lengthMenu : [[10, 25, 50, 100], [10, 25, 50, 100]],
order : [[3, 'desc']],
language : {
processing : 'Betöltés...',
search : 'Keresés:',
lengthMenu : '_MENU_ sor/oldal',
info : '_START_ _END_ / _TOTAL_ ügyfél',
infoEmpty : '0 ügyfél',
infoFiltered : '(szűrve _MAX_-ból)',
paginate : { first: '««', previous: '«', next: '»', last: '»»' },
emptyTable : 'Nincs találat',
zeroRecords : 'Nincs találat'
},
ajax: {
url : '/Admin/CustomerCredit/CustomerCreditList',
type: 'POST',
data: function (d) {
d.__RequestVerificationToken = _token;
},
error: function (xhr) {
console.error('CustomerCreditList error:', xhr.status, xhr.responseText);
}
},
columns: [
/* 0 */ { data: 'CustomerName', name: 'CustomerName' },
/* 1 */ { data: 'CustomerEmail', name: 'CustomerEmail' },
/* 2 */ { data: 'CreditLimit', name: 'CreditLimit', className: 'cc-editable text-right',
render: function (d, t, row) { return renderCreditLimit(row); } },
/* 3 */ { data: 'OutstandingBalance', name: 'OutstandingBalance', className: 'text-right',
render: function (d) {
var color = d > 0 ? 'color:#c0392b;font-weight:600' : '';
return '<span style="' + color + '">' + fmt(d) + '</span>';
}},
/* 4 */ { data: 'RemainingCredit', name: 'RemainingCredit',
render: function (d, t, row) { return renderRemaining(row); } },
/* 5 */ { data: 'Comment', name: 'Comment', orderable: false,
render: function (d) { return d ? '<span class="text-muted">' + d + '</span>' : ''; } },
/* 6 */ { data: 'CustomerId', name: null, orderable: false, searchable: false, width: '80px', className: 'text-center',
render: function (d) {
return '<a href="/Admin/CustomerCredit/Details/' + d + '" class="btn btn-default btn-xs" title="Részletek"><i class="fas fa-edit"></i></a>' +
' <a href="/Admin/Customer/Edit/' + d + '" class="btn btn-default btn-xs" title="Ügyfél szerkesztése"><i class="fas fa-user"></i></a>';
}}
]
});
/* ── Inline editing: CreditLimit ─────────────────────────────── */
$(document).on('click', '#cc-grid tbody td.cc-editable', function () {
var $td = $(this);
if ($td.find('input').length) return;
var $row = $td.closest('tr');
var rowData = table.row($row).data();
if (!rowData) return;
var savedHtml = $td.html();
var current = rowData.HasCreditLimit ? rowData.CreditLimit : '';
var $inp = $('<input type="number" min="0" step="1000" placeholder="Korlátlan (törléshez hagyd üresen)">')
.val(current)
.css({ width: '180px', fontSize: '13px' });
$td.html('').append($inp);
$inp.focus().select();
function restore() { $td.html(savedHtml); }
function persist() {
var raw = $inp.val().trim();
var removeLimit = raw === ''; // empty = remove limit → unlimited
var newVal = removeLimit ? null : parseFloat(raw);
// If a number was typed but is invalid or negative, cancel
if (!removeLimit && (isNaN(newVal) || newVal < 0)) { restore(); return; }
// No change: still has limit and same value
if (!removeLimit && rowData.HasCreditLimit && newVal === rowData.CreditLimit) { restore(); return; }
// No change: was already unlimited and still wants unlimited
if (removeLimit && !rowData.HasCreditLimit) { restore(); return; }
$.ajax({
url : '/Admin/CustomerCredit/UpdateCreditLimit',
type : 'POST',
data : {
__RequestVerificationToken : _token,
customerId : rowData.CustomerId,
creditLimit : removeLimit ? '' : newVal, // empty string signals "remove"
removeLimit : removeLimit,
comment : rowData.Comment || ''
},
success: function (res) {
if (res.success) {
rowData.CreditLimit = res.creditLimit;
rowData.OutstandingBalance = res.outstanding;
rowData.RemainingCredit = res.remaining;
rowData.HasCreditLimit = res.hasLimit;
table.row($row).data(rowData).invalidate().draw(false);
} else {
restore();
alert('Mentési hiba: ' + (res.error || 'Ismeretlen hiba'));
}
},
error: function () { restore(); }
});
}
$inp.on('blur', function () { persist(); });
$inp.on('keydown', function (e) {
if (e.key === 'Enter') { $inp.off('blur'); persist(); }
if (e.key === 'Escape') { $inp.off('blur'); restore(); }
});
});
});
</script>

View File

@ -0,0 +1,634 @@
@model Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models.Order.OrderSearchModelExtended
@using FruitBank.Common.Interfaces
@using Nop.Services.Stores
@using Nop.Web.Areas.Admin.Components
@using Nop.Web.Areas.Admin.Models.Orders
@using Nop.Web.Framework.Infrastructure
@inject IStoreService storeService
@{
// Layout = "~/Areas/Admin/Views/Shared/_LayoutAdmin.cshtml";
ViewBag.PageTitle = "FruitBank Rendelések";
NopHtml.SetActiveMenuItemSystemName("Orders");
}
@* ── Action buttons ─────────────────────────────────────────────── *@
<form id="fb-header-form" asp-controller="Order" asp-action="List" method="post">
<div class="content-header clearfix">
<h1 class="float-left">Rendelések</h1>
<div class="float-right">
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#create-order-window">
<i class="fas fa-plus"></i> @T("Admin.Common.AddNew")
</button>
<div class="btn-group">
<button type="button" class="btn btn-success"><i class="fas fa-download"></i> @T("Admin.Common.Export")</button>
<button type="button" class="btn btn-success dropdown-toggle dropdown-icon" data-toggle="dropdown"><span class="sr-only">&nbsp;</span></button>
<ul class="dropdown-menu" role="menu">
<li class="dropdown-item"><button asp-action="ExportXml" type="submit" name="exportxml-all"><i class="far fa-file-code"></i> @T("Admin.Common.ExportToXml.All")</button></li>
<li class="dropdown-item"><button type="button" id="exportxml-selected"><i class="far fa-file-code"></i> @T("Admin.Common.ExportToXml.Selected")</button></li>
<li class="dropdown-divider"></li>
<li class="dropdown-item"><button asp-action="ExportExcel" type="submit" name="exportexcel-all"><i class="far fa-file-excel"></i> @T("Admin.Common.ExportToExcel.All")</button></li>
<li class="dropdown-item"><button type="button" id="exportexcel-selected"><i class="far fa-file-excel"></i> @T("Admin.Common.ExportToExcel.Selected")</button></li>
</ul>
</div>
<div class="btn-group">
<button type="button" class="btn btn-info"><i class="far fa-file-pdf"></i> @T("Admin.Orders.PdfInvoices")</button>
<button type="button" class="btn btn-info dropdown-toggle dropdown-icon" data-toggle="dropdown"><span class="sr-only">&nbsp;</span></button>
<ul class="dropdown-menu" role="menu">
<li class="dropdown-item"><button asp-action="PdfInvoice" type="submit" name="pdf-invoice-all">@T("Admin.Orders.PdfInvoices.All")</button></li>
<li class="dropdown-item"><button type="button" id="pdf-invoice-selected">@T("Admin.Orders.PdfInvoices.Selected")</button></li>
</ul>
</div>
</div>
</div>
</form>
<section class="content">
<div class="container-fluid">
@* ── Filter Panel ─────────────────────────────────────────────── *@
<div class="card card-default card-search mb-2">
<div class="card-body py-2">
<div class="row align-items-end">
@* Date from *@
<div class="col-md-2">
<div class="form-group mb-1">
<nop-label asp-for="StartDate" />
<nop-editor asp-for="StartDate" />
</div>
</div>
@* Date to *@
<div class="col-md-2">
<div class="form-group mb-1">
<nop-label asp-for="EndDate" />
<nop-editor asp-for="EndDate" />
</div>
</div>
@* Partner autocomplete → stores customer ID in hidden *@
<div class="col-md-4">
<div class="form-group mb-1">
<label class="col-form-label">Partner</label>
<div class="input-group">
<input type="text" id="fb-company-display" autocomplete="off" class="form-control" placeholder="Cég neve..." />
<div class="input-group-append">
<button type="button" id="fb-company-clear" class="btn btn-outline-secondary" style="display:none">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<input asp-for="BillingCompany" type="hidden" id="BillingCompany" />
</div>
</div>
@* Go to order by number *@
<div class="col-md-3">
<div class="form-group mb-1">
<nop-label asp-for="GoDirectlyToCustomOrderNumber" />
<div class="input-group">
<nop-editor asp-for="GoDirectlyToCustomOrderNumber" />
<div class="input-group-append">
<button type="button" id="go-to-order-by-number" class="btn btn-info">
@T("Admin.Common.Go")
</button>
</div>
</div>
</div>
</div>
@* Search button *@
<div class="col-md-1">
<div class="form-group mb-1">
<label class="col-form-label">&nbsp;</label>
<button type="button" id="fb-search-btn" class="btn btn-primary btn-block">
<i class="fas fa-search"></i>
</button>
</div>
</div>
</div>
</div>
</div>
@* ── Grid ─────────────────────────────────────────────────────── *@
<div class="card card-default">
<div class="card-body p-0">
@* Anti-forgery token for AJAX POSTs *@
@Html.AntiForgeryToken()
<table id="fb-orders-grid" class="table table-bordered table-hover m-0" style="width:100%">
<thead>
<tr>
<th><input type="checkbox" id="fb-check-all" title="Összes kijelölése"></th>
<th>Rendelés #</th>
<th>Partner</th>
<th>InnVoice</th>
<th>Súly</th>
<th>Mérhető</th>
<th>Mérés</th>
<th title="Kattintásra szerkeszthető">Átvétel <small class="text-muted">✏️</small></th>
<th>Státusz</th>
<th>Fizetés</th>
<th>Szállítás</th>
<th>Létrehozva</th>
<th>Összeg</th>
<th></th>
</tr>
</thead>
</table>
</div>
<div id="fb-totals-row" class="card-footer py-2" style="display:none">
<div id="fb-totals-content" class="small text-muted"></div>
</div>
</div>
</div>
</section>
@* ── Export selected XML ──────────────────────────────────────── *@
<form asp-controller="Order" asp-action="ExportXmlSelected" method="post" id="export-xml-selected-form">
<input type="hidden" id="export-xml-ids" name="selectedIds" value="" />
</form>
@* ── Export selected Excel ────────────────────────────────────── *@
<form asp-controller="Order" asp-action="ExportExcelSelected" method="post" id="export-excel-selected-form">
<input type="hidden" id="export-excel-ids" name="selectedIds" value="" />
</form>
@* ── PDF selected ───────────────────────────────────────────────── *@
<form asp-controller="Order" asp-action="PdfInvoiceSelected" method="post" id="pdf-invoice-selected-form">
<input type="hidden" id="pdf-invoice-ids" name="selectedIds" value="" />
</form>
@* ── Create Order Modal ─────────────────────────────────────────── *@
<div id="create-order-window" class="modal fade" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">@T("Admin.Orders.AddNew")</h4>
<button type="button" class="close" data-dismiss="modal"><span>&times;</span></button>
</div>
<form asp-controller="CustomOrder" asp-action="Create" method="post" id="create-order-form">
<div class="form-horizontal">
<div class="modal-body">
<div class="form-group row">
<div class="col-md-3"><label class="col-form-label">@T("Admin.Orders.Fields.Customer")</label></div>
<div class="col-md-9">
<input type="text" id="create-order-customer-search" autocomplete="off" class="form-control" placeholder="Ügyfél neve vagy email..." />
<span id="create-order-customer-name" class="mt-1 d-block"></span>
<input type="hidden" id="create-order-customer-id" name="customerId" />
<span class="field-validation-error" id="create-order-customer-error" style="display:none">Kérjük válasszon ügyfelet</span>
</div>
</div>
<div class="form-group row" id="create-product-search-section" style="display:none">
<div class="col-md-3"><label class="col-form-label">@T("Admin.Orders.Fields.Product")</label></div>
<div class="col-md-9">
<input type="text" id="create-order-product-search" autocomplete="off" class="form-control" placeholder="Termék neve vagy SKU..." />
</div>
</div>
<div id="create-selected-products-section" style="display:none">
<table class="table table-sm table-bordered" id="create-products-table">
<thead><tr><th>Termék</th><th style="width:100px">Menny.</th><th style="width:120px">Egységár</th><th style="width:40px"></th></tr></thead>
<tbody id="create-products-body"></tbody>
</table>
</div>
<input type="hidden" id="create-order-products-json" name="orderProductsJson" />
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">@T("Admin.Common.Cancel")</button>
<button type="submit" class="btn btn-primary">@T("Admin.Common.Create")</button>
</div>
</div>
</form>
</div>
</div>
</div>
<style>
/* ── Column filter row ──────────────────────────────────────── */
#fb-orders-grid thead tr.fb-filter-row th {
padding: 3px 4px;
background-color: #f4f6f9;
border-bottom: 2px solid #dee2e6;
}
#fb-orders-grid thead tr.fb-filter-row input,
#fb-orders-grid thead tr.fb-filter-row select {
width: 100%;
height: 26px;
padding: 1px 4px;
font-size: 11px;
border: 1px solid #ced4da;
border-radius: 3px;
background: #fff;
}
#fb-orders-grid thead tr.fb-filter-row input:focus,
#fb-orders-grid thead tr.fb-filter-row select:focus {
outline: none;
border-color: #80bdff;
box-shadow: 0 0 0 2px rgba(0,123,255,.15);
}
/* ── Editable date cell ────────────────────────────────────── */
#fb-orders-grid tbody td.fb-editable-date {
cursor: pointer;
}
#fb-orders-grid tbody td.fb-editable-date:hover {
background-color: #fff8e1;
}
#fb-orders-grid tbody td.fb-editable-date input[type="date"] {
width: 120px;
font-size: 12px;
padding: 1px 4px;
border: 1px solid #80bdff;
border-radius: 3px;
}
/* ── Stripe rows ───────────────────────────────────────────── */
#fb-orders-grid tbody tr:nth-child(even) {
background-color: #f9f9f9;
}
#fb-orders-grid tbody tr:hover {
background-color: #eaf2ff;
}
/* ── Processing overlay ────────────────────────────────────── */
#fb-orders-grid_processing {
background: rgba(255,255,255,0.85);
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 8px 16px;
font-size: 13px;
}
/* autocomplete z-index fix in modals */
.ui-autocomplete { z-index: 1060 !important; }
</style>
<script>
$(function () {
/* ── Helpers ─────────────────────────────────────────────────── */
var _token = $('input[name="__RequestVerificationToken"]').val();
function antiForgery(obj) {
obj['__RequestVerificationToken'] = _token;
return obj;
}
var selectedIds = [];
function getSelectedIds() {
selectedIds = [];
$('#fb-orders-grid tbody .fb-row-check:checked').each(function () {
selectedIds.push($(this).val());
});
return selectedIds;
}
/* ── Column renderers ────────────────────────────────────────── */
function renderInnvoice(data) {
return data
? '<span class="badge badge-success">Igen</span>'
: '<span class="badge badge-secondary">Nem</span>';
}
function renderWeightValid(data) {
return data
? '<span class="badge badge-success">OK</span>'
: '<span class="badge badge-danger font-weight-bold">!</span>';
}
function renderMeasurable(data) {
return data
? '<span class="badge badge-info">Igen</span>'
: '<span class="badge badge-light text-secondary">Nem</span>';
}
function renderMeasuringStatus(val, row) {
var map = { 10: 'warning', 20: 'primary', 30: 'success', 40: 'danger' };
var cls = map[val] || 'secondary';
var label = row.MeasuringStatusString || String(val);
return '<span class="badge badge-' + cls + '">' + label + '</span>';
}
function renderDateOfReceipt(data) {
if (!data) return '<span class="text-muted">—</span>';
var d = new Date(data);
var dateStr = d.toLocaleDateString('hu-HU');
var timeStr = d.toLocaleTimeString('hu-HU', { hour: '2-digit', minute: '2-digit' });
return '<span>' + dateStr + ' ' + timeStr + '</span>';
}
function renderOrderStatus(statusId, row) {
var map = { 10: 'warning', 20: 'primary', 30: 'success', 40: 'danger' };
var cls = map[statusId] || 'secondary';
var label = row.OrderStatus || String(statusId);
return '<span class="badge badge-' + cls + '">' + label + '</span>';
}
/* ── DataTables ──────────────────────────────────────────────── */
var table = $('#fb-orders-grid').DataTable({
serverSide : true,
processing : true,
orderCellsTop: true,
stateSave : false,
pageLength : 50,
lengthMenu : [[20, 50, 100, 200, 500], [20, 50, 100, 200, 500]],
order : [[0, 'desc']],
language : {
processing : 'Betöltés...',
search : 'Keresés:',
lengthMenu : '_MENU_ sor/oldal',
info : '_START_ _END_ / _TOTAL_ rendelés',
infoEmpty : '0 rendelés',
infoFiltered : '(szűrve _MAX_-ból)',
paginate : { first: '««', previous: '«', next: '»', last: '»»' },
emptyTable : 'Nincs találat',
zeroRecords : 'Nincs találat a szűrési feltételekre'
},
ajax: {
url : '@Url.Action("FruitBankOrderList", "CustomOrder")',
type: 'POST',
data: function (d) {
d.StartDate = $('#@Html.IdFor(m => m.StartDate)').val();
d.EndDate = $('#@Html.IdFor(m => m.EndDate)').val();
d.BillingCompany = $('#BillingCompany').val();
addAntiForgeryToken(d);
},
error: function (xhr) {
console.error('FruitBankOrderList AJAX error:', xhr.status, xhr.responseText);
}
},
columns: [
/* 0 */ { data: 'Id', name: 'Id', orderable: false, searchable: false, width: '32px',
className: 'text-center',
render: function (d) { return '<input type="checkbox" class="fb-row-check" value="' + d + '">'; } },
/* 1 */ { data: 'CustomOrderNumber', name: 'CustomOrderNumber', width: '95px' },
/* 2 */ { data: 'CustomerCompany', name: 'CustomerCompany' },
/* 3 */ { data: 'InnvoiceTechId', name: 'InnvoiceTechId', orderable: false, width: '75px',
className: 'text-center',
render: function (d) { return renderInnvoice(d); } },
/* 4 */ { data: 'IsAllOrderItemAvgWeightValid', name: 'IsAllOrderItemAvgWeightValid', orderable: false, width: '55px',
className: 'text-center',
render: function (d) { return renderWeightValid(d); } },
/* 5 */ { data: 'IsMeasurable', name: 'IsMeasurable', orderable: false, width: '65px',
className: 'text-center',
render: function (d) { return renderMeasurable(d); } },
/* 6 */ { data: 'MeasuringStatus', name: 'MeasuringStatus', width: '95px',
className: 'text-center',
render: function (d, t, row) { return renderMeasuringStatus(d, row); } },
/* 7 */ { data: 'DateOfReceipt', name: 'DateOfReceipt', width: '110px',
className: 'text-center fb-editable-date',
render: function (d) { return renderDateOfReceipt(d); } },
/* 8 */ { data: 'OrderStatusId', name: 'OrderStatusId', width: '105px',
className: 'text-center',
render: function (d, t, row) { return renderOrderStatus(d, row); } },
/* 9 */ { data: 'PaymentStatus', name: 'PaymentStatus', orderable: false, width: '110px',
render: function (d) { return d || '—'; } },
/* 10 */ { data: 'ShippingStatus', name: 'ShippingStatus', orderable: false, width: '110px',
render: function (d) { return d || '—'; } },
/* 11 */ { data: 'CreatedOn', name: 'CreatedOn', width: '92px',
className: 'text-center',
render: function (d) { return d ? new Date(d).toLocaleDateString('hu-HU') : '—'; } },
/* 12 */ { data: 'OrderTotal', name: 'OrderTotal', orderable: false, width: '105px',
className: 'text-right' },
/* 13 */ { data: 'Id', name: null, orderable: false, searchable: false, width: '42px',
className: 'text-center',
render: function (d) { return '<a href="/Admin/Order/Edit/' + d + '" class="btn btn-default btn-xs" title="Szerkesztés"><i class="fas fa-pencil-alt"></i></a>'; } }
],
/* ── Per-column filter row ─────────────────────────────────── */
initComplete: function () {
var api = this.api();
var $thead = $(this).find('thead');
var $filterRow = $('<tr class="fb-filter-row"></tr>').appendTo($thead);
// Filter definition per column index:
// null = no filter, 'text' = text input, {type:'select', opts:[...]} = dropdown
var defs = [
null, /* 0 checkbox */
'text', /* 1 order # */
'text', /* 2 company */
{ type: 'select', opts: [['', 'Mind'], ['has', '✓ Igen'], ['none', '✗ Nem']] }, /* 3 innvoice */
null, /* 4 weight (no per-column filter) */
{ type: 'select', opts: [['', 'Mind'], ['true', 'Igen'], ['false', 'Nem']] }, /* 5 measurable */
{ type: 'select', opts: [['', 'Mind'], ['0', 'Nincs'], ['10', '…folyamat'], ['20', 'Mérésre'], ['30', 'Mérve'], ['40', 'Lezárva']] }, /* 6 measuring */
null, /* 7 date (top-level filter handles this) */
{ type: 'select', opts: [['', 'Mind'], ['10', 'Függőben'], ['20', 'Feldolgozás'], ['30', 'Teljesítve'], ['40', 'Törölve']] }, /* 8 order status */
null, /* 9 payment */
null, /* 10 shipping */
null, /* 11 created */
null, /* 12 total */
null /* 13 button */
];
api.columns().every(function (idx) {
var col = this;
var $th = $('<th></th>').appendTo($filterRow);
var def = defs[idx];
if (!def) return;
if (def === 'text') {
var $inp = $('<input type="text" placeholder="🔍">');
$inp.appendTo($th);
var timer;
$inp.on('input', function () {
clearTimeout(timer);
var v = this.value;
timer = setTimeout(function () { col.search(v).draw(); }, 450);
});
} else if (def.type === 'select') {
var $sel = $('<select></select>');
def.opts.forEach(function (o) {
$sel.append($('<option>').val(o[0]).text(o[1]));
});
$sel.appendTo($th);
$sel.on('change', function () { col.search(this.value).draw(); });
}
});
}
});
/* ── Search / filter triggers ────────────────────────────────── */
$('#fb-search-btn').on('click', function () { table.draw(); });
/* redraw on date change */
$('#@Html.IdFor(m => m.StartDate), #@Html.IdFor(m => m.EndDate)').on('change', function () { table.draw(); });
/* ── Partner (company) autocomplete ──────────────────────────── */
$('#fb-company-display').autocomplete({
delay : 400,
minLength: 2,
source : '@Url.Action("CustomerSearchAutoComplete", "CustomOrder")',
select : function (e, ui) {
$('#BillingCompany').val(ui.item.value);
$('#fb-company-display').val(ui.item.label);
$('#fb-company-clear').show();
table.draw();
return false;
}
});
$('#fb-company-clear').on('click', function () {
$('#BillingCompany').val('');
$('#fb-company-display').val('');
$(this).hide();
table.draw();
});
/* ── Checkbox: select all on current page ────────────────────── */
$('#fb-check-all').on('change', function () {
var checked = this.checked;
$('#fb-orders-grid tbody .fb-row-check').prop('checked', checked);
});
/* ── Inline editing: DateOfReceipt ───────────────────────────── */
$(document).on('click', '#fb-orders-grid tbody td.fb-editable-date', function (e) {
var $td = $(this);
if ($td.find('input').length) return; // already in edit mode
var $row = $td.closest('tr');
var rowData = table.row($row).data();
if (!rowData) return;
// Build a datetime-local string (YYYY-MM-DDTHH:mm) for the input value
function toDatetimeLocal(iso) {
if (!iso) return '';
var d = new Date(iso);
if (isNaN(d)) return '';
var pad = function(n) { return String(n).padStart(2, '0'); };
return d.getFullYear() + '-' + pad(d.getMonth()+1) + '-' + pad(d.getDate()) +
'T' + pad(d.getHours()) + ':' + pad(d.getMinutes());
}
var currentIso = rowData.DateOfReceipt || null;
// Default to now if no date set yet
var inputVal = currentIso ? toDatetimeLocal(currentIso) : toDatetimeLocal(new Date().toISOString());
var orderId = rowData.Id;
var savedHtml = $td.html();
var $inp = $('<input type="datetime-local">').val(inputVal);
$td.html('').append($inp);
$inp.focus();
function restore() { $td.html(savedHtml); }
function persist() {
var newVal = $inp.val(); // format: YYYY-MM-DDTHH:mm
// Compare against original ISO; skip save only if identical
var newIso = newVal ? new Date(newVal).toISOString() : null;
var oldIso = currentIso ? new Date(currentIso).toISOString() : null;
if (newIso === oldIso) { restore(); return; }
$.ajax({
url : '@Url.Action("UpdateOrderField", "CustomOrder")',
type : 'POST',
data : antiForgery({ orderId: orderId, field: 'DateOfReceipt', value: newVal }),
success: function (res) {
if (res.success) {
rowData.DateOfReceipt = newVal || null;
table.row($row).data(rowData).invalidate();
/* re-render only the date cell without full redraw */
$td.html(renderDateOfReceipt(newVal));
} else {
restore();
alert('Mentési hiba: ' + (res.error || 'Ismeretlen hiba'));
}
},
error: function () { restore(); }
});
}
$inp.on('blur', function () { persist(); });
$inp.on('keydown', function (e) {
if (e.key === 'Enter') { $inp.off('blur'); persist(); }
if (e.key === 'Escape') { $inp.off('blur'); restore(); }
});
});
/* ── Go-to order by number ───────────────────────────────────── */
$('#@Html.IdFor(m => m.GoDirectlyToCustomOrderNumber)').on('keydown', function (e) {
if (e.keyCode === 13) { $('#go-to-order-by-number').trigger('click'); return false; }
});
$('#go-to-order-by-number').on('click', function () {
var num = $('#@Html.IdFor(m => m.GoDirectlyToCustomOrderNumber)').val();
if (num) {
window.location.href = '@Url.Action("GoToOrderId", "CustomOrder")?' +
'@Html.IdFor(m => m.GoDirectlyToCustomOrderNumber)=' + encodeURIComponent(num);
}
});
/* ── Export / PDF selected ───────────────────────────────────── */
function exportSelected(formId, inputId) {
var ids = getSelectedIds().join(',');
if (!ids) { alert('@T("Admin.Orders.NoOrders")'); return; }
$(inputId).val(ids);
$(formId).submit();
}
$('#exportxml-selected').on('click', function (e) { e.preventDefault(); exportSelected('#export-xml-selected-form', '#export-xml-ids'); });
$('#exportexcel-selected').on('click', function (e) { e.preventDefault(); exportSelected('#export-excel-selected-form', '#export-excel-ids'); });
$('#pdf-invoice-selected').on('click', function (e) { e.preventDefault(); exportSelected('#pdf-invoice-selected-form', '#pdf-invoice-ids'); });
/* ── Create order modal ──────────────────────────────────────── */
var createProducts = [];
$('#create-order-customer-search').autocomplete({
delay : 400,
minLength: 2,
source : '@Url.Action("CustomerSearchAutoComplete", "CustomOrder")',
select : function (e, ui) {
$('#create-order-customer-id').val(ui.item.value);
$('#create-order-customer-name').html('<strong>' + ui.item.label + '</strong>');
$('#create-order-customer-search').val('');
$('#create-product-search-section').slideDown();
return false;
}
});
$('#create-order-product-search').autocomplete({
delay : 400,
minLength: 2,
source : '@Url.Action("ProductSearchAutoComplete", "CustomOrder")',
select : function (e, ui) {
addCreateProduct(ui.item);
$('#create-order-product-search').val('');
return false;
}
});
function addCreateProduct(item) {
if (createProducts.find(function (p) { return p.id === item.value; })) return;
createProducts.push({ id: item.value, name: item.label, quantity: 1, price: item.price || 0 });
renderCreateProducts();
}
function renderCreateProducts() {
var $body = $('#create-products-body').empty();
if (!createProducts.length) { $('#create-selected-products-section').hide(); return; }
$('#create-selected-products-section').show();
createProducts.forEach(function (p, i) {
$body.append(
'<tr>' +
'<td><strong>' + p.name + '</strong></td>' +
'<td><input type="number" class="form-control form-control-sm" min="1" value="' + p.quantity + '" data-idx="' + i + '" onchange="window._fbUpdateQty(this)"></td>' +
'<td><input type="text" class="form-control form-control-sm" value="' + p.price + '" data-idx="' + i + '" onchange="window._fbUpdatePrice(this)"></td>' +
'<td class="text-center"><button type="button" class="btn btn-danger btn-xs" onclick="window._fbRemoveProduct(' + i + ')"><i class="fas fa-trash"></i></button></td>' +
'</tr>'
);
});
$('#create-order-products-json').val(JSON.stringify(createProducts));
}
window._fbUpdateQty = function (el) { createProducts[+el.dataset.idx].quantity = +el.value; renderCreateProducts(); };
window._fbUpdatePrice = function (el) { createProducts[+el.dataset.idx].price = +el.value; renderCreateProducts(); };
window._fbRemoveProduct = function (i) { createProducts.splice(i, 1); renderCreateProducts(); };
$('#create-order-form').on('submit', function (e) {
if (!$('#create-order-customer-id').val()) {
e.preventDefault();
$('#create-order-customer-error').show();
}
});
$('#create-order-window').on('hidden.bs.modal', function () {
$('#create-order-customer-search, #create-order-customer-id, #create-order-customer-name').val('').html('');
$('#create-order-customer-error').hide();
$('#create-product-search-section').hide();
createProducts = [];
renderCreateProducts();
});
});
</script>

View File

@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Mvc;
using Nop.Plugin.Misc.FruitBankPlugin.Models;
using Nop.Plugin.Misc.FruitBankPlugin.Services;
using Nop.Web.Areas.Admin.Models.Customers;
using Nop.Web.Framework.Components;
namespace Nop.Plugin.Misc.FruitBankPlugin.Components;
[ViewComponent(Name = "CustomerCreditWidget")]
public class CustomerCreditWidgetViewComponent : NopViewComponent
{
private readonly ICustomerCreditService _customerCreditService;
public CustomerCreditWidgetViewComponent(ICustomerCreditService customerCreditService)
{
_customerCreditService = customerCreditService;
}
public async Task<IViewComponentResult> InvokeAsync(string widgetZone, object additionalData)
{
if (additionalData is not CustomerModel customerModel) return Content("");
var customerId = customerModel.Id;
var credit = await _customerCreditService.GetByCustomerIdAsync(customerId);
var outstanding = await _customerCreditService.GetOutstandingBalanceAsync(customerId);
var model = new CustomerCreditWidgetModel
{
CustomerId = customerId,
HasCreditLimit = credit != null,
CreditLimit = credit?.CreditLimit ?? 0m,
OutstandingBalance = outstanding,
RemainingCredit = credit != null ? credit.CreditLimit - outstanding : (decimal?)null,
Comment = credit?.Comment
};
return View("~/Plugins/Misc.FruitBankPlugin/Views/CustomerCreditWidget.cshtml", model);
}
}

View File

@ -0,0 +1,27 @@
using FruitBank.Common.Entities;
using LinqToDB;
using Mango.Nop.Core.Loggers;
using Mango.Nop.Data.Repositories;
using Nop.Core.Caching;
using Nop.Core.Configuration;
using Nop.Core.Events;
using Nop.Data;
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
public class CustomerCreditDbTable : MgDbTableBase<CustomerCredit>
{
public CustomerCreditDbTable(
IEventPublisher eventPublisher,
INopDataProvider dataProvider,
IShortTermCacheManager shortTermCacheManager,
IStaticCacheManager staticCacheManager,
AppSettings appSettings)
: base(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings)
{
}
public Task<CustomerCredit?> GetByCustomerIdAsync(int customerId)
=> GetAll().FirstOrDefaultAsync(x => x.CustomerId == customerId);
}

View File

@ -40,7 +40,8 @@ public class FruitBankDbContext : MgDbContextBase,
IShippingItemPalletDbSet<ShippingItemPalletDbTable>,
IOrderItemPalletDbSet<OrderItemPalletDbTable>,
IShippingDocumentToFilesDbSet<ShippingDocumentToFilesDbTable>,
IFilesDbSet<FilesDbTable>
IFilesDbSet<FilesDbTable>,
ICustomerCreditDbSet<CustomerCreditDbTable>
{
private readonly FruitBankAttributeService _fruitBankAttributeService;
private readonly IStoreContext _storeContext;
@ -64,6 +65,7 @@ public class FruitBankDbContext : MgDbContextBase,
public FilesDbTable Files { get; set; }
public ShippingDocumentToFilesDbTable ShippingDocumentToFiles { get; set; }
public StockQuantityHistoryDtoDbTable StockQuantityHistoryDtos { get; set; }
public CustomerCreditDbTable CustomerCredits { get; set; }
public IRepository<Customer> Customers { get; set; }
public IRepository<CustomerRole> CustomerRoles { get; set; }
@ -79,7 +81,7 @@ public class FruitBankDbContext : MgDbContextBase,
PartnerDbTable partnerDbTable, ShippingDbTable shippingDbTable, ShippingDocumentDbTable shippingDocumentDbTable, ShippingItemDbTable shippingItemDbTable,
ShippingItemPalletDbTable shippingItemPalletDbTable, FilesDbTable filesDbTable, ShippingDocumentToFilesDbTable shippingDocumentToFilesDbTable,
ProductDtoDbTable productDtoDbTable, OrderDtoDbTable orderDtoDbTable, OrderItemDtoDbTable orderItemDtoDbTable, OrderItemPalletDbTable orderItemPalletDbTable,
StockQuantityHistoryDtoDbTable stockQuantityHistoryDtos,
StockQuantityHistoryDtoDbTable stockQuantityHistoryDtos, CustomerCreditDbTable customerCreditDbTable,
IProductService productService, IStaticCacheManager staticCacheManager,
IRepository<Order> orderRepository,
IRepository<OrderItem> orderItemRepository,
@ -127,6 +129,7 @@ public class FruitBankDbContext : MgDbContextBase,
StockQuantityHistories = stockQuantityHistories;
StockQuantityHistoriesExt = stockQuantityHistoriesExt;
StockQuantityHistoryDtos = stockQuantityHistoryDtos;
CustomerCredits = customerCreditDbTable;
}
public IQueryable<Customer> GetCustomersBySystemRoleName(string systemRoleName)

View File

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

View File

@ -331,7 +331,9 @@ public class MgOrderModelFactory<TOrderListModelExt, TOrderModelExt> : OrderMode
public virtual async Task<TOrderListModelExt> PrepareOrderListModelExtendedAsync(OrderSearchModelExtended searchModel, Func<OrderListModel, TOrderModelExt, Task> dataItemCopiedCallback)
{
var customerCompany = searchModel.BillingCompany;
var customer = await _customerService.GetCustomerByIdAsync(Convert.ToInt32(customerCompany));
var customer = int.TryParse(customerCompany, out var customerId) && customerId > 0
? await _customerService.GetCustomerByIdAsync(customerId)
: null;
//var customer = customers.FirstOrDefault(c => c.Company != null && c.Company.Equals(customerCompany, StringComparison.InvariantCultureIgnoreCase));
//var customer = customers.FirstOrDefault(c => c.Company != null && c.Company.Equals(customerCompany, StringComparison.InvariantCultureIgnoreCase));
OrderListModel prefiltered;

View File

@ -3,13 +3,16 @@ 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
@ -28,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
@ -38,7 +42,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
ILocalizationService localizationService,
IPermissionService permissionService,
IUrlHelperFactory urlHelperFactory,
IAdminMenu adminMenu)
IAdminMenu adminMenu,
IMessageTemplateService messageTemplateService)
{
_actionContextAccessor = actionContextAccessor;
_settingService = settingService;
@ -47,6 +52,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
_urlHelperFactory = urlHelperFactory;
_adminMenu = adminMenu;
_permissionService = permissionService;
_messageTemplateService = messageTemplateService;
}
// --- INSTALL ---
@ -192,6 +198,91 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.InvalidProductOrQuantity", "Invalid product or quantity", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.InvalidProductOrQuantity", "\u00c9rv\u00e9nytelen term\u00e9k vagy mennyis\u00e9g", hu);
// ── 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();
}
@ -207,7 +298,14 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
public Task<IList<string>> GetWidgetZonesAsync()
{
return Task.FromResult<IList<string>>(new List<string> { PublicWidgetZones.ProductBoxAddinfoBefore, PublicWidgetZones.ProductDetailsBottom, AdminWidgetZones.ProductDetailsBlock, AdminWidgetZones.OrderDetailsBlock });
return Task.FromResult<IList<string>>(new List<string>
{
PublicWidgetZones.ProductBoxAddinfoBefore,
PublicWidgetZones.ProductDetailsBottom,
AdminWidgetZones.ProductDetailsBlock,
AdminWidgetZones.OrderDetailsBlock,
AdminWidgetZones.CustomerDetailsBlock
});
}
public async Task ManageSiteMapAsync(AdminMenuItem rootNode)
@ -232,15 +330,19 @@ 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);
}
}

View File

@ -81,6 +81,7 @@ public class PluginNopStartup : INopStartup
services.AddScoped<StockTakingDbTable>();
services.AddScoped<StockTakingItemDbTable>();
services.AddScoped<StockTakingItemPalletDbTable>();
services.AddScoped<CustomerCreditDbTable>();
services.AddScoped<StockTakingDbContext>();
services.AddScoped<FruitBankDbContext>();
@ -127,8 +128,7 @@ public class PluginNopStartup : INopStartup
//services.AddScoped<IAIAPIService, OpenAIApiService>();
services.AddScoped<AICalculationService>();
services.AddScoped<PdfToImageService>();
services.AddScoped<IWorkflowMessageService, WorkflowMessageService>();
services.AddScoped<FruitBankNotificationService>();
services.AddSingleton<IFileStorageProvider>(sp =>
new LocalFileStorageProvider() // Uses default wwwroot/uploads
// Or specify custom path:
@ -137,6 +137,7 @@ public class PluginNopStartup : INopStartup
// Register the file storage service
services.AddScoped<FileStorageService>();
services.AddScoped<ICustomerCreditService, CustomerCreditService>();
services.AddControllersWithViews(options =>
{

View File

@ -21,58 +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 }
);
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 }
);
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 }
);
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",
@ -150,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",
@ -179,6 +181,22 @@ public class RouteProvider : IRouteProvider
pattern: "Admin/ExtractText",
defaults: new { controller = "FileManager", action = "ImageTextExtraction", area = AreaNames.ADMIN });
// ── Customer Credit ──────────────────────────────────────────────────
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.CustomerCredit.List",
pattern: "Admin/CustomerCredit/List",
defaults: new { controller = "CustomerCredit", action = "List", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.CustomerCredit.CustomerCreditList",
pattern: "Admin/CustomerCredit/CustomerCreditList",
defaults: new { controller = "CustomerCredit", action = "CustomerCreditList", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.CustomerCredit.UpdateCreditLimit",
pattern: "Admin/CustomerCredit/UpdateCreditLimit",
defaults: new { controller = "CustomerCredit", action = "UpdateCreditLimit", area = AreaNames.ADMIN });
// ── Public: Quick Order ──────────────────────────────────────────────
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.QuickOrder.Index",

View File

@ -0,0 +1,84 @@
<?xml version="1.0" encoding="utf-8"?>
<Language Name="English" IsDefault="false" IsRightToLeft="false">
<!-- ═══════════════════════════════════════════════════════════
Customer Credit — Plugins.Misc.FruitBankPlugin.CustomerCredit.*
Import: Admin > Configuration > Languages > [English] > Import resources
═══════════════════════════════════════════════════════════ -->
<!-- Page -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle">
<Value><![CDATA[Customer Credit Management]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.BackToCustomer">
<Value><![CDATA[Back to customer]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.EditTitle">
<Value><![CDATA[Set Credit Limit]]></Value>
</LocaleResource>
<!-- Summary cards -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit">
<Value><![CDATA[Credit Limit]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimitHint">
<Value><![CDATA[Set to 0 to block all orders. Leave the record absent to allow unlimited.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance">
<Value><![CDATA[Outstanding Balance]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit">
<Value><![CDATA[Remaining Credit]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited">
<Value><![CDATA[Unlimited]]></Value>
</LocaleResource>
<!-- Form -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment">
<Value><![CDATA[Notes]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.Save">
<Value><![CDATA[Save]]></Value>
</LocaleResource>
<!-- List page -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.CustomerName">
<Value><![CDATA[Customer Name]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.CustomerEmail">
<Value><![CDATA[Email]]></Value>
</LocaleResource>
<!-- Unpaid orders table -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.UnpaidOrdersTitle">
<Value><![CDATA[Unpaid / Pending Orders]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.NoUnpaidOrders">
<Value><![CDATA[No unpaid orders.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderId">
<Value><![CDATA[Order #]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderDate">
<Value><![CDATA[Date]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderTotal">
<Value><![CDATA[Total]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderStatus">
<Value><![CDATA[Order Status]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.PaymentStatus">
<Value><![CDATA[Payment Status]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.Total">
<Value><![CDATA[Total]]></Value>
</LocaleResource>
<!-- Enforcement -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderBlocked">
<Value><![CDATA[Your order cannot be placed because your outstanding balance has reached your credit limit. Please settle your existing balance first.]]></Value>
</LocaleResource>
</Language>

View File

@ -0,0 +1,84 @@
<?xml version="1.0" encoding="utf-8"?>
<Language Name="Hungarian" IsDefault="false" IsRightToLeft="false">
<!-- ═══════════════════════════════════════════════════════════
Customer Credit — Plugins.Misc.FruitBankPlugin.CustomerCredit.*
Import: Admin > Konfiguráció > Nyelvek > [Magyar] > Erőforrások importálása
═══════════════════════════════════════════════════════════ -->
<!-- Oldal -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle">
<Value><![CDATA[Ügyfél hitelkeret kezelés]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.BackToCustomer">
<Value><![CDATA[Vissza az ügyfélhez]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.EditTitle">
<Value><![CDATA[Hitelkeret beállítása]]></Value>
</LocaleResource>
<!-- Összefoglaló kártyák -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit">
<Value><![CDATA[Hitelkeret]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimitHint">
<Value><![CDATA[0 esetén minden rendelés le van tiltva. Ha nincs rekord, a limit korlátlan.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance">
<Value><![CDATA[Kintlévő egyenleg]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit">
<Value><![CDATA[Szabad keret]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited">
<Value><![CDATA[Korlátlan]]></Value>
</LocaleResource>
<!-- Űrlap -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment">
<Value><![CDATA[Megjegyzés]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.Save">
<Value><![CDATA[Mentés]]></Value>
</LocaleResource>
<!-- Lista oldal -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.CustomerName">
<Value><![CDATA[Ügyfél neve]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.CustomerEmail">
<Value><![CDATA[Email]]></Value>
</LocaleResource>
<!-- Kifizetetlen rendelések táblázat -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.UnpaidOrdersTitle">
<Value><![CDATA[Kifizetetlen / függő rendelések]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.NoUnpaidOrders">
<Value><![CDATA[Nincs kifizetetlen rendelés.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderId">
<Value><![CDATA[Rendelés #]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderDate">
<Value><![CDATA[Dátum]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderTotal">
<Value><![CDATA[Összeg]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderStatus">
<Value><![CDATA[Rendelés állapot]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.PaymentStatus">
<Value><![CDATA[Fizetési állapot]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.Total">
<Value><![CDATA[Összesen]]></Value>
</LocaleResource>
<!-- Hitelkeret túllépés hibaüzenet -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderBlocked">
<Value><![CDATA[A rendelést nem lehet leadni, mert a kintlévő egyenlege elérte a hitelkeretét. Kérjük, először rendezze meglévő tartozását.]]></Value>
</LocaleResource>
</Language>

View File

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

View File

@ -0,0 +1,21 @@
using Nop.Web.Framework.Models;
using Nop.Web.Framework.Mvc.ModelBinding;
namespace Nop.Plugin.Misc.FruitBankPlugin.Models;
public record CustomerCreditWidgetModel : BaseNopModel
{
public int CustomerId { get; set; }
public bool HasCreditLimit { get; set; }
[NopResourceDisplayName("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit")]
public decimal CreditLimit { get; set; }
[NopResourceDisplayName("Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance")]
public decimal OutstandingBalance { get; set; }
[NopResourceDisplayName("Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit")]
public decimal? RemainingCredit { get; set; }
public string? Comment { get; set; }
}

View File

@ -0,0 +1,35 @@
using System;
namespace Nop.Plugin.Misc.FruitBankPlugin.Models.Orders
{
/// <summary>
/// Lightweight DTO returned by the FruitBankOrderList endpoint.
/// Contains only what the grid needs avoids serialising heavy OrderModel navigation properties.
/// </summary>
public record FruitBankOrderRowDto
{
public int Id { get; init; }
public string CustomOrderNumber { get; init; }
public string CustomerCompany { get; init; }
public int CustomerId { get; init; }
// FruitBank-specific fields
public string InnvoiceTechId { get; init; }
public bool IsAllOrderItemAvgWeightValid { get; init; }
public bool IsMeasurable { get; init; }
public int MeasuringStatus { get; init; }
public string MeasuringStatusString { get; init; }
public DateTime? DateOfReceipt { get; init; }
// NopCommerce order fields
public int OrderStatusId { get; init; }
public string OrderStatus { get; init; }
public int PaymentStatusId { get; init; }
public string PaymentStatus { get; init; }
public int ShippingStatusId { get; init; }
public string ShippingStatus { get; init; }
public string StoreName { get; init; }
public DateTime CreatedOn { get; init; }
public string OrderTotal { get; init; }
}
}

View File

@ -173,6 +173,12 @@
<None Update="Areas\Admin\Views\AppDownload\Index.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\CustomerCredit\Details.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\CustomerCredit\List.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\Extras\ImageTextExtraction.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
@ -185,6 +191,9 @@
<None Update="Areas\Admin\Views\Order\FileUploadGridComponent.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\Order\FruitBankOrderList.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\Order\TestGridComponent.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
@ -650,6 +659,9 @@
<None Update="Views\Checkout\PendingMeasurementWarning.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Views\CustomerCreditWidget.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Views\ProductAIListWidget.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

View File

@ -0,0 +1,71 @@
using FruitBank.Common.Entities;
using Nop.Core.Domain.Orders;
using Nop.Core.Domain.Payments;
using Nop.Data;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
public class CustomerCreditService : ICustomerCreditService
{
private readonly CustomerCreditDbTable _customerCreditDbTable;
private readonly IRepository<Order> _orderRepository;
public CustomerCreditService(
CustomerCreditDbTable customerCreditDbTable,
IRepository<Order> orderRepository)
{
_customerCreditDbTable = customerCreditDbTable;
_orderRepository = orderRepository;
}
public Task<CustomerCredit?> GetByCustomerIdAsync(int customerId)
=> _customerCreditDbTable.GetByCustomerIdAsync(customerId);
public async Task SaveAsync(CustomerCredit entity)
{
entity.UpdatedOnUtc = DateTime.UtcNow;
if (entity.Id <= 0)
{
entity.CreatedOnUtc = DateTime.UtcNow;
await _customerCreditDbTable.InsertAsync(entity);
}
else
{
await _customerCreditDbTable.UpdateAsync(entity);
}
}
public Task DeleteAsync(CustomerCredit entity)
=> _customerCreditDbTable.DeleteAsync(entity);
public async Task<decimal> GetOutstandingBalanceAsync(int customerId)
{
return await _orderRepository.Table
.Where(o =>
o.CustomerId == customerId &&
o.OrderStatusId != (int)OrderStatus.Cancelled &&
(o.PaymentStatusId == (int)PaymentStatus.Pending ||
o.PaymentStatusId == (int)PaymentStatus.PartiallyRefunded))
.SumAsync(o => (decimal?)o.OrderTotal) ?? 0m;
}
public async Task<decimal?> GetRemainingCreditAsync(int customerId)
{
var credit = await GetByCustomerIdAsync(customerId);
if (credit == null) return null;
var outstanding = await GetOutstandingBalanceAsync(customerId);
return credit.CreditLimit - outstanding;
}
public async Task<bool> IsOrderAllowedAsync(int customerId, decimal newOrderTotal)
{
var credit = await GetByCustomerIdAsync(customerId);
if (credit == null) return true;
var outstanding = await GetOutstandingBalanceAsync(customerId);
return outstanding + newOrderTotal <= credit.CreditLimit;
}
}

View File

@ -1,4 +1,5 @@
using FruitBank.Common.Interfaces;
using FruitBank.Common.Enums;
using FruitBank.Common.Interfaces;
using FruitBank.Common.Server;
using Mango.Nop.Core.Dtos;
using Microsoft.AspNetCore.Http;
@ -8,6 +9,7 @@ using Nop.Core;
using Nop.Core.Domain.Catalog;
using Nop.Core.Domain.Common;
using Nop.Core.Domain.Customers;
using Nop.Core.Domain.Messages;
using Nop.Core.Domain.Orders;
using Nop.Core.Domain.Tax;
using Nop.Core.Events;
@ -20,11 +22,13 @@ using Nop.Services.Common;
using Nop.Services.Customers;
using Nop.Services.Events;
using Nop.Services.Localization;
using Nop.Services.Messages;
using Nop.Services.Orders;
using Nop.Services.Plugins;
using Nop.Web.Framework.Events;
using Nop.Web.Framework.Menu;
using Nop.Web.Models.Sitemap;
using NUglify.JavaScript.Syntax;
using System.Linq;
using System.Xml.Linq;
@ -47,6 +51,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
private readonly FruitBankDbContext _dbContext;
private readonly IAttributeParser<CustomerAttribute, CustomerAttributeValue> _attributeParser;
private readonly ICustomerService _customerService;
private readonly IWorkflowMessageService _workflowMessageService;
private readonly FruitBankNotificationService _fruitBankNotificationService;
public EventConsumer(
IGenericAttributeService genericAttributeService,
@ -64,7 +70,9 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
FruitBankAttributeService fruitBankAttributeService,
FruitBankDbContext dbContext,
IAttributeParser<CustomerAttribute, CustomerAttributeValue> attributeParser,
ICustomerService customerService
ICustomerService customerService,
IWorkflowMessageService workflowMessageService,
FruitBankNotificationService fruitBankNotificationService
) : base(pluginManager)
{
_genericAttributeService = genericAttributeService;
@ -82,6 +90,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
_dbContext = dbContext;
_attributeParser = attributeParser;
_customerService = customerService;
_workflowMessageService = workflowMessageService;
_fruitBankNotificationService = fruitBankNotificationService;
}
protected override string PluginSystemName => "Misc.FruitBankPlugin";
@ -93,6 +103,43 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
public async Task HandleEventAsync(EntityUpdatedEvent<Order> eventMessage)
{
await SaveOrderCustomAttributesAsync(eventMessage.Entity);
if (eventMessage.Entity == null) return;
var orderDto = await _dbContext.OrderDtos.GetByIdAsync(eventMessage.Entity.Id, true);
if (orderDto == null) return;
if (orderDto.MeasuringStatus == MeasuringStatus.Audited)
{
var alreadySent = await _fruitBankAttributeService
.GetGenericAttributeValueAsync<Order, bool>(eventMessage.Entity.Id, "OrderAuditedNotificationSent");
if (!alreadySent)
{
await _fruitBankNotificationService
.SendOrderAuditedCustomerNotificationAsync(eventMessage.Entity, orderDto.IsMeasurable);
await _fruitBankAttributeService
.InsertOrUpdateGenericAttributeAsync<Order, bool>(
eventMessage.Entity.Id, "OrderAuditedNotificationSent", true);
}
}
else if (orderDto.MeasuringStatus == MeasuringStatus.Started)
{
var alreadySent = await _fruitBankAttributeService
.GetGenericAttributeValueAsync<Order, bool>(eventMessage.Entity.Id, "OrderStartedNotificationSent");
if (!alreadySent)
{
await _fruitBankNotificationService
.SendOrderStartedCustomerNotificationAsync(eventMessage.Entity, orderDto.IsMeasurable);
await _fruitBankAttributeService
.InsertOrUpdateGenericAttributeAsync<Order, bool>(
eventMessage.Entity.Id, "OrderStartedNotificationSent", true);
}
}
}

View File

@ -0,0 +1,84 @@
using Nop.Core;
using Nop.Core.Domain.Messages;
using Nop.Core.Domain.Orders;
using Nop.Services.Customers;
using Nop.Services.Messages;
namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
public class FruitBankNotificationService(
IMessageTemplateService messageTemplateService,
IEmailAccountService emailAccountService,
EmailAccountSettings emailAccountSettings,
IMessageTokenProvider messageTokenProvider,
IWorkflowMessageService workflowMessageService,
ICustomerService customerService,
IStoreContext storeContext)
{
public const string ORDER_AUDITED_TEMPLATE_NAME = "FruitBank.OrderAudited.CustomerNotification";
public const string ORDER_STARTED_TEMPLATE_NAME = "FruitBank.OrderStarted.CustomerNotification";
/// <summary>
/// Sends the "order started" (being prepared) customer notification.
/// For measurable orders, informs the customer that final prices will be
/// confirmed after weighing. Fires once when MeasuringStatus transitions to Started.
/// </summary>
public async Task<int> SendOrderStartedCustomerNotificationAsync(Order order, bool isMeasurable)
{
var measurableNote = isMeasurable
? "<p>Rendel&#233;sed m&#233;rhet&#337; t&#233;teleket tartalmaz. A v&#233;gleges &#225;r a m&#233;r&#233;s ut&#225;n ker&#252;l meger&#337;s&#237;t&#233;sre.</p>"
: string.Empty;
return await SendNotificationAsync(ORDER_STARTED_TEMPLATE_NAME, order, measurableNote);
}
/// <summary>
/// Sends the "order audited" customer notification.
/// For measurable orders, confirms that weights have been recorded and
/// the final price is as shown on the order.
/// Fires once when MeasuringStatus transitions to Audited.
/// </summary>
public async Task<int> SendOrderAuditedCustomerNotificationAsync(Order order, bool isMeasurable)
{
var measurableNote = isMeasurable
? "<p>A m&#233;rt t&#233;telek s&#250;lyait r&#246;gz&#237;tett&#252;k, a v&#233;gleges &#225;r a rendel&#233;sen felt&#252;ntetett &#246;sszeg.</p>"
: string.Empty;
return await SendNotificationAsync(ORDER_AUDITED_TEMPLATE_NAME, order, measurableNote);
}
// ── shared core ─────────────────────────────────────────────────────────
private async Task<int> SendNotificationAsync(string templateName, Order order, string measurableNote)
{
var store = await storeContext.GetCurrentStoreAsync();
var templates = await messageTemplateService.GetMessageTemplatesByNameAsync(templateName, store.Id);
var messageTemplate = templates?.FirstOrDefault();
if (messageTemplate is null || !messageTemplate.IsActive)
return 0;
var emailAccount = await emailAccountService.GetEmailAccountByIdAsync(messageTemplate.EmailAccountId)
?? await emailAccountService.GetEmailAccountByIdAsync(emailAccountSettings.DefaultEmailAccountId);
var tokens = new List<Token>();
await messageTokenProvider.AddStoreTokensAsync(tokens, store, emailAccount, order.CustomerLanguageId);
await messageTokenProvider.AddOrderTokensAsync(tokens, order, order.CustomerLanguageId);
var customer = await customerService.GetCustomerByIdAsync(order.CustomerId);
await messageTokenProvider.AddCustomerTokensAsync(tokens, customer);
tokens.Add(new Token("Order.MeasurableNote", measurableNote, true));
var toEmail = customer.Email;
var toName = $"{customer.FirstName} {customer.LastName}".Trim();
if (string.IsNullOrWhiteSpace(toName)) toName = customer.Email;
return await workflowMessageService.SendNotificationAsync(
messageTemplate, emailAccount,
order.CustomerLanguageId,
tokens,
toEmail, toName);
}
}

View File

@ -0,0 +1,32 @@
using FruitBank.Common.Entities;
namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
public interface ICustomerCreditService
{
/// <summary>Gets the credit record for a customer, or null if none exists (= unlimited).</summary>
Task<CustomerCredit?> GetByCustomerIdAsync(int customerId);
/// <summary>Insert or update a customer credit record.</summary>
Task SaveAsync(CustomerCredit entity);
/// <summary>Delete the credit record for a customer, restoring unlimited access.</summary>
Task DeleteAsync(CustomerCredit entity);
/// <summary>
/// Sum of OrderTotal for all pending/unpaid, non-cancelled orders for the customer.
/// </summary>
Task<decimal> GetOutstandingBalanceAsync(int customerId);
/// <summary>
/// CreditLimit - OutstandingBalance. Returns null if no credit record exists (= unlimited).
/// </summary>
Task<decimal?> GetRemainingCreditAsync(int customerId);
/// <summary>
/// Returns true if the customer is allowed to place a new order with the given total.
/// Rule: no credit record = always allowed.
/// Otherwise: OutstandingBalance + newOrderTotal must be &lt;= CreditLimit.
/// </summary>
Task<bool> IsOrderAllowedAsync(int customerId, decimal newOrderTotal);
}

View File

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