AI basics, FileStorage service, Menu fixes, Voice order improvements

This commit is contained in:
Adam 2026-01-16 13:53:48 +01:00
parent dca377df0e
commit 15dbec5aad
13 changed files with 3330 additions and 548 deletions

View File

@ -7,6 +7,7 @@ using DocumentFormat.OpenXml.Spreadsheet;
using FluentMigrator.Runner.Generators.Base; using FluentMigrator.Runner.Generators.Base;
using FruitBank.Common.Dtos; using FruitBank.Common.Dtos;
using FruitBank.Common.Entities; using FruitBank.Common.Entities;
using FruitBank.Common.Enums;
using FruitBank.Common.Interfaces; using FruitBank.Common.Interfaces;
using FruitBank.Common.Server.Interfaces; using FruitBank.Common.Server.Interfaces;
using FruitBank.Common.Server.Services.SignalRs; using FruitBank.Common.Server.Services.SignalRs;
@ -324,7 +325,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
searchModel.SortColumn = columnName; searchModel.SortColumn = columnName;
if(int.Parse(sortColumnIndex) > 0) searchModel.SortColumnDirection = sortDirection; // "asc" or "desc" if (int.Parse(sortColumnIndex) > 0) searchModel.SortColumnDirection = sortDirection; // "asc" or "desc"
else searchModel.SortColumnDirection = "desc"; else searchModel.SortColumnDirection = "desc";
} }
//else //else
@ -458,7 +459,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
//MeasurementService.OrderItemMeasuringReset //MeasurementService.OrderItemMeasuringReset
//Todo: ezt orderitiemnként kéne kirakni?? - Á. //Todo: ezt orderitiemnként kéne kirakni?? - Á.
var valami = await _measurementService.OrderItemMeasuringReset(model.OrderItemId); var valami = await _measurementService.OrderItemMeasuringReset(model.OrderItemId);
return RedirectToAction("Edit", "Order", new { id = model.OrderId }); return RedirectToAction("Edit", "Order", new { id = model.OrderId });
} }
@ -587,7 +588,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
throw new Exception($"{errorText}"); throw new Exception($"{errorText}");
} }
if(orderProductItem.Price != product.Price) if (orderProductItem.Price != product.Price)
{ {
//manual price change //manual price change
unitPricesIncludeDiscounts = false; unitPricesIncludeDiscounts = false;
@ -598,7 +599,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
} }
var orderItem = await CreateOrderItem(product, order, orderProductItem, isMeasurable, unitPricesIncludeDiscounts, customer, store); var orderItem = await CreateOrderItem(product, order, orderProductItem, isMeasurable, unitPricesIncludeDiscounts, customer, store);
await _orderService.InsertOrderItemAsync(orderItem); await _orderService.InsertOrderItemAsync(orderItem);
await _productService.AdjustInventoryAsync(product, -orderItem.Quantity, orderItem.AttributesXml, string.Format(await _localizationService.GetResourceAsync("Admin.StockQuantityHistory.Messages.PlaceOrder"), order.Id)); await _productService.AdjustInventoryAsync(product, -orderItem.Quantity, orderItem.AttributesXml, string.Format(await _localizationService.GetResourceAsync("Admin.StockQuantityHistory.Messages.PlaceOrder"), order.Id));
@ -647,10 +648,10 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
} }
// Calculate tax // Calculate tax
//var (unitPriceInclTaxValue, _) = await _taxService.GetProductPriceAsync(product, unitPrice, true, customer); //var (unitPriceInclTaxValue, _) = await _taxService.GetProductPriceAsync(product, unitPrice, true, customer);
var (unitPriceExclTaxValue, _) = await _taxService.GetProductPriceAsync(product, unitPriceInclTaxValue, false, customer); var (unitPriceExclTaxValue, _) = await _taxService.GetProductPriceAsync(product, unitPriceInclTaxValue, false, customer);
return new OrderItem return new OrderItem
{ {
OrderId = order.Id, OrderId = order.Id,
@ -664,7 +665,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
PriceInclTax = isMeasurable ? 0 : unitPriceInclTaxValue * orderProductItem.Quantity, PriceInclTax = isMeasurable ? 0 : unitPriceInclTaxValue * orderProductItem.Quantity,
PriceExclTax = isMeasurable ? 0 : unitPriceExclTaxValue * orderProductItem.Quantity, PriceExclTax = isMeasurable ? 0 : unitPriceExclTaxValue * orderProductItem.Quantity,
OriginalProductCost = await _priceCalculationService.GetProductCostAsync(product, null), OriginalProductCost = await _priceCalculationService.GetProductCostAsync(product, null),
AttributeDescription = string.Empty, AttributeDescription = string.Empty,
@ -936,7 +937,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
var productDto = productDtosById[product.Id]; var productDto = productDtosById[product.Id];
if (productDto != null) if (productDto != null)
{ {
if(productDto.AvailableQuantity > 0) if (productDto.AvailableQuantity > 0)
{ {
result.Add(new result.Add(new
{ {
@ -954,6 +955,46 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
return Json(result); return Json(result);
} }
[HttpGet]
[CheckPermission(StandardPermission.Catalog.PRODUCTS_VIEW)]
public virtual async Task<IActionResult> ProductSearchUnfilteredAutoComplete(string term)
{
if (string.IsNullOrWhiteSpace(term) || term.Length < 2)
return Json(new List<object>());
const int maxResults = 30;
// Search products by name or SKU
var products = await _productService.SearchProductsAsync(
keywords: term,
pageIndex: 0,
pageSize: maxResults);
var result = new List<object>();
var productDtosById = await _dbContext.ProductDtos.GetAllByIds(products.Select(p => p.Id)).ToDictionaryAsync(k => k.Id, v => v);
foreach (var product in products)
{
var productDto = productDtosById[product.Id];
if (productDto != null)
{
result.Add(new
{
label = $"{product.Name} [RENDELHETŐ: {(product.StockQuantity + productDto.IncomingQuantity)}] [ÁR: {product.Price}]",
value = product.Id,
sku = product.Sku,
price = product.Price,
stockQuantity = product.StockQuantity,
incomingQuantity = productDto.IncomingQuantity,
});
}
}
return Json(result);
}
//[HttpPost] //[HttpPost]
//public async Task<IActionResult> CreateInvoice(int orderId) //public async Task<IActionResult> CreateInvoice(int orderId)
//{ //{
@ -1362,21 +1403,21 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
if (!await _permissionService.AuthorizeAsync(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE)) if (!await _permissionService.AuthorizeAsync(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE))
return Json(new { success = false, message = "Access denied" }); return Json(new { success = false, message = "Access denied" });
if (string.IsNullOrEmpty(productsJson)) if (string.IsNullOrEmpty(productsJson))
return Json(new { success = false, message = "No products data received" }); return Json(new { success = false, message = "No products data received" });
var order = await _orderService.GetOrderByIdAsync(orderId); var order = await _orderService.GetOrderByIdAsync(orderId);
if (order == null || order.Deleted) if (order == null || order.Deleted)
return Json(new { success = false, message = "Order not found" }); return Json(new { success = false, message = "Order not found" });
// Deserialize products // Deserialize products
var products = productsJson.JsonTo<List<AddProductModel>>(); //JsonConvert.DeserializeObject<List<AddProductModel>>(productsJson); var products = productsJson.JsonTo<List<AddProductModel>>(); //JsonConvert.DeserializeObject<List<AddProductModel>>(productsJson);
if (products == null || products.Count == 0) if (products == null || products.Count == 0)
return Json(new { success = false, message = "No products to add" }); return Json(new { success = false, message = "No products to add" });
var customer = await _customerService.GetCustomerByIdAsync(order.CustomerId); var customer = await _customerService.GetCustomerByIdAsync(order.CustomerId);
var store = await _storeContext.GetCurrentStoreAsync(); var store = await _storeContext.GetCurrentStoreAsync();
var admin = await _workContext.GetCurrentCustomerAsync(); var admin = await _workContext.GetCurrentCustomerAsync();
@ -1409,6 +1450,272 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
return Json(new { success = false, message = $"Error: {ex.Message}" }); return Json(new { success = false, message = $"Error: {ex.Message}" });
} }
} }
[HttpPost]
[ValidateAntiForgeryToken]
[CheckPermission(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE)]
public async Task<IActionResult> SplitOrder(int orderId)
{
try
{
_logger.Info($"SplitOrder - OrderId: {orderId} - STARTED");
var order = await _orderService.GetOrderByIdAsync(orderId);
if (order == null || order.Deleted)
{
_logger.Warning($"SplitOrder - Order {orderId} not found or deleted");
return Json(new { success = false, message = "Rendelés nem található" });
}
_logger.Info($"SplitOrder - Order {orderId} found, checking access");
// Check if user has access to this order
if (!await HasAccessToOrderAsync(order))
{
_logger.Warning($"SplitOrder - No access to order {orderId}");
return Json(new { success = false, message = "Nincs jogosultsága ehhez a rendeléshez" });
}
_logger.Info($"SplitOrder - Getting OrderDto for order {orderId}");
var orderDto = await _dbContext.OrderDtos.GetByIdAsync(orderId, true);
if (orderDto == null)
{
_logger.Warning($"SplitOrder - OrderDto not found for order {orderId}");
return Json(new { success = false, message = "OrderDto nem található" });
}
// SAFETY CHECK: Don't allow splitting if order is complete/audited
if (orderDto.MeasuringStatus == MeasuringStatus.Audited)
{
_logger.Warning($"SplitOrder - Cannot split audited order {orderId}");
return Json(new
{
success = false,
message = "Ez a rendelés már auditált, nem választható szét!"
});
}
// SAFETY CHECK: Don't allow splitting if measuring hasn't started
if (orderDto.MeasuringStatus == MeasuringStatus.NotStarted)
{
_logger.Warning($"SplitOrder - Cannot split order {orderId} that hasn't been started");
return Json(new
{
success = false,
message = "Ez a rendelés még nem lett elkezdve, nem választható szét!"
});
}
_logger.Info($"SplitOrder - OrderDto found, separating items. Total items: {orderDto.OrderItemDtos.Count}, MeasuringStatus: {orderDto.MeasuringStatus}");
// Separate audited and non-audited items
var auditedItems = orderDto.OrderItemDtos.Where(oi => oi.IsAudited).ToList();
var nonAuditedItems = orderDto.OrderItemDtos.Where(oi => !oi.IsAudited).ToList();
_logger.Info($"SplitOrder - Audited items: {auditedItems.Count}, Non-audited items: {nonAuditedItems.Count}");
if (nonAuditedItems.Count == 0)
{
_logger.Warning($"SplitOrder - No non-audited items in order {orderId}");
return Json(new
{
success = false,
message = "Nincs nem auditált termék a rendelésben. Szétválasztás nem szükséges."
});
}
if (auditedItems.Count == 0)
{
_logger.Warning($"SplitOrder - All items are non-audited in order {orderId}");
return Json(new
{
success = false,
message = "Minden termék nem auditált. Szétválasztás nem szükséges."
});
}
_logger.Info($"SplitOrder - Getting customer, store, and admin");
var customer = await _customerService.GetCustomerByIdAsync(order.CustomerId);
var store = await _storeContext.GetCurrentStoreAsync();
var admin = await _workContext.GetCurrentCustomerAsync();
_logger.Info($"SplitOrder - Customer: {customer?.Id}, Store: {store?.Id}, Admin: {admin?.Id}");
_logger.Info($"SplitOrder - Creating new order (no transaction)");
// Create new order for non-audited items
var newOrder = new Order
{
OrderGuid = Guid.NewGuid(),
CustomOrderNumber = "",
CustomerId = order.CustomerId,
CustomerLanguageId = order.CustomerLanguageId,
CustomerTaxDisplayType = order.CustomerTaxDisplayType,
CustomerIp = order.CustomerIp,
OrderStatus = OrderStatus.Pending,
PaymentStatus = PaymentStatus.Pending,
ShippingStatus = ShippingStatus.ShippingNotRequired,
CreatedOnUtc = DateTime.UtcNow,
BillingAddressId = order.BillingAddressId,
ShippingAddressId = order.ShippingAddressId,
PaymentMethodSystemName = order.PaymentMethodSystemName,
CustomerCurrencyCode = order.CustomerCurrencyCode,
OrderTotal = 0,
OrderSubtotalInclTax = 0,
OrderSubtotalExclTax = 0,
OrderSubTotalDiscountInclTax = 0,
OrderSubTotalDiscountExclTax = 0,
};
_logger.Info($"SplitOrder - Inserting new order");
await _orderService.InsertOrderAsync(newOrder);
_logger.Info($"SplitOrder - New order inserted with ID: {newOrder.Id}");
newOrder.CustomOrderNumber = newOrder.Id.ToString();
await _orderService.UpdateOrderAsync(newOrder);
// Get original order items
_logger.Info($"SplitOrder - Getting original order items");
var originalOrderItems = await _orderService.GetOrderItemsAsync(orderId);
_logger.Info($"SplitOrder - Found {originalOrderItems.Count} original order items");
var orderItemsToMove = new List<OrderItem>();
// Find order items to move based on non-audited OrderItemDtos
foreach (var nonAuditedDto in nonAuditedItems)
{
var orderItemToMove = originalOrderItems.FirstOrDefault(oi => oi.Id == nonAuditedDto.Id);
if (orderItemToMove != null)
{
orderItemsToMove.Add(orderItemToMove);
}
}
_logger.Info($"SplitOrder - Found {orderItemsToMove.Count} items to move");
// Move non-audited items to new order
foreach (var orderItem in orderItemsToMove)
{
_logger.Info($"SplitOrder - Processing order item {orderItem.Id}");
var product = await _productService.GetProductByIdAsync(orderItem.ProductId);
if (product == null)
{
_logger.Warning($"Product with ID {orderItem.ProductId} not found during split");
continue;
}
// Create new order item for new order
var newOrderItem = new OrderItem
{
OrderId = newOrder.Id,
ProductId = orderItem.ProductId,
Quantity = orderItem.Quantity,
OrderItemGuid = Guid.NewGuid(),
UnitPriceInclTax = orderItem.UnitPriceInclTax,
UnitPriceExclTax = orderItem.UnitPriceExclTax,
PriceInclTax = orderItem.PriceInclTax,
PriceExclTax = orderItem.PriceExclTax,
OriginalProductCost = orderItem.OriginalProductCost,
AttributeDescription = orderItem.AttributeDescription,
AttributesXml = orderItem.AttributesXml,
DiscountAmountInclTax = orderItem.DiscountAmountInclTax,
DiscountAmountExclTax = orderItem.DiscountAmountExclTax,
DownloadCount = 0,
IsDownloadActivated = false,
LicenseDownloadId = 0,
ItemWeight = orderItem.ItemWeight,
RentalStartDateUtc = orderItem.RentalStartDateUtc,
RentalEndDateUtc = orderItem.RentalEndDateUtc
};
_logger.Info($"SplitOrder - Inserting new order item for product {orderItem.ProductId}");
await _orderService.InsertOrderItemAsync(newOrderItem);
// Update new order totals
newOrder.OrderSubtotalInclTax += newOrderItem.PriceInclTax;
newOrder.OrderSubtotalExclTax += newOrderItem.PriceExclTax;
newOrder.OrderTotal += newOrderItem.PriceInclTax;
_logger.Info($"SplitOrder - Adjusting inventory for product {orderItem.ProductId}");
// Return inventory to stock (from original order)
await _productService.AdjustInventoryAsync(
product,
orderItem.Quantity,
orderItem.AttributesXml,
$"Returned from split order #{order.Id}"
);
// Remove from stock (for new order)
await _productService.AdjustInventoryAsync(
product,
-orderItem.Quantity,
orderItem.AttributesXml,
$"Split to new order #{newOrder.Id}"
);
_logger.Info($"SplitOrder - Deleting order item {orderItem.Id} from original order");
// Delete from original order
await _orderService.DeleteOrderItemAsync(orderItem);
// Update original order totals
order.OrderSubtotalInclTax -= orderItem.PriceInclTax;
order.OrderSubtotalExclTax -= orderItem.PriceExclTax;
order.OrderTotal -= orderItem.PriceInclTax;
}
_logger.Info($"SplitOrder - Updating both orders");
// Update both orders
await _orderService.UpdateOrderAsync(newOrder);
await _orderService.UpdateOrderAsync(order);
_logger.Info($"SplitOrder - Adding order notes");
// Add notes to both orders
await InsertOrderNoteAsync(
order.Id,
false,
$"Rendelés szétválasztva. Nem auditált termékek átkerültek a #{newOrder.Id} rendelésbe. Művelet végrehajtója: {admin.FirstName} {admin.LastName} (ID: {admin.Id})"
);
await InsertOrderNoteAsync(
newOrder.Id,
false,
$"Új rendelés létrehozva a #{order.Id} rendelés szétválasztásával. Nem auditált termékek. Művelet végrehajtója: {admin.FirstName} {admin.LastName} (ID: {admin.Id})"
);
_logger.Info($"Order {orderId} split successfully. New order created: {newOrder.Id}. Moved {orderItemsToMove.Count} items.");
// Send notifications
_logger.Info($"SplitOrder - Sending notifications");
var originalOrderDto = await _dbContext.OrderDtos.GetByIdAsync(order.Id, true);
var newOrderDto = await _dbContext.OrderDtos.GetByIdAsync(newOrder.Id, true);
await _sendToClient.SendOrderChanged(originalOrderDto);
await _sendToClient.SendOrderChanged(newOrderDto);
_logger.Info($"SplitOrder - COMPLETED SUCCESSFULLY");
return Json(new
{
success = true,
message = "Rendelés sikeresen szétválasztva",
newOrderId = newOrder.Id,
originalOrderId = order.Id
});
}
catch (Exception ex)
{
_logger.Error($"Error splitting order {orderId}: {ex.Message}", ex);
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
}
}
} }
} }

View File

@ -1,11 +1,18 @@
using FruitBank.Common.Dtos; 
using DocumentFormat.OpenXml.Vml;
using FruitBank.Common;
using FruitBank.Common.Dtos;
using FruitBank.Common.Entities; using FruitBank.Common.Entities;
using FruitBank.Common.Interfaces;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Nop.Core;
using Nop.Core.Domain.Catalog; using Nop.Core.Domain.Catalog;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Plugin.Misc.FruitBankPlugin.Helpers; using Nop.Plugin.Misc.FruitBankPlugin.Helpers;
using Nop.Plugin.Misc.FruitBankPlugin.Services; using Nop.Plugin.Misc.FruitBankPlugin.Services;
using Nop.Plugin.Misc.FruitBankPlugin.Services.FileStorage;
using Nop.Services.Catalog; using Nop.Services.Catalog;
using Nop.Services.Security; using Nop.Services.Security;
using Nop.Web.Framework; using Nop.Web.Framework;
@ -28,6 +35,8 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
private readonly IProductService _productService; private readonly IProductService _productService;
private readonly FruitBankDbContext _dbContext; private readonly FruitBankDbContext _dbContext;
private readonly PdfToImageService _pdfToImageService; private readonly PdfToImageService _pdfToImageService;
private readonly IWorkContext _workContext;
private readonly FileStorageService _fileStorageService;
public FileManagerController( public FileManagerController(
IPermissionService permissionService, IPermissionService permissionService,
@ -35,7 +44,9 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
AICalculationService aiCalculationService, AICalculationService aiCalculationService,
IProductService productService, IProductService productService,
FruitBankDbContext fruitBankDbContext, FruitBankDbContext fruitBankDbContext,
PdfToImageService pdfToImageService) PdfToImageService pdfToImageService,
IWorkContext workContext,
FileStorageService fileStorageService)
{ {
_permissionService = permissionService; _permissionService = permissionService;
_aiApiService = aiApiService; _aiApiService = aiApiService;
@ -43,6 +54,8 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
_productService = productService; _productService = productService;
_dbContext = fruitBankDbContext; _dbContext = fruitBankDbContext;
_pdfToImageService = pdfToImageService; _pdfToImageService = pdfToImageService;
_workContext = workContext;
_fileStorageService = fileStorageService;
} }
/// <summary> /// <summary>
@ -57,7 +70,8 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
} }
/// <summary> /// <summary>
/// Endpoint to extract text from uploaded image /// Endpoint to extract text from uploaded image with duplicate detection
/// FIXED: Removed EF Core .Include() dependency
/// </summary> /// </summary>
[HttpPost] [HttpPost]
public async Task<IActionResult> ExtractTextFromImage(IFormFile imageFile, string customPrompt = null) public async Task<IActionResult> ExtractTextFromImage(IFormFile imageFile, string customPrompt = null)
@ -71,20 +85,114 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
} }
// Validate file type - now including PDF // Validate file type - now including PDF
var extension = Path.GetExtension(imageFile.FileName).ToLowerInvariant(); var extension = System.IO.Path.GetExtension(imageFile.FileName).ToLowerInvariant();
if (extension != ".jpg" && extension != ".jpeg" && extension != ".png" && if (extension != ".jpg" && extension != ".jpeg" && extension != ".png" &&
extension != ".gif" && extension != ".webp" && extension != ".pdf") extension != ".gif" && extension != ".webp" && extension != ".pdf")
{ {
return Json(new { success = false, message = "Invalid file type. Please upload JPG, PNG, GIF, WebP, or PDF." }); return Json(new { success = false, message = "Invalid file type. Please upload JPG, PNG, GIF, WebP, or PDF." });
} }
ShippingDocument shippingDocument = new ShippingDocument();
shippingDocument.ShippingItems = new List<ShippingItem>();
try try
{ {
// ✅ STEP 1: Calculate file hash FIRST to check for duplicates
string fileHash;
using (var hashStream = imageFile.OpenReadStream())
{
using (var sha256 = System.Security.Cryptography.SHA256.Create())
{
var hashBytes = await Task.Run(() => sha256.ComputeHash(hashStream));
fileHash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
}
}
Console.WriteLine($"📝 Uploaded file hash: {fileHash}");
// ✅ STEP 2: Check if this file has already been processed
var existingFile = await _dbContext.Files
.GetAll()
.FirstOrDefaultAsync(f => f.FileHash == fileHash);
if (existingFile != null)
{
Console.WriteLine($"♻️ Duplicate file detected! File ID: {existingFile.Id}");
// Find the shipping document(s) associated with this file
var existingMappingList = await _dbContext.ShippingDocumentToFiles
.GetAll()
.Where(m => m.FilesId == existingFile.Id)
.OrderByDescending(m => m.Created).ToListAsync();
var existingMapping = existingMappingList.FirstOrDefault();
if (existingMapping != null)
{
// ✅ Load the existing shipping document (without .Include())
var existingShippingDoc = await _dbContext.ShippingDocuments
.GetByIdAsync(existingMapping.ShippingDocumentId);
if (existingShippingDoc != null)
{
Console.WriteLine($"✅ Found existing shipping document - ID: {existingShippingDoc.Id}");
// ✅ Manually load related data
var shippingItems = await _dbContext.ShippingItems
.GetAll()
.Where(si => si.ShippingDocumentId == existingShippingDoc.Id)
.ToListAsync();
Partner matchedPartner = null;
if (existingShippingDoc.PartnerId > 0)
{
matchedPartner = await _dbContext.Partners.GetByIdAsync(existingShippingDoc.PartnerId);
}
// Return the existing data instead of re-processing
return Json(new
{
success = true,
isDuplicate = true, // ✅ Flag so frontend can show warning
message = $"This document was already processed on {existingShippingDoc.ShippingDate.ToString("yyyy-MM-dd HH:mm")}. Loaded existing data.",
existingDocumentId = existingShippingDoc.Id,
shippingDocument = new
{
documentIdNumber = existingShippingDoc.DocumentIdNumber,
partnerId = existingShippingDoc.PartnerId,
partnerName = matchedPartner?.Name,
partnerTaxId = matchedPartner?.TaxId,
pdfFileName = existingShippingDoc.PdfFileName,
totalPallets = existingShippingDoc.TotalPallets,
shippingItems = shippingItems?.Select(item => new
{
name = item.Name,
hungarianName = item.HungarianName,
nameOnDocument = item.NameOnDocument,
productId = item.ProductId,
palletsOnDocument = item.PalletsOnDocument,
quantityOnDocument = item.QuantityOnDocument,
netWeightOnDocument = item.NetWeightOnDocument,
grossWeightOnDocument = item.GrossWeightOnDocument,
unitPriceOnDocument = item.UnitPriceOnDocument
}).ToList(),
extractedText = existingFile.RawText
},
fileName = existingFile.FileName + existingFile.FileExtension,
fileSize = imageFile.Length,
wasConverted = extension == ".pdf"
});
}
}
// If we found the file but no shipping document, continue with extraction
Console.WriteLine("⚠️ File exists but no shipping document found. Continuing with extraction...");
}
// ✅ STEP 3: File is new or has no associated document - proceed with extraction
Console.WriteLine("🆕 New file detected. Starting AI extraction...");
ShippingDocument shippingDocument = new ShippingDocument();
shippingDocument.ShippingItems = new List<ShippingItem>();
// Define the uploads folder // Define the uploads folder
var uploadsFolder = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "uploads", "ocr"); var uploadsFolder = System.IO.Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "uploads", "ocr");
// Create directory if it doesn't exist // Create directory if it doesn't exist
if (!Directory.Exists(uploadsFolder)) if (!Directory.Exists(uploadsFolder))
@ -100,7 +208,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
{ {
// Save the PDF temporarily // Save the PDF temporarily
var tempPdfFileName = $"temp_pdf_{DateTime.Now:yyyyMMdd_HHmmss}.pdf"; var tempPdfFileName = $"temp_pdf_{DateTime.Now:yyyyMMdd_HHmmss}.pdf";
var tempPdfPath = Path.Combine(uploadsFolder, tempPdfFileName); var tempPdfPath = System.IO.Path.Combine(uploadsFolder, tempPdfFileName);
using (var stream = new FileStream(tempPdfPath, FileMode.Create)) using (var stream = new FileStream(tempPdfPath, FileMode.Create))
{ {
@ -121,7 +229,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
// Use the first page // Use the first page
processedFilePath = convertedImages[0]; processedFilePath = convertedImages[0];
processedFileName = Path.GetFileName(processedFilePath); processedFileName = System.IO.Path.GetFileName(processedFilePath);
// Clean up temp PDF // Clean up temp PDF
if (System.IO.File.Exists(tempPdfPath)) if (System.IO.File.Exists(tempPdfPath))
@ -131,7 +239,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
{ {
// Handle regular image files // Handle regular image files
processedFileName = $"ocr_image_{DateTime.Now:yyyyMMdd_HHmmss}{extension}"; processedFileName = $"ocr_image_{DateTime.Now:yyyyMMdd_HHmmss}{extension}";
processedFilePath = Path.Combine(uploadsFolder, processedFileName); processedFilePath = System.IO.Path.Combine(uploadsFolder, processedFileName);
using (var stream = new FileStream(processedFilePath, FileMode.Create)) using (var stream = new FileStream(processedFilePath, FileMode.Create))
{ {
@ -158,14 +266,15 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
message = "Failed to extract text. The API may have returned an empty response." message = "Failed to extract text. The API may have returned an empty response."
}); });
} }
OpenaiImageResponse deserializedContent = new(); OpenaiImageResponse deserializedContent = new();
var result = TextHelper.FixJsonWithoutAI(extractedText); var result = TextHelper.FixJsonWithoutAI(extractedText);
var options = new System.Text.Json.JsonSerializerOptions var options = new System.Text.Json.JsonSerializerOptions
{ {
PropertyNameCaseInsensitive = true, // Handles camelCase/PascalCase mismatches PropertyNameCaseInsensitive = true,
IncludeFields = true // This allows deserializing fields (in case you keep it as a field) IncludeFields = true
}; };
try try
@ -183,15 +292,11 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
Console.Error.WriteLine($"JSON content: {result}"); Console.Error.WriteLine($"JSON content: {result}");
} }
//string documentIdAnalysisResult = await ExtractDocumentId(deserializedContent.extractedData.fullText);
Console.WriteLine($"Document number analysis Result: {deserializedContent.extractedData.documentId}"); Console.WriteLine($"Document number analysis Result: {deserializedContent.extractedData.documentId}");
shippingDocument.DocumentIdNumber = deserializedContent.extractedData.documentId; shippingDocument.DocumentIdNumber = deserializedContent.extractedData.documentId;
string partnerAnalysis = await ExtractPartnerName(extractedText); string partnerAnalysis = await ExtractPartnerName(extractedText);
//int? dbPartnerName = await DeterminePartner(deserializedContent.extractedData.partner.name);
int? dbPartnerName = await DeterminePartner(partnerAnalysis); int? dbPartnerName = await DeterminePartner(partnerAnalysis);
if (dbPartnerName != null) if (dbPartnerName != null)
{ {
@ -203,21 +308,17 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
Console.WriteLine("No matching partner found in the database."); Console.WriteLine("No matching partner found in the database.");
} }
//string productAnalysis = await _aiCalculationService.ExtractProducts(extractedText);
Console.WriteLine($"Product analysis Result: {deserializedContent.extractedData.products}"); Console.WriteLine($"Product analysis Result: {deserializedContent.extractedData.products}");
//identify products from database // Identify products from database
var allProducts = await _dbContext.ProductDtos.GetAll(true).ToListAsync(); var allProducts = await _dbContext.ProductDtos.GetAll(true).ToListAsync();
var historicalProducts = await _dbContext.ShippingItems.GetAll().ToListAsync(); var historicalProducts = await _dbContext.ShippingItems.GetAll().ToListAsync();
//create json from product analyzis jsonstring
ProductReferenceResponse deserializedProducts = new ProductReferenceResponse(); ProductReferenceResponse deserializedProducts = new ProductReferenceResponse();
//deserializedProducts.products = new List<ProductReference>();
deserializedProducts.products = deserializedContent.extractedData.products; deserializedProducts.products = deserializedContent.extractedData.products;
Console.WriteLine($"Serialized Products: {deserializedProducts.products.Count}"); Console.WriteLine($"Serialized Products: {deserializedProducts.products.Count}");
List<ShippingItem> matchedProducts = new List<ShippingItem>(); List<ShippingItem> matchedProducts = new List<ShippingItem>();
//do we have historical references?
matchedProducts = await DetermineProducts(allProducts, historicalProducts, deserializedProducts); matchedProducts = await DetermineProducts(allProducts, historicalProducts, deserializedProducts);
shippingDocument.ShippingItems = matchedProducts; shippingDocument.ShippingItems = matchedProducts;
@ -236,27 +337,21 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
} }
shippingDocument.PdfFileName = processedFileName; shippingDocument.PdfFileName = processedFileName;
shippingDocument.ShippingDocumentToFiles = new List<ShippingDocumentToFiles>();
Files processedFile = new Files
{
FileName = processedFileName,
FileExtension = extension,
RawText = deserializedContent.extractedData.fullText,
};
ShippingDocumentToFiles shippingDocumentToFiles = new ShippingDocumentToFiles
{
FilesId = processedFile.Id,
ShippingDocumentId = shippingDocument.Id,
};
// Calculate total pallets from shipping items // Calculate total pallets from shipping items
shippingDocument.TotalPallets = shippingDocument.ShippingItems?.Sum(item => item.PalletsOnDocument) ?? 0; shippingDocument.TotalPallets = shippingDocument.ShippingItems?.Sum(item => item.PalletsOnDocument) ?? 0;
// Get partner details if matched
Partner matchedPartner2 = null;
if (shippingDocument.PartnerId > 0)
{
matchedPartner2 = await _dbContext.Partners.GetByIdAsync(shippingDocument.PartnerId);
}
return Json(new return Json(new
{ {
success = true, success = true,
isDuplicate = false, // ✅ Flag indicating this is new extraction
message = extension == ".pdf" message = extension == ".pdf"
? "PDF converted and text extracted successfully" ? "PDF converted and text extracted successfully"
: "Text extracted successfully", : "Text extracted successfully",
@ -264,6 +359,8 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
{ {
documentIdNumber = shippingDocument.DocumentIdNumber, documentIdNumber = shippingDocument.DocumentIdNumber,
partnerId = shippingDocument.PartnerId, partnerId = shippingDocument.PartnerId,
partnerName = matchedPartner2?.Name,
partnerTaxId = matchedPartner2?.TaxId,
pdfFileName = shippingDocument.PdfFileName, pdfFileName = shippingDocument.PdfFileName,
totalPallets = shippingDocument.TotalPallets, totalPallets = shippingDocument.TotalPallets,
shippingItems = shippingDocument.ShippingItems?.Select(item => new shippingItems = shippingDocument.ShippingItems?.Select(item => new
@ -276,7 +373,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
quantityOnDocument = item.QuantityOnDocument, quantityOnDocument = item.QuantityOnDocument,
netWeightOnDocument = item.NetWeightOnDocument, netWeightOnDocument = item.NetWeightOnDocument,
grossWeightOnDocument = item.GrossWeightOnDocument, grossWeightOnDocument = item.GrossWeightOnDocument,
isMeasurable = item.IsMeasurable unitPriceOnDocument = item.UnitPriceOnDocument
}).ToList(), }).ToList(),
extractedText = deserializedContent.extractedData.fullText extractedText = deserializedContent.extractedData.fullText
}, },
@ -586,87 +683,6 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
return cleaned; return cleaned;
} }
private async Task<string> TestFullResult(string extractedText)
{
string fullResultPrompt = $"Role:\r\nYou are an AI data extraction assistant for Fruitbank, a " +
$"fruit and vegetable wholesale company. Your task is to analyze a " +
$"provided text (delivery notes, invoices, or order confirmations) and extract structured information about " +
$"the shipment and its items.\r\n\r\n🎯 Goal:\r\nRead the provided text and extract all shipment " +
$"details and items according to the data model below.\r\n Generate the complete JSON output following this " +
$"structure.\r\n\r\n🧩 Data Models:\r\n\r\npublic " +
$"class Partner\r\n{{\r\n " +
$"/// <summary>\r\n /// Partner entity primary key\r\n /// </summary>\r\n " +
$"public int Id {{ get; set; }}\r\n " +
$"/// <summary>\r\n /// Partner company name\r\n /// </summary>\r\n " +
$"public string Name {{ get; set; }}\r\n " +
$"/// <summary>\r\n /// Partner company TaxId\r\n /// </summary>\r\n " +
$"public string TaxId {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// Partner company Certification if exists\r\n /// </summary>\r\n " +
$"public string CertificationNumber {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// Partner company address PostalCode\r\n /// </summary>\r\n " +
$"public string PostalCode {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// Partner company address Country\r\n /// </summary>\r\n " +
$"public string Country {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// Partner company address State if exists\r\n /// </summary>\r\n " +
$"public string State {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// Partner company address County if exists\r\n /// </summary>\r\n " +
$"public string County {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// Partner company address City\r\n /// </summary>\r\n " +
$"public string City {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// Partner company address Street\r\n /// </summary>\r\n " +
$"public string Street {{ get; set; }}\r\n\t/// <summary>\r\n " +
$"/// Entities of ShippingDocument\r\n /// </summary>\r\n\tpublic List<ShippingDocument> " +
$"ShippingDocuments {{ get; set; }}\t\r\n}}\r\n\r\npublic class ShippingDocument\r\n{{\r\n " +
$"/// <summary>\r\n /// ShippingItem entity primary key\r\n /// </summary>\r\n " +
$"public int Id {{ get; set; }}\r\n /// <summary>\r\n /// Partner entity primary key\r\n " +
$"/// </summary>\r\n public int PartnerId {{ get; set; }}\t\r\n\t/// <summary>\r\n " +
$"/// Entities of ShippingItem\r\n /// </summary>\r\n\t" +
$"public List<ShippingItem> ShippingItems {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// DocumentIdNumber if exists\r\n /// </summary>\r\n public string DocumentIdNumber {{ get; set; }}\r\n " +
$"/// <summary>\r\n /// \r\n /// </summary>\r\n public DateTime ShippingDate {{ get; set; }}\r\n " +
$"/// <summary>\r\n /// Shipping pickup Contry of origin\r\n /// </summary>\r\n " +
$"public string Country {{ get; set; }}\r\n\t/// <summary>\r\n /// Sum of ShippingItem pallets\r\n " +
$"/// </summary>\r\n public int TotalPallets {{ get; set; }}\r\n\t/// <summary>\r\n " +
$"/// Filename of pdf\r\n /// </summary>\r\n\tpublic string PdfFileName {{ get; set; }}\r\n}}\r\n\r\n" +
$"public class ShippingItem\r\n{{\r\n /// <summary>\r\n /// ShippingItem entity primary key\r\n /// " +
$"</summary>\r\n public int Id {{ get; set; }}\r\n /// <summary>\r\n /// " +
$"ShippingDocument entity primary key\r\n /// </summary>\r\n " +
$"public int ShippingDocumentId {{ get; set; }}\r\n /// " +
$"<summary>\r\n /// Name of the fruit or vegitable\r\n /// </summary>\r\n " +
$"public string Name {{ get; set; }}\r\n\t/// <summary>\r\n /// Translated Name to Hungarian\r\n " +
$"/// </summary>\r\n public string HungarianName {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// Pallets of fruit or vegitable item\r\n /// </summary>\r\n " +
$"public int PalletsOnDocument {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// Quantity of fruit or vegitable item\r\n /// </summary>\r\n " +
$"public int QuantityOnDocument {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// Net weight in kg. of fruit or vegitable item\r\n /// </summary>\r\n " +
$"public double NetWeightOnDocument {{ get; set; }}\r\n /// <summary>\r\n " +
$"/// Gross weight in kg. of fruit or vegitable item\r\n /// </summary>\r\n " +
$"public double GrossWeightOnDocument {{ get; set; }}\r\n}}\r\n\r\n🧾 Output Requirements\r\n- " +
$"Output must be a single valid JSON object containing:\r\n- One Partner object\r\n- " +
$"One ShippingDocument object\r\n- A list of all related ShippingItem objects\r\n\r\n- " +
$"Primary keys (Partner.Id, ShippingDocument.Id, ShippingItem.Id) should be auto-generated integers " +
$"(e.g. sequential: 1, 2, 3…).\r\n\r\n- When a field is missing or unclear, return it as an empty " +
$"string or 0 (depending on type).\r\nDo not omit any fields.\r\n\r\n- " +
$"All dates must be in ISO 8601 format (yyyy-MM-dd).\r\n\r\n🧭 Instructions to the AI\r\n" +
$"1. Analyze the provided text carefully.\r\n" +
$"2. Identify the Partner/Company details of THE OTHER PARTY (other than Fruitbank), " +
$"document identifiers, and each shipment item.\r\n" +
$"3. FruitBank is not a partner! Always look for THE OTHER partner on the document. \r\n " +
$"4. Generate a complete hierarchical JSON of ALL received documents in ONE JSON structure according to the " +
$"data model above.\r\n5. Do not include any explanations or text outside the JSON output. " +
$"Only return the structured JSON.\r\n" +
$"6. A teljes ShippingItem.Name-et tedd bele a ShippingItem.HungarianName-be " +
$"és a zöldség vagy gyümölcs nevét fordítsd le magyarra!\r\n" +
$"7. A ShippingDocument-et tedd bele a Partner entitásba!\r\n" +
$"8. ShippingItem-eket tedd bele a ShippingDocument-be!\r\n" +
$"9. Do not assume or modify any data, if you don't find a value, return null, if you find a value, keep it unmodified.\r\n" +
$"10. Magyarázat nélkül válaszolj!";
var fullresult = await _aiApiService.GetSimpleResponseAsync(fullResultPrompt, extractedText);
return fullresult;
}
private async Task<int> DeterminePartner(string partnerAnalysis) private async Task<int> DeterminePartner(string partnerAnalysis)
{ {
// Clean the input first // Clean the input first
@ -887,8 +903,214 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
// //analyze the text for document number or identifiers // //analyze the text for document number or identifiers
// return await _aiApiService.GetSimpleResponseAsync("You are an agent of Fruitbank to analyize text extracted frem a pfd document, and find the document number or identifier. IMPORTANT: reply only with the number, do not add further explanation.", $"What is the document identifier of this document: {extractedText}"); // return await _aiApiService.GetSimpleResponseAsync("You are an agent of Fruitbank to analyize text extracted frem a pfd document, and find the document number or identifier. IMPORTANT: reply only with the number, do not add further explanation.", $"What is the document identifier of this document: {extractedText}");
//} //}
/// <summary>
/// Partner search autocomplete endpoint for finding partners by name or tax ID
/// </summary>
[HttpGet]
public async Task<IActionResult> PartnerSearchAutoComplete(string term)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, message = "Access denied" });
if (string.IsNullOrWhiteSpace(term) || term.Length < 2)
return Json(new List<object>());
try
{
const int maxResults = 20;
// Get all partners and filter in-memory (or create a custom search method)
var allPartners = await _dbContext.Partners.GetAll().ToListAsync();
var matchedPartners = allPartners
.Where(p =>
(!string.IsNullOrEmpty(p.Name) && p.Name.Contains(term, StringComparison.OrdinalIgnoreCase)) ||
(!string.IsNullOrEmpty(p.TaxId) && p.TaxId.Contains(term, StringComparison.OrdinalIgnoreCase))
)
.Take(maxResults)
.ToList();
var result = matchedPartners.Select(partner => new
{
label = $"{partner.Name} - Tax ID: {partner.TaxId ?? "N/A"}",
value = partner.Id,
name = partner.Name,
taxId = partner.TaxId,
city = partner.City,
country = partner.Country
}).ToList();
return Json(result);
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error in PartnerSearchAutoComplete: {ex}");
return Json(new List<object>());
}
}
/// <summary>
/// Save a shipping document with its items AND the original uploaded file
/// </summary>
[HttpPost]
public async Task<IActionResult> SaveShippingDocument(
[FromForm] string documentData,
[FromForm] IFormFile originalFile,
[FromForm] string extractedText)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, message = "Access denied" });
if (string.IsNullOrEmpty(documentData))
{
return Json(new { success = false, message = "Invalid request data" });
}
try
{
// ✅ Configure JSON options to handle camelCase from JavaScript
var jsonOptions = new System.Text.Json.JsonSerializerOptions
{
PropertyNameCaseInsensitive = true // This handles camelCase <-> PascalCase
};
// Deserialize the shipping document data
var request = System.Text.Json.JsonSerializer.Deserialize<SaveShippingDocumentRequest>(documentData, jsonOptions);
if (request == null)
{
return Json(new { success = false, message = "Failed to parse document data" });
}
// Validate required fields
if (string.IsNullOrEmpty(request.DocumentIdNumber))
{
return Json(new { success = false, message = "Document ID is required" });
}
if (!request.PartnerId.HasValue || request.PartnerId <= 0)
{
return Json(new { success = false, message = "Valid Partner ID is required" });
}
if (request.ShippingItems == null || !request.ShippingItems.Any())
{
return Json(new { success = false, message = "At least one shipping item is required" });
}
var partner = await _dbContext.Partners.GetByIdAsync(request.PartnerId.Value);
// Create the shipping document entity
var shippingDocument = new ShippingDocument
{
DocumentIdNumber = request.DocumentIdNumber,
PartnerId = request.PartnerId.Value,
Country = partner?.Country,
TotalPallets = request.TotalPallets,
PdfFileName = originalFile.FileName,
ShippingDate = DateTime.UtcNow,
ShippingItems = new List<ShippingItem>()
};
// Convert DTOs to entities
foreach (var itemDto in request.ShippingItems)
{
var productDto = await _dbContext.ProductDtos.GetByIdAsync(itemDto.ProductId ?? 0, true);
if (productDto != null && string.IsNullOrEmpty(itemDto.Name))
{
itemDto.IsMeasurable = productDto.IsMeasurable;
}
var shippingItem = new ShippingItem
{
Name = itemDto.Name,
HungarianName = itemDto.HungarianName,
NameOnDocument = itemDto.NameOnDocument,
ProductId = itemDto.ProductId,
PalletsOnDocument = itemDto.PalletsOnDocument,
QuantityOnDocument = itemDto.QuantityOnDocument,
NetWeightOnDocument = itemDto.NetWeightOnDocument,
GrossWeightOnDocument = itemDto.GrossWeightOnDocument,
UnitPriceOnDocument = itemDto.UnitPriceOnDocument,
IsMeasurable = itemDto.IsMeasurable
};
shippingDocument.ShippingItems.Add(shippingItem);
}
// ✅ STEP 1: Save shipping document to database FIRST
await _dbContext.ShippingDocuments.InsertAsync(shippingDocument);
foreach (var sItem in shippingDocument.ShippingItems)
{
sItem.ShippingDocumentId = shippingDocument.Id; // Set FK
await _dbContext.ShippingItems.InsertAsync(sItem);
}
Console.WriteLine($"✓ Shipping document saved - ID: {shippingDocument.Id}, Document ID: {shippingDocument.DocumentIdNumber}");
// ✅ STEP 2: NOW save the original file (only if document saved successfully)
if (originalFile != null && originalFile.Length > 0)
{
var currentUser = await _workContext.GetCurrentCustomerAsync();
using (var fileStream = originalFile.OpenReadStream())
{
var savedFile = await _fileStorageService.SaveFileAsync(
fileStream: fileStream,
fileName: originalFile.FileName,
userId: currentUser.Id,
featureName: "ShippingDocumentProcessing",
entityType: "ShippingDocuments",
entityId: shippingDocument.Id, // ✅ Now we have the real ID!
rawText: extractedText,
checkForDuplicates: true
);
// Create mapping between ShippingDocument and File
var mapping = new ShippingDocumentToFiles
{
FilesId = savedFile.Id,
ShippingDocumentId = shippingDocument.Id,
DocumentTypeId = (int)DocumentType.ShippingDocument,
Created = DateTime.UtcNow,
Modified = DateTime.UtcNow
};
await _dbContext.ShippingDocumentToFiles.InsertAsync(mapping);
Console.WriteLine($"✓ Original file saved - File ID: {savedFile.Id}, Filename: {savedFile.FileName}");
}
}
else
{
Console.WriteLine("⚠ No original file provided - skipping file save");
}
return Json(new
{
success = true,
message = "Shipping document and file saved successfully",
shippingDocumentId = shippingDocument.Id,
documentId = shippingDocument.DocumentIdNumber,
itemCount = shippingDocument.ShippingItems.Count
});
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error saving shipping document: {ex}");
return Json(new
{
success = false,
message = $"Error saving document: {ex.Message}"
});
}
}
} }
public class ProductReference public class ProductReference
{ {
public string? name { get; set; } public string? name { get; set; }
@ -904,4 +1126,27 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
public List<ProductReference> products { get; set; } public List<ProductReference> products { get; set; }
} }
public class SaveShippingDocumentRequest
{
public string DocumentIdNumber { get; set; }
public int? PartnerId { get; set; }
public int TotalPallets { get; set; }
public string PdfFileName { get; set; }
public List<SaveShippingItemDto> ShippingItems { get; set; }
}
public class SaveShippingItemDto
{
public string Name { get; set; }
public string HungarianName { get; set; }
public string NameOnDocument { get; set; }
public int? ProductId { get; set; }
public int PalletsOnDocument { get; set; }
public int QuantityOnDocument { get; set; }
public double NetWeightOnDocument { get; set; }
public double GrossWeightOnDocument { get; set; }
public double UnitPriceOnDocument { get; set; }
public bool IsMeasurable { get; set; }
}
} }

View File

@ -0,0 +1,684 @@
using FruitBank.Common.Entities;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Nop.Core;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Plugin.Misc.FruitBankPlugin.Services.FileStorage;
using Nop.Services.Security;
using Nop.Web.Framework;
using Nop.Web.Framework.Controllers;
using Nop.Web.Framework.Mvc.Filters;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
namespace Nop.Plugin.Misc.FruitBank.Controllers
{
[AuthorizeAdmin]
[Area(AreaNames.ADMIN)]
[AutoValidateAntiforgeryToken]
public class FileStorageController : BasePluginController
{
private readonly FileStorageService _fileStorageService;
private readonly FruitBankDbContext _dbContext;
private readonly IPermissionService _permissionService;
private readonly IWorkContext _workContext;
public FileStorageController(
FileStorageService fileStorageService,
FruitBankDbContext dbContext,
IPermissionService permissionService,
IWorkContext workContext)
{
_fileStorageService = fileStorageService;
_dbContext = dbContext;
_permissionService = permissionService;
_workContext = workContext;
}
#region Upload Files
/// <summary>
/// Upload a single file
/// </summary>
/// <param name="file">The uploaded file</param>
/// <param name="featureName">Feature name (e.g., "AIdocumentprocessing")</param>
/// <param name="entityType">Entity type (e.g., "ShippingDocuments")</param>
/// <param name="entityId">Entity ID</param>
/// <param name="rawText">Optional raw text for searchable documents</param>
[HttpPost]
public async Task<IActionResult> UploadFile(
IFormFile file,
string featureName,
string entityType,
int entityId,
string rawText = null)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, message = "Access denied" });
if (file == null || file.Length == 0)
return Json(new { success = false, message = "No file uploaded" });
if (string.IsNullOrWhiteSpace(featureName))
return Json(new { success = false, message = "Feature name is required" });
if (string.IsNullOrWhiteSpace(entityType))
return Json(new { success = false, message = "Entity type is required" });
if (entityId <= 0)
return Json(new { success = false, message = "Valid entity ID is required" });
try
{
var currentUser = await _workContext.GetCurrentCustomerAsync();
var userId = currentUser.Id;
using (var stream = file.OpenReadStream())
{
var fileEntity = await _fileStorageService.SaveFileAsync(
fileStream: stream,
fileName: file.FileName,
userId: userId,
featureName: featureName,
entityType: entityType,
entityId: entityId,
rawText: rawText
);
return Json(new
{
success = true,
message = "File uploaded successfully",
file = new
{
id = fileEntity.Id,
fileName = fileEntity.FileName,
fileExtension = fileEntity.FileExtension,
created = fileEntity.Created,
hasRawText = !string.IsNullOrEmpty(fileEntity.RawText)
}
});
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error uploading file: {ex}");
return Json(new
{
success = false,
message = $"Error uploading file: {ex.Message}"
});
}
}
/// <summary>
/// Upload multiple files at once
/// </summary>
[HttpPost]
public async Task<IActionResult> UploadMultipleFiles(
List<IFormFile> files,
string featureName,
string entityType,
int entityId)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, message = "Access denied" });
if (files == null || files.Count == 0)
return Json(new { success = false, message = "No files uploaded" });
try
{
var currentUser = await _workContext.GetCurrentCustomerAsync();
var userId = currentUser.Id;
var uploadedFiles = new List<object>();
var errors = new List<string>();
foreach (var file in files)
{
try
{
if (file.Length > 0)
{
using (var stream = file.OpenReadStream())
{
var fileEntity = await _fileStorageService.SaveFileAsync(
fileStream: stream,
fileName: file.FileName,
userId: userId,
featureName: featureName,
entityType: entityType,
entityId: entityId
);
uploadedFiles.Add(new
{
id = fileEntity.Id,
fileName = fileEntity.FileName + fileEntity.FileExtension
});
}
}
}
catch (Exception ex)
{
errors.Add($"{file.FileName}: {ex.Message}");
}
}
return Json(new
{
success = uploadedFiles.Count > 0,
message = $"Uploaded {uploadedFiles.Count} of {files.Count} files",
files = uploadedFiles,
errors = errors
});
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error uploading multiple files: {ex}");
return Json(new
{
success = false,
message = $"Error: {ex.Message}"
});
}
}
#endregion
#region Download Files
/// <summary>
/// Download a file by ID
/// </summary>
[HttpGet]
public async Task<IActionResult> DownloadFile(
int fileId,
string featureName,
string entityType,
int entityId)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Unauthorized();
try
{
var currentUser = await _workContext.GetCurrentCustomerAsync();
var userId = currentUser.Id;
var (fileStream, fileInfo) = await _fileStorageService.GetFileByIdAsync(
fileId: fileId,
userId: userId,
featureName: featureName,
entityType: entityType,
entityId: entityId
);
var fileName = $"{fileInfo.FileName}{fileInfo.FileExtension}";
var contentType = GetContentType(fileInfo.FileExtension);
return File(fileStream, contentType, fileName);
}
catch (FileNotFoundException ex)
{
return NotFound(new { success = false, message = ex.Message });
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error downloading file: {ex}");
return BadRequest(new { success = false, message = ex.Message });
}
}
/// <summary>
/// Preview a file inline (for PDFs, images)
/// </summary>
[HttpGet]
public async Task<IActionResult> PreviewFile(
int fileId,
string featureName,
string entityType,
int entityId)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Unauthorized();
try
{
var currentUser = await _workContext.GetCurrentCustomerAsync();
var userId = currentUser.Id;
var (fileStream, fileInfo) = await _fileStorageService.GetFileByIdAsync(
fileId: fileId,
userId: userId,
featureName: featureName,
entityType: entityType,
entityId: entityId
);
var contentType = GetContentType(fileInfo.FileExtension);
// Return inline for preview
return File(fileStream, contentType);
}
catch (FileNotFoundException ex)
{
return NotFound(new { success = false, message = ex.Message });
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error previewing file: {ex}");
return BadRequest(new { success = false, message = ex.Message });
}
}
#endregion
#region List & Search Files
/// <summary>
/// Get all files
/// </summary>
[HttpGet]
public async Task<IActionResult> GetAllFiles()
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, message = "Access denied" });
try
{
var files = await _fileStorageService.GetAllFilesAsync();
return Json(new
{
success = true,
count = files.Count,
files = files.Select(f => new
{
id = f.Id,
fileName = f.FileName,
fileExtension = f.FileExtension,
fullName = $"{f.FileName}{f.FileExtension}",
created = f.Created,
modified = f.Modified,
hasRawText = !string.IsNullOrEmpty(f.RawText)
})
});
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error getting files: {ex}");
return Json(new { success = false, message = ex.Message });
}
}
/// <summary>
/// Search files by filename or content
/// </summary>
[HttpGet]
public async Task<IActionResult> SearchFiles(string searchTerm)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, message = "Access denied" });
if (string.IsNullOrWhiteSpace(searchTerm))
return Json(new { success = false, message = "Search term is required" });
try
{
var files = await _fileStorageService.SearchFilesAsync(searchTerm);
return Json(new
{
success = true,
searchTerm = searchTerm,
count = files.Count,
files = files.Select(f => new
{
id = f.Id,
fileName = f.FileName,
fileExtension = f.FileExtension,
fullName = $"{f.FileName}{f.FileExtension}",
created = f.Created,
hasRawText = !string.IsNullOrEmpty(f.RawText),
// Include snippet of matched text if available
textSnippet = GetTextSnippet(f.RawText, searchTerm)
})
});
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error searching files: {ex}");
return Json(new { success = false, message = ex.Message });
}
}
/// <summary>
/// Get files for a specific entity
/// </summary>
[HttpGet]
public async Task<IActionResult> GetEntityFiles(string entityType, int entityId)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, message = "Access denied" });
try
{
// Example for ShippingDocuments - adapt for other entity types
if (entityType == "ShippingDocuments")
{
var mappings = await _dbContext.ShippingDocumentToFiles
.GetAll()
.Where(m => m.ShippingDocumentId == entityId)
.ToListAsync();
var fileIds = mappings.Select(m => m.FilesId).ToList();
var files = await _dbContext.Files
.GetAll()
.Where(f => fileIds.Contains(f.Id))
.ToListAsync();
return Json(new
{
success = true,
entityType = entityType,
entityId = entityId,
count = files.Count,
files = files.Select(f => new
{
id = f.Id,
fileName = $"{f.FileName}{f.FileExtension}",
created = f.Created,
documentType = mappings.FirstOrDefault(m => m.FilesId == f.Id)?.DocumentType.ToString()
})
});
}
return Json(new
{
success = false,
message = $"Entity type '{entityType}' not supported yet"
});
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error getting entity files: {ex}");
return Json(new { success = false, message = ex.Message });
}
}
#endregion
#region Delete Files
/// <summary>
/// Delete a file by ID
/// </summary>
[HttpPost]
public async Task<IActionResult> DeleteFile(
int fileId,
string featureName,
string entityType,
int entityId)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, message = "Access denied" });
try
{
var currentUser = await _workContext.GetCurrentCustomerAsync();
var userId = currentUser.Id;
var deleted = await _fileStorageService.DeleteFileAsync(
fileId: fileId,
userId: userId,
featureName: featureName,
entityType: entityType,
entityId: entityId
);
if (deleted)
{
// Also delete mappings (example for ShippingDocuments)
if (entityType == "ShippingDocuments")
{
var mappings = await _dbContext.ShippingDocumentToFiles
.GetAll()
.Where(m => m.FilesId == fileId && m.ShippingDocumentId == entityId)
.ToListAsync();
foreach (var mapping in mappings)
{
await _dbContext.ShippingDocumentToFiles.DeleteAsync(mapping);
}
}
}
return Json(new
{
success = deleted,
message = deleted ? "File deleted successfully" : "File not found"
});
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error deleting file: {ex}");
return Json(new
{
success = false,
message = $"Error deleting file: {ex.Message}"
});
}
}
/// <summary>
/// Delete multiple files at once
/// </summary>
[HttpPost]
public async Task<IActionResult> DeleteMultipleFiles(
List<int> fileIds,
string featureName,
string entityType,
int entityId)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, message = "Access denied" });
if (fileIds == null || fileIds.Count == 0)
return Json(new { success = false, message = "No file IDs provided" });
try
{
var currentUser = await _workContext.GetCurrentCustomerAsync();
var userId = currentUser.Id;
var deletedCount = 0;
var errors = new List<string>();
foreach (var fileId in fileIds)
{
try
{
var deleted = await _fileStorageService.DeleteFileAsync(
fileId: fileId,
userId: userId,
featureName: featureName,
entityType: entityType,
entityId: entityId
);
if (deleted)
{
deletedCount++;
}
}
catch (Exception ex)
{
errors.Add($"File {fileId}: {ex.Message}");
}
}
return Json(new
{
success = deletedCount > 0,
message = $"Deleted {deletedCount} of {fileIds.Count} files",
deletedCount = deletedCount,
errors = errors
});
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error deleting multiple files: {ex}");
return Json(new
{
success = false,
message = $"Error: {ex.Message}"
});
}
}
#endregion
#region File Information
/// <summary>
/// Get file information by ID
/// </summary>
[HttpGet]
public async Task<IActionResult> GetFileInfo(int fileId)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, message = "Access denied" });
try
{
var fileEntity = await _dbContext.Files.GetByIdAsync(fileId);
if (fileEntity == null)
return NotFound(new { success = false, message = "File not found" });
return Json(new
{
success = true,
file = new
{
id = fileEntity.Id,
fileName = fileEntity.FileName,
fileExtension = fileEntity.FileExtension,
fullName = $"{fileEntity.FileName}{fileEntity.FileExtension}",
created = fileEntity.Created,
modified = fileEntity.Modified,
hasRawText = !string.IsNullOrEmpty(fileEntity.RawText),
rawTextLength = fileEntity.RawText?.Length ?? 0
}
});
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error getting file info: {ex}");
return Json(new { success = false, message = ex.Message });
}
}
/// <summary>
/// Update file metadata (RawText)
/// </summary>
[HttpPost]
public async Task<IActionResult> UpdateFileMetadata(int fileId, string rawText)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, message = "Access denied" });
try
{
var fileEntity = await _dbContext.Files.GetByIdAsync(fileId);
if (fileEntity == null)
return NotFound(new { success = false, message = "File not found" });
fileEntity.RawText = rawText;
await _fileStorageService.AddOrUpdateFileAsync(fileEntity);
return Json(new
{
success = true,
message = "File metadata updated successfully"
});
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error updating file metadata: {ex}");
return Json(new
{
success = false,
message = $"Error updating metadata: {ex.Message}"
});
}
}
#endregion
#region Helper Methods
/// <summary>
/// Get content type based on file extension
/// </summary>
private string GetContentType(string extension)
{
return extension.ToLowerInvariant() switch
{
".pdf" => "application/pdf",
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
".bmp" => "image/bmp",
".txt" => "text/plain",
".csv" => "text/csv",
".json" => "application/json",
".xml" => "application/xml",
".html" => "text/html",
".doc" => "application/msword",
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls" => "application/vnd.ms-excel",
".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".zip" => "application/zip",
".rar" => "application/x-rar-compressed",
_ => "application/octet-stream"
};
}
/// <summary>
/// Get a snippet of text around the search term
/// </summary>
private string GetTextSnippet(string text, string searchTerm, int contextLength = 100)
{
if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(searchTerm))
return null;
var index = text.IndexOf(searchTerm, StringComparison.OrdinalIgnoreCase);
if (index == -1)
return null;
var start = Math.Max(0, index - contextLength);
var length = Math.Min(text.Length - start, contextLength * 2 + searchTerm.Length);
var snippet = text.Substring(start, length);
if (start > 0)
snippet = "..." + snippet;
if (start + length < text.Length)
snippet = snippet + "...";
return snippet;
}
#endregion
}
}

View File

@ -1,5 +1,5 @@
@{ @{
Layout = "_ConfigurePlugin"; Layout = "../_FruitBankEmptyAdminLayout.cshtml";
} }
@await Component.InvokeAsync("StoreScopeConfiguration") @await Component.InvokeAsync("StoreScopeConfiguration")
@ -60,12 +60,12 @@
</div> </div>
</div> </div>
<!-- Shipping Document Section --> <!-- Shipping Document Section - Now Editable -->
<div class="form-group row" id="shippingDocumentSection" style="display:none;"> <div class="form-group row" id="shippingDocumentSection" style="display:none;">
<div class="col-md-12"> <div class="col-md-12">
<h4 class="mt-4"><i class="fas fa-shipping-fast"></i> Shipping Document Details</h4> <h4 class="mt-4"><i class="fas fa-shipping-fast"></i> Shipping Document Details</h4>
<!-- Document Info Card --> <!-- Document Info Card - Now Editable -->
<div class="card card-primary"> <div class="card card-primary">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0"><i class="fas fa-file-alt"></i> Document Information</h5> <h5 class="mb-0"><i class="fas fa-file-alt"></i> Document Information</h5>
@ -73,47 +73,66 @@
<div class="card-body"> <div class="card-body">
<div class="row"> <div class="row">
<div class="col-md-4"> <div class="col-md-4">
<strong>Document ID:</strong> <div class="form-group">
<p id="documentIdNumber" class="text-muted">-</p> <label for="editDocumentIdNumber">Document ID:</label>
<input type="text" class="form-control" id="editDocumentIdNumber">
</div>
</div> </div>
<div class="col-md-4"> <div class="col-md-8">
<strong>Partner ID:</strong> <div class="form-group">
<p id="partnerId" class="text-muted">-</p> <label for="editPartnerName">Partner:</label>
</div> <input type="text" class="form-control partner-search-input" id="editPartnerName" placeholder="Type to search partners...">
<div class="col-md-4"> <small class="form-text text-muted">
<strong>Total Pallets:</strong> <span id="partnerIdDisplay"></span>
<p id="totalPallets" class="text-muted">-</p> <span id="partnerTaxIdDisplay"></span>
</small>
</div>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-4">
<strong>PDF Filename:</strong> <div class="form-group">
<p id="pdfFileName" class="text-muted">-</p> <label for="editTotalPallets">Total Pallets:</label>
<input type="number" class="form-control" id="editTotalPallets" readonly>
<small class="form-text text-muted">Auto-calculated from items</small>
</div>
</div>
<div class="col-md-8">
<div class="form-group">
<label for="editPdfFileName">PDF Filename:</label>
<input type="text" class="form-control" id="editPdfFileName" readonly>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Shipping Items Table --> <!-- Shipping Items Table - Now Editable -->
<div class="card card-info mt-3"> <div class="card card-info mt-3">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0"><i class="fas fa-boxes"></i> Shipping Items (<span id="itemCount">0</span>)</h5> <h5 class="mb-0">
<i class="fas fa-boxes"></i> Shipping Items (<span id="itemCount">0</span>)
<button type="button" id="addItemButton" class="btn btn-sm btn-success float-right">
<i class="fas fa-plus"></i> Add Item
</button>
</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-bordered table-striped" id="shippingItemsTable"> <table class="table table-bordered table-hover" id="shippingItemsTable">
<thead class="thead-dark"> <thead class="thead-dark">
<tr> <tr>
<th>#</th> <th style="width: 50px;">#</th>
<th>Name</th> <th>Name</th>
<th>Hungarian Name</th> <th>Hungarian Name</th>
<th>Name on Document</th> <th>Name on Document</th>
<th>Product ID</th> <th style="width: 100px;">Product ID</th>
<th>Pallets</th> <th style="width: 80px;">Pallets</th>
<th>Quantity</th> <th style="width: 80px;">Quantity</th>
<th>Net Weight (kg)</th> <th style="width: 100px;">Net Weight (kg)</th>
<th>Gross Weight (kg)</th> <th style="width: 100px;">Gross Weight (kg)</th>
<th>Measurable</th> <th style="width: 100px;">Unit Cost</th>
<th style="width: 80px;">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody id="shippingItemsBody"> <tbody id="shippingItemsBody">
@ -124,6 +143,15 @@
</div> </div>
</div> </div>
<!-- Save Button -->
<div class="card card-success mt-3">
<div class="card-body">
<button type="button" id="saveShippingDocumentButton" class="btn btn-success btn-lg btn-block">
<i class="fas fa-save"></i> Save Shipping Document
</button>
</div>
</div>
<!-- Extracted Text Card (Collapsible) --> <!-- Extracted Text Card (Collapsible) -->
<div class="card card-secondary mt-3"> <div class="card card-secondary mt-3">
<div class="card-header" data-toggle="collapse" data-target="#extractedTextCollapse" style="cursor: pointer;"> <div class="card-header" data-toggle="collapse" data-target="#extractedTextCollapse" style="cursor: pointer;">
@ -146,6 +174,71 @@
</div> </div>
</div> </div>
<style>
.autocomplete-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid #ddd;
border-top: none;
max-height: 300px;
overflow-y: auto;
z-index: 1000;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.autocomplete-item {
padding: 10px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
}
.autocomplete-item:hover {
background-color: #f8f9fa;
}
.autocomplete-item:last-child {
border-bottom: none;
}
.product-search-input {
background-color: #fff3cd !important; /* Yellow for unmatched */
}
.product-search-input.matched-product {
background-color: #d1ecf1 !important; /* Light blue for matched (changeable) */
}
.partner-search-input-unmatched {
background-color: #fff3cd !important; /* Yellow for unmatched */
}
.partner-search-input-matched {
background-color: #d1ecf1 !important; /* Light blue for matched */
}
/* Add to existing styles */
.border-warning {
border: 2px solid #ffc107 !important;
}
.duplicate-warning-banner {
background-color: #fff3cd;
border: 1px solid #ffc107;
padding: 15px;
margin-bottom: 15px;
border-radius: 4px;
}
.duplicate-warning-banner i {
color: #856404;
margin-right: 10px;
}
</style>
<script> <script>
const imageFileInput = document.getElementById('imageFile'); const imageFileInput = document.getElementById('imageFile');
const customPromptInput = document.getElementById('customPrompt'); const customPromptInput = document.getElementById('customPrompt');
@ -160,8 +253,17 @@
const extractedText = document.getElementById('extractedText'); const extractedText = document.getElementById('extractedText');
const copyButton = document.getElementById('copyButton'); const copyButton = document.getElementById('copyButton');
const fileLabel = document.querySelector('.custom-file-label'); const fileLabel = document.querySelector('.custom-file-label');
const addItemButton = document.getElementById('addItemButton');
const saveShippingDocumentButton = document.getElementById('saveShippingDocumentButton');
let selectedFile = null; let selectedFile = null;
let shippingItems = [];
let currentPartner = { id: null, name: '', taxId: '' };
let originalUploadedFile = null; // Store the original file for later save
let extractedFullText = ''; // Store extracted text for file metadata
let isKnownDuplicate = false; // Track if current document is a duplicate
// Update file label and enable upload button when file is selected // Update file label and enable upload button when file is selected
imageFileInput.addEventListener('change', (event) => { imageFileInput.addEventListener('change', (event) => {
@ -204,7 +306,7 @@
shippingDocumentSection.style.display = 'none'; shippingDocumentSection.style.display = 'none';
}); });
uploadButton.addEventListener('click', async () => { uploadButton.addEventListener('click', async () => {
if (!selectedFile) { if (!selectedFile) {
showMessage('Please select a file first!', 'warning'); showMessage('Please select a file first!', 'warning');
return; return;
@ -239,14 +341,31 @@
const result = await response.json(); const result = await response.json();
if (response.ok && result.success) { if (response.ok && result.success) {
const message = result.wasConverted // ✅ Check if this is a duplicate document
? 'PDF converted and text extracted successfully!' if (result.isDuplicate) {
: 'Text extracted successfully!'; isKnownDuplicate = true; // ✅ Set duplicate flag
showMessage(message, 'success'); // Show warning message with different style
showMessage(
`⚠️ DUPLICATE DETECTED: ${result.message} You can review/edit the data or upload a different document.`,
'warning'
);
console.log('📋 Loaded existing document ID:', result.existingDocumentId);
} else {
isKnownDuplicate = false; // ✅ Clear duplicate flag
// Normal success message for new documents
const message = result.wasConverted
? 'PDF converted and text extracted successfully!'
: 'Text extracted successfully!';
showMessage(message, 'success');
}
// Display shipping document data // ✅ Store the file reference (for new documents or if user wants to create duplicate)
originalUploadedFile = selectedFile;
extractedFullText = result.shippingDocument.extractedText || '';
// Display shipping document data (works for both new and duplicate)
if (result.shippingDocument) { if (result.shippingDocument) {
displayShippingDocument(result.shippingDocument); displayShippingDocument(result.shippingDocument, result.isDuplicate);
document.getElementById('shippingDocumentSection').style.display = 'block'; document.getElementById('shippingDocumentSection').style.display = 'block';
} }
} else { } else {
@ -281,53 +400,505 @@
}); });
}); });
function displayShippingDocument(shippingDoc) { // Add new item button handler
// Populate document information addItemButton.addEventListener('click', () => {
document.getElementById('documentIdNumber').textContent = shippingDoc.documentIdNumber || 'N/A'; addNewShippingItem();
document.getElementById('partnerId').textContent = shippingDoc.partnerId || 'N/A'; });
document.getElementById('totalPallets').textContent = shippingDoc.totalPallets || '0';
document.getElementById('pdfFileName').textContent = shippingDoc.pdfFileName || 'N/A'; // Save button handler
saveShippingDocumentButton.addEventListener('click', async () => {
await saveShippingDocument();
});
function displayShippingDocument(shippingDoc, isDuplicate = false) {
// Populate document information fields
document.getElementById('editDocumentIdNumber').value = shippingDoc.documentIdNumber || '';
document.getElementById('editTotalPallets').value = shippingDoc.totalPallets || '0';
document.getElementById('editPdfFileName').value = shippingDoc.pdfFileName || '';
// ✅ OPTIONAL: Add visual indicator if duplicate
if (isDuplicate) {
const documentCard = document.querySelector('.card-primary');
documentCard.classList.add('border-warning');
// Add duplicate badge to header
const cardHeader = documentCard.querySelector('.card-header h5');
if (!cardHeader.querySelector('.badge-warning')) {
cardHeader.innerHTML += ' <span class="badge badge-warning ml-2">DUPLICATE</span>';
}
}
// Populate partner information
currentPartner = {
id: shippingDoc.partnerId || null,
name: shippingDoc.partnerName || '',
taxId: shippingDoc.partnerTaxId || ''
};
updatePartnerDisplay();
// Populate extracted text (collapsible section) // Populate extracted text (collapsible section)
extractedText.textContent = shippingDoc.extractedText || 'No text extracted'; extractedText.textContent = shippingDoc.extractedText || 'No text extracted';
// Populate shipping items table // Store items in global array
shippingItems = shippingDoc.shippingItems || [];
// Render items table
renderShippingItemsTable();
// Initialize partner autocomplete
initializePartnerAutocomplete();
}
function updatePartnerDisplay() {
const partnerNameInput = document.getElementById('editPartnerName');
const partnerIdDisplay = document.getElementById('partnerIdDisplay');
const partnerTaxIdDisplay = document.getElementById('partnerTaxIdDisplay');
if (currentPartner.id) {
partnerNameInput.value = currentPartner.name;
partnerNameInput.classList.remove('partner-search-input-unmatched');
partnerNameInput.classList.add('partner-search-input-matched');
partnerIdDisplay.textContent = `Partner ID: ${currentPartner.id}`;
partnerTaxIdDisplay.textContent = currentPartner.taxId ? ` | Tax ID: ${currentPartner.taxId}` : '';
} else {
partnerNameInput.value = '';
partnerNameInput.classList.add('partner-search-input-unmatched');
partnerNameInput.classList.remove('partner-search-input-matched');
partnerIdDisplay.textContent = 'Partner ID: Not Matched';
partnerTaxIdDisplay.textContent = '';
}
}
function renderShippingItemsTable() {
const tbody = document.getElementById('shippingItemsBody'); const tbody = document.getElementById('shippingItemsBody');
tbody.innerHTML = ''; // Clear existing rows tbody.innerHTML = ''; // Clear existing rows
const items = shippingDoc.shippingItems || []; document.getElementById('itemCount').textContent = shippingItems.length;
document.getElementById('itemCount').textContent = items.length;
if (shippingItems.length === 0) {
if (items.length === 0) { tbody.innerHTML = '<tr><td colspan="11" class="text-center text-muted">No shipping items found. Click "Add Item" to add one.</td></tr>';
tbody.innerHTML = '<tr><td colspan="10" class="text-center text-muted">No shipping items found</td></tr>';
} else { } else {
items.forEach((item, index) => { shippingItems.forEach((item, index) => {
const row = document.createElement('tr'); const row = createEditableRow(item, index);
// Add status class based on whether product is matched
if (!item.productId) {
row.classList.add('table-warning');
}
row.innerHTML = `
<td>${index + 1}</td>
<td>${escapeHtml(item.name || '-')}</td>
<td>${escapeHtml(item.hungarianName || '-')}</td>
<td>${escapeHtml(item.nameOnDocument || '-')}</td>
<td>${item.productId ? `<span class="badge badge-success">${item.productId}</span>` : '<span class="badge badge-warning">Not Matched</span>'}</td>
<td>${item.palletsOnDocument || '0'}</td>
<td>${item.quantityOnDocument || '0'}</td>
<td>${item.netWeightOnDocument ? item.netWeightOnDocument.toFixed(2) : '0.00'}</td>
<td>${item.grossWeightOnDocument ? item.grossWeightOnDocument.toFixed(2) : '0.00'}</td>
<td>${item.isMeasurable ? '<span class="badge badge-info">Yes</span>' : '<span class="badge badge-secondary">No</span>'}</td>
`;
tbody.appendChild(row); tbody.appendChild(row);
}); });
} }
// Update total pallets
updateTotalPallets();
} }
function createEditableRow(item, index) {
const row = document.createElement('tr');
// Add status class based on whether product is matched
if (!item.productId) {
row.classList.add('table-warning');
}
// Determine if product is matched or not
const isMatched = item.productId && item.productId > 0;
// Create name field - always searchable, but pre-filled if matched
const nameFieldValue = isMatched ? item.name : (item.nameOnDocument || '');
const nameFieldClass = isMatched ? 'product-search-input matched-product' : 'product-search-input';
row.innerHTML = `
<td>${index + 1}</td>
<td>
<input type="text" class="form-control form-control-sm ${nameFieldClass}"
value="${escapeHtml(nameFieldValue)}"
data-index="${index}"
placeholder="Type to search products...">
</td>
<td><input type="text" class="form-control form-control-sm" value="${escapeHtml(item.hungarianName || '')}" data-field="hungarianName" data-index="${index}" readonly></td>
<td><input type="text" class="form-control form-control-sm" value="${escapeHtml(item.nameOnDocument || '')}" data-field="nameOnDocument" data-index="${index}"></td>
<td>
${isMatched
? `<span class="badge badge-success">${item.productId}</span>`
: `<span class="badge badge-warning">Not Matched</span>`}
</td>
<td><input type="number" class="form-control form-control-sm" value="${item.palletsOnDocument || 0}" data-field="palletsOnDocument" data-index="${index}"></td>
<td><input type="number" class="form-control form-control-sm" value="${item.quantityOnDocument || 0}" data-field="quantityOnDocument" data-index="${index}"></td>
<td><input type="number" step="0.01" class="form-control form-control-sm" value="${item.netWeightOnDocument ? item.netWeightOnDocument.toFixed(2) : '0.00'}" data-field="netWeightOnDocument" data-index="${index}"></td>
<td><input type="number" step="0.01" class="form-control form-control-sm" value="${item.grossWeightOnDocument ? item.grossWeightOnDocument.toFixed(2) : '0.00'}" data-field="grossWeightOnDocument" data-index="${index}"></td>
<td><input type="number" step="0.01" class="form-control form-control-sm" value="${item.unitPriceOnDocument ? item.unitPriceOnDocument.toFixed(2) : '0.00'}" data-field="unitPriceOnDocument" data-index="${index}"></td>
<td>
<button type="button" class="btn btn-sm btn-danger" onclick="deleteShippingItem(${index})">
<i class="fas fa-trash"></i>
</button>
</td>
`;
// Add change listeners to update the data array
const inputs = row.querySelectorAll('input:not(.product-search-input), select');
inputs.forEach(input => {
input.addEventListener('change', (e) => {
const field = e.target.dataset.field;
const idx = parseInt(e.target.dataset.index);
let value = e.target.value;
// Parse numeric values
if (field === 'productId' || field === 'palletsOnDocument' || field === 'quantityOnDocument') {
value = value ? parseInt(value) : null;
} else if (field === 'netWeightOnDocument' || field === 'grossWeightOnDocument' || field === 'unitPriceOnDocument') {
value = value ? parseFloat(value) : 0;
}
shippingItems[idx][field] = value;
// Update total pallets if pallets changed
if (field === 'palletsOnDocument') {
updateTotalPallets();
}
});
});
// Initialize autocomplete for all product search inputs
const searchInput = row.querySelector('.product-search-input');
initializeProductAutocomplete(searchInput, index);
return row;
}
function addNewShippingItem() {
const newItem = {
name: '',
hungarianName: '',
nameOnDocument: '',
productId: null,
palletsOnDocument: 0,
quantityOnDocument: 0,
netWeightOnDocument: 0,
grossWeightOnDocument: 0,
unitPriceOnDocument: 0
};
shippingItems.push(newItem);
renderShippingItemsTable();
}
window.deleteShippingItem = function(index) {
if (confirm('Are you sure you want to delete this item?')) {
shippingItems.splice(index, 1);
renderShippingItemsTable();
}
};
function updateTotalPallets() {
const total = shippingItems.reduce((sum, item) => sum + (item.palletsOnDocument || 0), 0);
document.getElementById('editTotalPallets').value = total;
}
// Product autocomplete functionality
let autocompleteTimeout = null;
let currentSearchIndex = null;
let partnerAutocompleteTimeout = null;
// Partner autocomplete initialization
function initializePartnerAutocomplete() {
const partnerInput = document.getElementById('editPartnerName');
// Create autocomplete container
const wrapper = document.createElement('div');
wrapper.style.position = 'relative';
wrapper.style.width = '100%';
// Check if wrapper already exists
if (partnerInput.parentNode.querySelector('.autocomplete-results')) {
return; // Already initialized
}
partnerInput.parentNode.insertBefore(wrapper, partnerInput);
wrapper.appendChild(partnerInput);
const resultsContainer = document.createElement('div');
resultsContainer.className = 'autocomplete-results';
resultsContainer.style.display = 'none';
wrapper.appendChild(resultsContainer);
// Input event for searching
partnerInput.addEventListener('input', (e) => {
const searchTerm = e.target.value.trim();
// Clear existing timeout
if (partnerAutocompleteTimeout) {
clearTimeout(partnerAutocompleteTimeout);
}
if (searchTerm.length < 2) {
resultsContainer.style.display = 'none';
return;
}
// Debounce search
partnerAutocompleteTimeout = setTimeout(async () => {
await searchPartners(searchTerm, resultsContainer);
}, 300);
});
// Hide results when clicking outside
document.addEventListener('click', (e) => {
if (!wrapper.contains(e.target)) {
resultsContainer.style.display = 'none';
}
});
}
async function searchPartners(term, resultsContainer) {
try {
const response = await fetch(`@Url.Action("PartnerSearchAutoComplete", "FileManager")?term=${encodeURIComponent(term)}`);
const partners = await response.json();
displayPartnerResults(partners, resultsContainer);
} catch (error) {
console.error('Error searching partners:', error);
}
}
function displayPartnerResults(partners, resultsContainer) {
resultsContainer.innerHTML = '';
if (!partners || partners.length === 0) {
resultsContainer.innerHTML = '<div class="autocomplete-item">No partners found</div>';
resultsContainer.style.display = 'block';
return;
}
partners.forEach(partner => {
const item = document.createElement('div');
item.className = 'autocomplete-item';
item.innerHTML = `
<div style="font-weight: 500;">${escapeHtml(partner.name)}</div>
<div style="font-size: 0.85em; color: #666;">
Tax ID: ${escapeHtml(partner.taxId || 'N/A')} |
${escapeHtml(partner.city || '')}${partner.country ? ', ' + escapeHtml(partner.country) : ''}
</div>
`;
item.addEventListener('click', () => {
selectPartner(partner);
resultsContainer.style.display = 'none';
});
resultsContainer.appendChild(item);
});
resultsContainer.style.display = 'block';
}
function selectPartner(partner) {
const previousPartnerId = currentPartner.id;
const wasMatched = previousPartnerId && previousPartnerId > 0;
currentPartner = {
id: partner.value,
name: partner.name,
taxId: partner.taxId || ''
};
updatePartnerDisplay();
if (wasMatched) {
showMessage(`Partner changed from ID ${previousPartnerId} to: ${partner.name}`, 'info');
} else {
showMessage(`Partner matched: ${partner.name}`, 'success');
}
}
function initializeProductAutocomplete(inputElement, itemIndex) {
// Create autocomplete container
const wrapper = document.createElement('div');
wrapper.style.position = 'relative';
wrapper.style.width = '100%';
inputElement.parentNode.insertBefore(wrapper, inputElement);
wrapper.appendChild(inputElement);
const resultsContainer = document.createElement('div');
resultsContainer.className = 'autocomplete-results';
resultsContainer.style.display = 'none';
wrapper.appendChild(resultsContainer);
// Input event for searching
inputElement.addEventListener('input', (e) => {
const searchTerm = e.target.value.trim();
// Clear existing timeout
if (autocompleteTimeout) {
clearTimeout(autocompleteTimeout);
}
if (searchTerm.length < 2) {
resultsContainer.style.display = 'none';
return;
}
// Debounce search
autocompleteTimeout = setTimeout(async () => {
await searchProducts(searchTerm, resultsContainer, itemIndex);
}, 300);
});
// Hide results when clicking outside
document.addEventListener('click', (e) => {
if (!wrapper.contains(e.target)) {
resultsContainer.style.display = 'none';
}
});
}
async function searchProducts(term, resultsContainer, itemIndex) {
try {
const response = await fetch(`@Url.Action("ProductSearchUnfilteredAutoComplete", "CustomOrder")?term=${encodeURIComponent(term)}`);
const products = await response.json();
displayProductResults(products, resultsContainer, itemIndex);
} catch (error) {
console.error('Error searching products:', error);
}
}
function displayProductResults(products, resultsContainer, itemIndex) {
resultsContainer.innerHTML = '';
if (!products || products.length === 0) {
resultsContainer.innerHTML = '<div class="autocomplete-item">No products found</div>';
resultsContainer.style.display = 'block';
return;
}
products.forEach(product => {
const item = document.createElement('div');
item.className = 'autocomplete-item';
item.innerHTML = `
<div style="font-weight: 500;">${escapeHtml(product.label)}</div>
<div style="font-size: 0.85em; color: #666;">SKU: ${escapeHtml(product.sku || 'N/A')}</div>
`;
item.addEventListener('click', () => {
selectProduct(product, itemIndex);
resultsContainer.style.display = 'none';
});
resultsContainer.appendChild(item);
});
resultsContainer.style.display = 'block';
}
function selectProduct(product, itemIndex) {
// Extract product name from label (remove the stock and price info)
const productName = product.label.split('[')[0].trim();
const previousProductId = shippingItems[itemIndex].productId;
const wasMatched = previousProductId && previousProductId > 0;
// Update the shipping item with selected product
shippingItems[itemIndex] = {
...shippingItems[itemIndex],
productId: product.value,
name: productName,
hungarianName: productName,
unitPriceOnDocument: product.price || 0
};
// Re-render the table to show the matched product
renderShippingItemsTable();
if (wasMatched) {
showMessage(`Product changed from ID ${previousProductId} to: ${productName}`, 'info');
} else {
showMessage(`Product matched: ${productName}`, 'success');
}
}
async function saveShippingDocument() {
if (isKnownDuplicate) {
const confirmSave = confirm(
'This document has already been processed before. ' +
'Do you want to create a new entry with the same data? ' +
'(This will NOT create duplicate file storage, but will create a new shipping document record.)'
);
if (!confirmSave) {
showMessage('Save cancelled.', 'info');
return;
}
}
// Validate partner is selected
if (!currentPartner.id) {
showMessage('Please select a partner before saving', 'warning');
return;
}
// Collect data from form
const shippingDocument = {
documentIdNumber: document.getElementById('editDocumentIdNumber').value,
partnerId: currentPartner.id,
totalPallets: parseInt(document.getElementById('editTotalPallets').value) || 0,
pdfFileName: document.getElementById('editPdfFileName').value,
shippingItems: shippingItems
};
try {
saveShippingDocumentButton.disabled = true;
saveShippingDocumentButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Saving...';
// Get the antiforgery token
const token = document.querySelector('input[name="__RequestVerificationToken"]').value;
// Create FormData to send both JSON and file
const formData = new FormData();
// Add document data as JSON string
formData.append('documentData', JSON.stringify(shippingDocument));
// Add extracted text
formData.append('extractedText', extractedFullText);
// Add original file if available
if (originalUploadedFile) {
formData.append('originalFile', originalUploadedFile);
console.log('✓ Including original file:', originalUploadedFile.name);
} else {
console.warn('⚠ No original file to save');
}
const response = await fetch('@Url.Action("SaveShippingDocument", "FileManager")', {
method: 'POST',
headers: {
'RequestVerificationToken': token
// Don't set Content-Type - browser sets it automatically for FormData
},
body: formData
});
const result = await response.json();
if (response.ok && result.success) {
showMessage('Shipping document and file saved successfully!', 'success');
console.log('✓ Saved:', result);
// Optionally reload or redirect after save
// window.location.reload();
} else {
showMessage('Error: ' + (result.message || 'Failed to save shipping document'), 'danger');
}
} catch (error) {
console.error('Error saving shipping document:', error);
showMessage('Error: Failed to communicate with server', 'danger');
} finally {
saveShippingDocumentButton.disabled = false;
saveShippingDocumentButton.innerHTML = '<i class="fas fa-save"></i> Save Shipping Document';
}
}
function escapeHtml(text) { function escapeHtml(text) {
if (!text) return '';
const map = { const map = {
'&': '&amp;', '&': '&amp;',
'<': '&lt;', '<': '&lt;',
@ -335,7 +906,7 @@
'"': '&quot;', '"': '&quot;',
"'": '&#039;' "'": '&#039;'
}; };
return text.replace(/[&<>"']/g, m => map[m]); return text.toString().replace(/[&<>"']/g, m => map[m]);
} }
function showMessage(message, type) { function showMessage(message, type) {
@ -347,4 +918,4 @@
responseMessage.style.display = 'none'; responseMessage.style.display = 'none';
}, 5000); }, 5000);
} }
</script> </script>

View File

@ -38,7 +38,7 @@
<button id="stopPartnerBtn" class="voice-btn recording" style="display: none;"> <button id="stopPartnerBtn" class="voice-btn recording" style="display: none;">
<i class="fas fa-stop"></i> <i class="fas fa-stop"></i>
</button> </button>
<p class="voice-hint">Tap to speak partner name</p> <p class="voice-hint">Tap to speak - auto-stops on silence</p>
</div> </div>
<!-- Recording Status --> <!-- Recording Status -->
@ -202,45 +202,50 @@
let mediaRecorder = null; let mediaRecorder = null;
let audioChunks = []; let audioChunks = [];
$(document).ready(function() { // VAD (Voice Activity Detection) state
// Event listeners let audioContext = null;
$('#recordPartnerBtn').click(() => startRecording('partner')); let analyser = null;
$('#stopPartnerBtn').click(() => stopRecording('partner')); let volumeCheckInterval = null;
$('#recordProductBtn').click(() => startRecording('product')); let recordingStartTime = null;
$('#stopProductBtn').click(() => stopRecording('product')); let isRecording = false;
$('#proceedToProductsBtn').click(proceedToStep2); let baselineNoiseLevel = -60; // Will be calibrated at start
let volumeHistory = []; // Track volume over time
// Check microphone availability and permissions on load // VAD Configuration
const VAD_CONFIG = {
silenceThreshold: -50, // dB threshold for silence (will be adjusted dynamically)
silenceDuration: 1500, // 1.5 seconds of silence triggers auto-stop
minRecordingTime: 800, // Minimum 0.8 seconds before allowing auto-stop
volumeCheckInterval: 100, // Check volume every 100ms
calibrationTime: 500, // First 500ms used to calibrate noise floor
noiseGateOffset: 15, // dB above noise floor to trigger speech detection
volumeHistorySize: 10 // Keep last 10 volume samples
};
$(document).ready(function() {
// Single click to start - auto-stops on silence
$('#recordPartnerBtn').click(() => startRecording('partner'));
$('#recordProductBtn').click(() => startRecording('product'));
// Optional manual stop
$('#stopPartnerBtn').click(() => stopRecording('partner', false));
$('#stopProductBtn').click(() => stopRecording('product', false));
$('#proceedToProductsBtn').click(proceedToStep2);
checkMicrophoneAvailability(); checkMicrophoneAvailability();
}); });
async function checkMicrophoneAvailability() { async function checkMicrophoneAvailability() {
console.log('[VoiceOrder] Checking microphone availability...'); console.log('[VoiceOrder] Checking microphone availability...');
console.log('[VoiceOrder] Protocol:', window.location.protocol);
console.log('[VoiceOrder] Hostname:', window.location.hostname);
console.log('[VoiceOrder] Is secure context:', window.isSecureContext);
// Check if getUserMedia is supported
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
console.error('[VoiceOrder] getUserMedia not supported');
showWarningBanner('Your browser does not support audio recording. Please use Chrome, Firefox, or Safari.'); showWarningBanner('Your browser does not support audio recording. Please use Chrome, Firefox, or Safari.');
return; return;
} }
// Check if HTTPS (except localhost)
if (window.location.protocol !== 'https:' &&
window.location.hostname !== 'localhost' &&
window.location.hostname !== '127.0.0.1') {
console.error('[VoiceOrder] Not HTTPS');
showWarningBanner('Voice recording requires HTTPS. Please use a secure connection.');
return;
}
// Try to enumerate devices (doesn't require permission)
try { try {
const devices = await navigator.mediaDevices.enumerateDevices(); const devices = await navigator.mediaDevices.enumerateDevices();
const audioInputs = devices.filter(device => device.kind === 'audioinput'); const audioInputs = devices.filter(device => device.kind === 'audioinput');
console.log('[VoiceOrder] Audio input devices found:', audioInputs.length);
if (audioInputs.length === 0) { if (audioInputs.length === 0) {
showWarningBanner('No microphone detected. Please connect a microphone to use voice recording.'); showWarningBanner('No microphone detected. Please connect a microphone to use voice recording.');
@ -248,26 +253,6 @@
} catch (error) { } catch (error) {
console.error('[VoiceOrder] Error enumerating devices:', error); console.error('[VoiceOrder] Error enumerating devices:', error);
} }
// Check permissions API if available
if (navigator.permissions && navigator.permissions.query) {
try {
const permissionStatus = await navigator.permissions.query({ name: 'microphone' });
console.log('[VoiceOrder] Microphone permission status:', permissionStatus.state);
if (permissionStatus.state === 'denied') {
showWarningBanner('Microphone access was denied. Please enable it in your browser settings.');
}
// Listen for permission changes
permissionStatus.onchange = function() {
console.log('[VoiceOrder] Permission changed to:', this.state);
};
} catch (error) {
// Permissions API not supported or microphone permission not available
console.log('[VoiceOrder] Permissions API not available:', error.message);
}
}
} }
function showWarningBanner(message) { function showWarningBanner(message) {
@ -280,233 +265,267 @@
$('.mobile-content').prepend(banner); $('.mobile-content').prepend(banner);
} }
function getSupportedMimeType() {
const mimeTypes = [
'audio/webm',
'audio/webm;codecs=opus',
'audio/ogg;codecs=opus',
'audio/mp4'
];
for (const mimeType of mimeTypes) {
if (MediaRecorder.isTypeSupported(mimeType)) {
return mimeType;
}
}
return 'audio/webm';
}
async function startRecording(type) { async function startRecording(type) {
console.log('[VoiceOrder] ========== START RECORDING ATTEMPT =========='); console.log('[VoiceOrder] Starting recording with VAD for:', type);
console.log('[VoiceOrder] Type:', type);
console.log('[VoiceOrder] Timestamp:', new Date().toISOString());
try { try {
// Check 1: Browser support if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
console.log('[VoiceOrder] Check 1: Browser support'); alert('Your browser does not support audio recording.');
if (!navigator.mediaDevices) {
console.error('[VoiceOrder] FAIL: navigator.mediaDevices is undefined');
alert('Your browser does not support mediaDevices API. Browser: ' + navigator.userAgent);
return; return;
} }
console.log('[VoiceOrder] PASS: navigator.mediaDevices exists');
if (!navigator.mediaDevices.getUserMedia) { const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
console.error('[VoiceOrder] FAIL: getUserMedia is undefined'); console.log('[VoiceOrder] Got media stream');
alert('Your browser does not support getUserMedia. Browser: ' + navigator.userAgent);
return;
}
console.log('[VoiceOrder] PASS: getUserMedia exists');
// Check 2: Security context // Setup Web Audio API for volume detection
console.log('[VoiceOrder] Check 2: Security context'); audioContext = new (window.AudioContext || window.webkitAudioContext)();
console.log('[VoiceOrder] - Protocol:', window.location.protocol); analyser = audioContext.createAnalyser();
console.log('[VoiceOrder] - Hostname:', window.location.hostname); const source = audioContext.createMediaStreamSource(stream);
console.log('[VoiceOrder] - IsSecureContext:', window.isSecureContext); source.connect(analyser);
console.log('[VoiceOrder] - Full URL:', window.location.href); analyser.fftSize = 512;
// Check 3: Try simple audio constraint first const supportedMimeType = getSupportedMimeType();
console.log('[VoiceOrder] Check 3: Requesting microphone with simple constraints'); mediaRecorder = new MediaRecorder(stream, {
console.log('[VoiceOrder] Calling getUserMedia({ audio: true })...'); mimeType: supportedMimeType
});
let stream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
console.log('[VoiceOrder] SUCCESS: Got media stream');
console.log('[VoiceOrder] Stream ID:', stream.id);
console.log('[VoiceOrder] Stream active:', stream.active);
console.log('[VoiceOrder] Audio tracks:', stream.getAudioTracks().length);
if (stream.getAudioTracks().length > 0) {
const track = stream.getAudioTracks()[0];
console.log('[VoiceOrder] Track label:', track.label);
console.log('[VoiceOrder] Track enabled:', track.enabled);
console.log('[VoiceOrder] Track muted:', track.muted);
console.log('[VoiceOrder] Track readyState:', track.readyState);
console.log('[VoiceOrder] Track settings:', track.getSettings());
}
} catch (getUserMediaError) {
console.error('[VoiceOrder] FAIL: getUserMedia threw error');
console.error('[VoiceOrder] Error name:', getUserMediaError.name);
console.error('[VoiceOrder] Error message:', getUserMediaError.message);
console.error('[VoiceOrder] Error stack:', getUserMediaError.stack);
throw getUserMediaError;
}
// Check 4: MediaRecorder support
console.log('[VoiceOrder] Check 4: MediaRecorder support');
if (!window.MediaRecorder) {
console.error('[VoiceOrder] FAIL: MediaRecorder not supported');
stream.getTracks().forEach(track => track.stop());
alert('MediaRecorder is not supported in your browser: ' + navigator.userAgent);
return;
}
console.log('[VoiceOrder] PASS: MediaRecorder exists');
// Check 5: Check supported MIME types
console.log('[VoiceOrder] Check 5: Checking MIME type support');
const mimeTypes = [
'audio/webm',
'audio/webm;codecs=opus',
'audio/ogg;codecs=opus',
'audio/mp4',
'audio/mpeg'
];
let supportedMimeType = null;
for (const mimeType of mimeTypes) {
const isSupported = MediaRecorder.isTypeSupported(mimeType);
console.log('[VoiceOrder] - ' + mimeType + ':', isSupported);
if (isSupported && !supportedMimeType) {
supportedMimeType = mimeType;
}
}
if (!supportedMimeType) {
console.error('[VoiceOrder] FAIL: No supported MIME type found');
stream.getTracks().forEach(track => track.stop());
alert('No supported audio recording format found in your browser');
return;
}
console.log('[VoiceOrder] Using MIME type:', supportedMimeType);
// Check 6: Create MediaRecorder
console.log('[VoiceOrder] Check 6: Creating MediaRecorder');
try {
mediaRecorder = new MediaRecorder(stream, {
mimeType: supportedMimeType
});
console.log('[VoiceOrder] SUCCESS: MediaRecorder created');
console.log('[VoiceOrder] MediaRecorder state:', mediaRecorder.state);
} catch (recorderError) {
console.error('[VoiceOrder] FAIL: MediaRecorder constructor threw error');
console.error('[VoiceOrder] Error:', recorderError);
stream.getTracks().forEach(track => track.stop());
throw recorderError;
}
audioChunks = []; audioChunks = [];
recordingStartTime = Date.now();
isRecording = true;
mediaRecorder.addEventListener('dataavailable', event => { mediaRecorder.addEventListener('dataavailable', event => {
console.log('[VoiceOrder] dataavailable event, size:', event.data.size);
audioChunks.push(event.data); audioChunks.push(event.data);
}); });
mediaRecorder.addEventListener('stop', () => { mediaRecorder.addEventListener('stop', () => {
console.log('[VoiceOrder] MediaRecorder stopped');
console.log('[VoiceOrder] Audio chunks:', audioChunks.length);
const audioBlob = new Blob(audioChunks, { type: supportedMimeType }); const audioBlob = new Blob(audioChunks, { type: supportedMimeType });
console.log('[VoiceOrder] Blob created, size:', audioBlob.size);
console.log('[VoiceOrder] Blob type:', audioBlob.type);
if (audioBlob.size === 0) { if (audioBlob.size === 0) {
console.error('[VoiceOrder] WARNING: Blob size is 0!'); alert('No audio captured. Please try again.');
alert('Recording failed: No audio data captured. Please check your microphone.');
resetRecordingUI(type); resetRecordingUI(type);
return; return;
} }
processAudio(audioBlob, type); processAudio(audioBlob, type);
stream.getTracks().forEach(track => {
console.log('[VoiceOrder] Stopping track:', track.label);
track.stop();
});
});
mediaRecorder.addEventListener('error', event => {
console.error('[VoiceOrder] MediaRecorder error event:', event);
console.error('[VoiceOrder] Error:', event.error);
});
// Check 7: Start recording
console.log('[VoiceOrder] Check 7: Starting recording');
try {
mediaRecorder.start();
console.log('[VoiceOrder] SUCCESS: Recording started');
console.log('[VoiceOrder] MediaRecorder state after start:', mediaRecorder.state);
} catch (startError) {
console.error('[VoiceOrder] FAIL: start() threw error');
console.error('[VoiceOrder] Error:', startError);
stream.getTracks().forEach(track => track.stop()); stream.getTracks().forEach(track => track.stop());
throw startError;
} if (audioContext) {
audioContext.close();
audioContext = null;
}
analyser = null;
isRecording = false;
});
mediaRecorder.start();
console.log('[VoiceOrder] Recording started');
// Update UI // Update UI
if (type === 'partner') { if (type === 'partner') {
$('#recordPartnerBtn').hide(); $('#recordPartnerBtn').hide();
$('#stopPartnerBtn').show(); $('#stopPartnerBtn').show();
showStatus('partnerRecordingStatus', 'Listening...'); showStatus('partnerRecordingStatus', 'Listening... (speak now)');
} else { } else {
$('#recordProductBtn').hide(); $('#recordProductBtn').hide();
$('#stopProductBtn').show(); $('#stopProductBtn').show();
showStatus('productRecordingStatus', 'Listening...'); showStatus('productRecordingStatus', 'Listening... (speak now)');
} }
console.log('[VoiceOrder] ========== RECORDING STARTED SUCCESSFULLY =========='); // Start voice activity detection
startVoiceActivityDetection(type);
} catch (error) { } catch (error) {
console.error('[VoiceOrder] ========== RECORDING FAILED =========='); console.error('[VoiceOrder] Recording error:', error);
console.error('[VoiceOrder] Error name:', error.name);
console.error('[VoiceOrder] Error message:', error.message);
console.error('[VoiceOrder] Error stack:', error.stack);
console.error('[VoiceOrder] Full error object:', error);
let errorMessage = 'Recording failed: '; let errorMessage = 'Could not start recording: ';
if (error.name === 'NotAllowedError') {
if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') { errorMessage += 'Microphone permission denied. Please allow microphone access.';
errorMessage += 'Microphone permission was denied.\n\n'; } else if (error.name === 'NotFoundError') {
errorMessage += 'Steps to fix:\n'; errorMessage += 'No microphone found.';
errorMessage += '1. Click the 🔒 or ⓘ icon in the address bar\n';
errorMessage += '2. Find "Microphone" in permissions\n';
errorMessage += '3. Change to "Allow"\n';
errorMessage += '4. Refresh the page\n\n';
errorMessage += 'Current permission status: Check browser console for details';
} else if (error.name === 'NotFoundError' || error.name === 'DevicesNotFoundError') {
errorMessage += 'No microphone device found.\n\n';
errorMessage += 'Please check:\n';
errorMessage += '- Is a microphone connected?\n';
errorMessage += '- Are you using headphones with a mic?\n';
errorMessage += '- Try a different microphone';
} else if (error.name === 'NotReadableError' || error.name === 'TrackStartError') {
errorMessage += 'Microphone is in use by another application.\n\n';
errorMessage += 'Please:\n';
errorMessage += '- Close other apps using the microphone\n';
errorMessage += '- Close other browser tabs using microphone\n';
errorMessage += '- Restart your browser';
} else if (error.name === 'OverconstrainedError') {
errorMessage += 'Audio recording constraints not supported.\n\n';
errorMessage += 'Try: Refresh the page and try again';
} else if (error.name === 'SecurityError') {
errorMessage += 'Security error accessing microphone.\n\n';
errorMessage += 'Check:\n';
errorMessage += '- Page must use HTTPS (currently: ' + window.location.protocol + ')\n';
errorMessage += '- Valid SSL certificate\n';
errorMessage += '- No mixed content (HTTP resources on HTTPS page)';
} else if (error.name === 'TypeError') {
errorMessage += 'Browser compatibility issue.\n\n';
errorMessage += 'Your browser: ' + navigator.userAgent + '\n\n';
errorMessage += 'Try: Update your browser to the latest version';
} else { } else {
errorMessage += error.message || 'Unknown error\n\n'; errorMessage += error.message;
errorMessage += 'Browser: ' + navigator.userAgent + '\n';
errorMessage += 'Error type: ' + error.name;
} }
errorMessage += '\n\n** Check browser console (F12) for detailed technical information **';
alert(errorMessage); alert(errorMessage);
resetRecordingUI(type); resetRecordingUI(type);
} }
} }
function stopRecording(type) { function startVoiceActivityDetection(type) {
console.log('[VoiceOrder] Starting voice activity detection with noise calibration');
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
if (volumeCheckInterval) clearInterval(volumeCheckInterval);
let consecutiveSilentChecks = 0;
const silentChecksNeeded = Math.ceil(VAD_CONFIG.silenceDuration / VAD_CONFIG.volumeCheckInterval);
let isCalibrated = false;
let calibrationSamples = [];
volumeHistory = [];
volumeCheckInterval = setInterval(() => {
if (!isRecording || !analyser) {
clearInterval(volumeCheckInterval);
return;
}
analyser.getByteFrequencyData(dataArray);
let sum = 0;
for (let i = 0; i < bufferLength; i++) {
sum += dataArray[i];
}
const average = sum / bufferLength;
const volume = 20 * Math.log10(average / 255);
const recordingDuration = Date.now() - recordingStartTime;
// CALIBRATION PHASE: First 500ms - measure background noise
if (!isCalibrated && recordingDuration < VAD_CONFIG.calibrationTime) {
calibrationSamples.push(volume);
updateVolumeIndicator(volume, type, false, 'Calibrating noise floor...');
console.log('[VAD] Calibration sample:', volume.toFixed(2), 'dB');
return;
}
// Finish calibration
if (!isCalibrated && calibrationSamples.length > 0) {
// Calculate baseline as average of calibration samples
const sum = calibrationSamples.reduce((a, b) => a + b, 0);
baselineNoiseLevel = sum / calibrationSamples.length;
// Set dynamic threshold: noise floor + offset
const dynamicThreshold = baselineNoiseLevel + VAD_CONFIG.noiseGateOffset;
console.log('[VAD] Calibration complete:');
console.log('[VAD] - Baseline noise:', baselineNoiseLevel.toFixed(2), 'dB');
console.log('[VAD] - Dynamic threshold:', dynamicThreshold.toFixed(2), 'dB');
isCalibrated = true;
}
// Track volume history
volumeHistory.push(volume);
if (volumeHistory.length > VAD_CONFIG.volumeHistorySize) {
volumeHistory.shift();
}
// Calculate rolling average to smooth out spikes
const avgVolume = volumeHistory.reduce((a, b) => a + b, 0) / volumeHistory.length;
// Dynamic threshold based on calibrated noise floor
const dynamicThreshold = baselineNoiseLevel + VAD_CONFIG.noiseGateOffset;
console.log('[VAD] Volume:', volume.toFixed(2), 'dB | Avg:', avgVolume.toFixed(2), 'dB | Threshold:', dynamicThreshold.toFixed(2), 'dB | Baseline:', baselineNoiseLevel.toFixed(2), 'dB');
updateVolumeIndicator(volume, type, true, null);
// Don't check for silence yet if still in minimum recording time
if (recordingDuration < VAD_CONFIG.minRecordingTime) {
return;
}
// Check if silent (using dynamic threshold and rolling average)
if (avgVolume < dynamicThreshold) {
consecutiveSilentChecks++;
console.log('[VAD] Silent check:', consecutiveSilentChecks, '/', silentChecksNeeded);
if (consecutiveSilentChecks >= silentChecksNeeded) {
console.log('[VAD] Silence detected - auto-stopping');
clearInterval(volumeCheckInterval);
stopRecording(type, true);
}
} else {
// Reset silence counter when sound detected above threshold
if (consecutiveSilentChecks > 0) {
console.log('[VAD] Sound detected, resetting silence counter');
}
consecutiveSilentChecks = 0;
}
}, VAD_CONFIG.volumeCheckInterval);
}
function updateVolumeIndicator(volume, type, showIndicator = true, customMessage = null) {
const statusId = type === 'partner' ? 'partnerRecordingStatus' : 'productRecordingStatus';
const statusEl = $('#' + statusId);
// Use dynamic threshold if calibrated, otherwise use baseline
const threshold = baselineNoiseLevel + VAD_CONFIG.noiseGateOffset;
const volumeAboveThreshold = volume - threshold;
// Normalize volume relative to threshold (0-100 scale)
// 0dB above threshold = 0%, 30dB above threshold = 100%
const normalizedVolume = Math.max(0, Math.min(100, ((volumeAboveThreshold + 10) / 40) * 100));
let statusText = customMessage || 'Listening...';
let barClass = 'volume-bar-silent';
if (showIndicator && !customMessage) {
if (normalizedVolume > 60) {
statusText = 'Listening... 🔊 (loud and clear!)';
barClass = 'volume-bar-high';
} else if (normalizedVolume > 30) {
statusText = 'Listening... 🔉 (speaking detected)';
barClass = 'volume-bar-medium';
} else if (normalizedVolume > 10) {
statusText = 'Listening... 🔈 (speak louder)';
barClass = 'volume-bar-low';
} else {
statusText = 'Listening... (waiting for voice...)';
barClass = 'volume-bar-silent';
}
}
statusEl.find('span').text(statusText);
if (statusEl.find('.volume-bar').length === 0) {
statusEl.append('<div class="volume-bar-container"><div class="volume-bar"></div></div>');
}
statusEl.find('.volume-bar')
.removeClass('volume-bar-low volume-bar-medium volume-bar-high volume-bar-silent')
.addClass(barClass)
.css('width', normalizedVolume + '%');
}
function stopRecording(type, autoStopped = false) {
console.log('[VoiceOrder] Stopping recording', autoStopped ? '(auto)' : '(manual)');
if (volumeCheckInterval) {
clearInterval(volumeCheckInterval);
volumeCheckInterval = null;
}
if (mediaRecorder && mediaRecorder.state !== 'inactive') { if (mediaRecorder && mediaRecorder.state !== 'inactive') {
const statusId = type === 'partner' ? 'partnerRecordingStatus' : 'productRecordingStatus';
if (autoStopped) {
showStatus(statusId, 'Processing... (silence detected)');
} else {
showStatus(statusId, 'Processing...');
}
mediaRecorder.stop(); mediaRecorder.stop();
showStatus(type === 'partner' ? 'partnerRecordingStatus' : 'productRecordingStatus', 'Processing...');
} }
} }
@ -526,7 +545,6 @@
}); });
const result = await response.json(); const result = await response.json();
resetRecordingUI(type); resetRecordingUI(type);
if (result.success) { if (result.success) {
@ -548,7 +566,6 @@
function handlePartnerTranscription(result) { function handlePartnerTranscription(result) {
$('#partnerTranscribedText').text(result.transcription); $('#partnerTranscribedText').text(result.transcription);
$('#partnerTranscribedCard').show(); $('#partnerTranscribedCard').show();
displayPartnerMatches(result.partners); displayPartnerMatches(result.partners);
} }
@ -583,7 +600,6 @@
function selectPartner(partner) { function selectPartner(partner) {
selectedPartnerId = partner.value; selectedPartnerId = partner.value;
selectedPartnerName = partner.label; selectedPartnerName = partner.label;
$('#selectedPartnerName').text(partner.label); $('#selectedPartnerName').text(partner.label);
$('#partnerMatchesCard').hide(); $('#partnerMatchesCard').hide();
$('#selectedPartnerCard').show(); $('#selectedPartnerCard').show();
@ -592,7 +608,6 @@
function resetPartnerSelection() { function resetPartnerSelection() {
selectedPartnerId = null; selectedPartnerId = null;
selectedPartnerName = ""; selectedPartnerName = "";
$('#partnerTranscribedCard').hide(); $('#partnerTranscribedCard').hide();
$('#partnerMatchesCard').hide(); $('#partnerMatchesCard').hide();
$('#selectedPartnerCard').hide(); $('#selectedPartnerCard').hide();
@ -602,7 +617,6 @@
async function submitManualPartner() { async function submitManualPartner() {
const text = $('#manualPartnerInput').val().trim(); const text = $('#manualPartnerInput').val().trim();
if (!text) { if (!text) {
alert('Please enter a partner name'); alert('Please enter a partner name');
return; return;
@ -633,31 +647,24 @@
} }
} catch (error) { } catch (error) {
$('#partnerRecordingStatus').hide(); $('#partnerRecordingStatus').hide();
console.error('Error searching partners:', error); console.error('Error:', error);
alert('Error: ' + error.message); alert('Error: ' + error.message);
} }
} }
function proceedToStep2() { function proceedToStep2() {
currentStep = 2; currentStep = 2;
// Update progress dots
$('#dot1').removeClass('active').addClass('completed'); $('#dot1').removeClass('active').addClass('completed');
$('#dot2').addClass('active'); $('#dot2').addClass('active');
// Update UI
$('#step1').removeClass('active').hide(); $('#step1').removeClass('active').hide();
$('#step2').addClass('active').show(); $('#step2').addClass('active').show();
$('#partnerSummary').text(selectedPartnerName); $('#partnerSummary').text(selectedPartnerName);
// Scroll to top
$('.mobile-content').scrollTop(0); $('.mobile-content').scrollTop(0);
} }
function handleProductTranscription(result) { function handleProductTranscription(result) {
$('#productTranscribedText').text(result.transcription); $('#productTranscribedText').text(result.transcription);
$('#productTranscribedCard').show(); $('#productTranscribedCard').show();
displayProductMatches(result.products); displayProductMatches(result.products);
} }
@ -671,7 +678,6 @@
return; return;
} }
// Group by search term
const grouped = {}; const grouped = {};
products.forEach(p => { products.forEach(p => {
const term = p.searchTerm || 'other'; const term = p.searchTerm || 'other';
@ -724,7 +730,6 @@
value="${product.price.toFixed(0)}" value="${product.price.toFixed(0)}"
min="0" min="0"
step="1" step="1"
data-product-id="${product.id}"
onclick="event.stopPropagation()"> onclick="event.stopPropagation()">
<span class="price-unit">Ft</span> <span class="price-unit">Ft</span>
`); `);
@ -752,8 +757,6 @@
}); });
updateOrderItemsDisplay(); updateOrderItemsDisplay();
// Hide product matches after adding
$('#productMatchesCard').hide(); $('#productMatchesCard').hide();
$('#productTranscribedCard').hide(); $('#productTranscribedCard').hide();
$('#recordProductBtn').show(); $('#recordProductBtn').show();
@ -805,7 +808,6 @@
window.removeItem = function(index) { window.removeItem = function(index) {
orderItems.splice(index, 1); orderItems.splice(index, 1);
updateOrderItemsDisplay(); updateOrderItemsDisplay();
if (orderItems.length === 0) { if (orderItems.length === 0) {
$('#orderItemsCard').hide(); $('#orderItemsCard').hide();
} }
@ -820,7 +822,6 @@
async function submitManualProducts() { async function submitManualProducts() {
const text = $('#manualProductInput').val().trim(); const text = $('#manualProductInput').val().trim();
if (!text) { if (!text) {
alert('Please enter some products'); alert('Please enter some products');
return; return;
@ -865,8 +866,6 @@
$('#finishOrderBtn').prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Creating...'); $('#finishOrderBtn').prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Creating...');
try { try {
const orderProductsJson = JSON.stringify(orderItems);
const response = await fetch('@Url.Action("Create", "CustomOrder")', { const response = await fetch('@Url.Action("Create", "CustomOrder")', {
method: 'POST', method: 'POST',
headers: { headers: {
@ -875,7 +874,7 @@
}, },
body: new URLSearchParams({ body: new URLSearchParams({
customerId: selectedPartnerId, customerId: selectedPartnerId,
orderProductsJson: orderProductsJson, orderProductsJson: JSON.stringify(orderItems),
__RequestVerificationToken: $('input[name="__RequestVerificationToken"]').val() __RequestVerificationToken: $('input[name="__RequestVerificationToken"]').val()
}) })
}); });
@ -883,7 +882,6 @@
if (response.redirected) { if (response.redirected) {
const url = new URL(response.url); const url = new URL(response.url);
const orderId = url.searchParams.get('id'); const orderId = url.searchParams.get('id');
if (orderId) { if (orderId) {
showSuccess(orderId); showSuccess(orderId);
} else { } else {
@ -917,22 +915,12 @@
$('#dot1').removeClass('completed').addClass('active'); $('#dot1').removeClass('completed').addClass('active');
$('#dot2').removeClass('active completed'); $('#dot2').removeClass('active completed');
$('#successCard').hide(); $('#successCard').hide();
$('#step2').hide(); $('#step2').hide();
$('#step1').show().addClass('active'); $('#step1').show().addClass('active');
$('#partnerTranscribedCard, #partnerMatchesCard, #selectedPartnerCard, #productTranscribedCard, #productMatchesCard, #orderItemsCard').hide();
$('#partnerTranscribedCard').hide();
$('#partnerMatchesCard').hide();
$('#selectedPartnerCard').hide();
$('#productTranscribedCard').hide();
$('#productMatchesCard').hide();
$('#orderItemsCard').hide();
$('#recordPartnerBtn').show(); $('#recordPartnerBtn').show();
$('#manualPartnerInput').val(''); $('#manualPartnerInput, #manualProductInput').val('');
$('#manualProductInput').val('');
$('.mobile-content').scrollTop(0); $('.mobile-content').scrollTop(0);
} }
@ -955,24 +943,13 @@
</script> </script>
<style> <style>
/* Mobile-First Reset */
.voice-order-mobile { .voice-order-mobile {
/* position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0; */
background: #f5f7fa; background: #f5f7fa;
overflow: hidden; overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
} }
/* Fixed Header */
.mobile-header { .mobile-header {
/* position: fixed;
top: 0;
left: 0;
right: 0; */
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white; color: white;
padding: 1rem; padding: 1rem;
@ -986,7 +963,6 @@
font-weight: 600; font-weight: 600;
} }
/* Progress Dots */
.progress-dots { .progress-dots {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@ -1011,19 +987,12 @@
background: #4ade80; background: #4ade80;
} }
/* Scrollable Content */
.mobile-content { .mobile-content {
/* position: absolute;
top: 90px;
left: 0;
right: 0;
bottom: 0; */
overflow-y: auto; overflow-y: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
padding: 1rem; padding: 1rem;
} }
/* Step Container */
.step-container { .step-container {
display: none; display: none;
animation: slideIn 0.3s ease-out; animation: slideIn 0.3s ease-out;
@ -1045,7 +1014,6 @@
} }
} }
/* Step Title */
.step-title { .step-title {
text-align: center; text-align: center;
margin-bottom: 2rem; margin-bottom: 2rem;
@ -1070,7 +1038,6 @@
margin: 0.5rem 0 0 0; margin: 0.5rem 0 0 0;
} }
/* Voice Button (Hero Element) */
.voice-button-container { .voice-button-container {
text-align: center; text-align: center;
margin: 2rem 0; margin: 2rem 0;
@ -1141,7 +1108,6 @@
font-size: 0.9rem; font-size: 0.9rem;
} }
/* Status Indicator */
.status-indicator { .status-indicator {
text-align: center; text-align: center;
padding: 1rem; padding: 1rem;
@ -1151,6 +1117,12 @@
box-shadow: 0 2px 8px rgba(0,0,0,0.05); box-shadow: 0 2px 8px rgba(0,0,0,0.05);
} }
.status-indicator span {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.spinner { .spinner {
display: inline-block; display: inline-block;
width: 20px; width: 20px;
@ -1169,7 +1141,50 @@
} }
} }
/* OR Divider */ /* Volume Bar for VAD */
.volume-bar-container {
width: 100%;
height: 8px;
background: #e5e7eb;
border-radius: 4px;
margin-top: 0.75rem;
overflow: hidden;
}
.volume-bar {
height: 100%;
transition: width 0.1s ease-out, background-color 0.3s;
border-radius: 4px;
}
.volume-bar-silent {
background: #9ca3af;
width: 0%;
}
.volume-bar-low {
background: linear-gradient(90deg, #fbbf24 0%, #f59e0b 100%);
}
.volume-bar-medium {
background: linear-gradient(90deg, #10b981 0%, #059669 100%);
}
.volume-bar-high {
background: linear-gradient(90deg, #3b82f6 0%, #2563eb 100%);
animation: pulse-volume 0.5s infinite;
}
@@keyframes pulse-volume {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.divider-or { .divider-or {
text-align: center; text-align: center;
color: #9ca3af; color: #9ca3af;
@ -1178,8 +1193,7 @@
position: relative; position: relative;
} }
.divider-or:before, .divider-or:before, .divider-or:after {
.divider-or:after {
content: ''; content: '';
position: absolute; position: absolute;
top: 50%; top: 50%;
@ -1196,7 +1210,6 @@
right: 0; right: 0;
} }
/* Input Container */
.input-container { .input-container {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@ -1233,7 +1246,6 @@
transform: scale(0.95); transform: scale(0.95);
} }
/* Result Card */
.result-card { .result-card {
background: white; background: white;
border-radius: 12px; border-radius: 12px;
@ -1255,7 +1267,6 @@
color: #1f2937; color: #1f2937;
} }
/* Matches Container */
.matches-container { .matches-container {
margin: 1.5rem 0; margin: 1.5rem 0;
} }
@ -1276,7 +1287,6 @@
gap: 0.75rem; gap: 0.75rem;
} }
/* Product Card Wrapper */
.product-card-wrapper { .product-card-wrapper {
background: white; background: white;
border-radius: 12px; border-radius: 12px;
@ -1288,7 +1298,6 @@
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.2); box-shadow: 0 2px 8px rgba(245, 158, 11, 0.2);
} }
/* List Item Button */
.list-item-btn { .list-item-btn {
width: 100%; width: 100%;
padding: 1rem; padding: 1rem;
@ -1325,7 +1334,6 @@
color: #1f2937; color: #1f2937;
} }
/* Product Item Button */
.product-item-btn { .product-item-btn {
width: 100%; width: 100%;
padding: 1rem; padding: 1rem;
@ -1349,15 +1357,6 @@
transform: scale(0.98); transform: scale(0.98);
} }
.product-card-wrapper:hover .product-item-btn {
background: #f9fafb;
}
.product-card-wrapper.has-warning:hover .product-item-btn {
background: #fef3c7;
}
/* Price Editor */
.price-editor { .price-editor {
display: flex; display: flex;
align-items: center; align-items: center;
@ -1408,11 +1407,6 @@
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
} }
.price-input::-webkit-inner-spin-button,
.price-input::-webkit-outer-spin-button {
opacity: 1;
}
.price-unit { .price-unit {
font-size: 0.875rem; font-size: 0.875rem;
color: #6b7280; color: #6b7280;
@ -1482,11 +1476,6 @@
} }
} }
.price {
font-weight: 600;
color: #1f2937;
}
.stock { .stock {
color: #10b981; color: #10b981;
} }
@ -1504,7 +1493,6 @@
margin: 1rem 0 0.5rem 0; margin: 1rem 0 0.5rem 0;
} }
/* Selected Card */
.selected-card { .selected-card {
background: linear-gradient(135deg, #10b981 0%, #059669 100%); background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: white; color: white;
@ -1537,7 +1525,6 @@
text-decoration: underline; text-decoration: underline;
} }
/* Primary Button */
.btn-primary-mobile { .btn-primary-mobile {
width: 100%; width: 100%;
padding: 1rem; padding: 1rem;
@ -1562,7 +1549,6 @@
cursor: not-allowed; cursor: not-allowed;
} }
/* Secondary Button */
.btn-secondary-mobile { .btn-secondary-mobile {
width: 100%; width: 100%;
padding: 1rem; padding: 1rem;
@ -1581,7 +1567,6 @@
transform: scale(0.98); transform: scale(0.98);
} }
/* Partner Summary Bar */
.partner-summary { .partner-summary {
background: white; background: white;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
@ -1599,7 +1584,6 @@
color: #667eea; color: #667eea;
} }
/* Order Summary */
.order-summary { .order-summary {
background: white; background: white;
border-radius: 16px; border-radius: 16px;
@ -1688,7 +1672,6 @@
font-weight: 600; font-weight: 600;
} }
/* Success Screen */
.success-screen { .success-screen {
text-align: center; text-align: center;
padding: 2rem 0; padding: 2rem 0;
@ -1731,7 +1714,6 @@
padding: 2rem; padding: 2rem;
} }
/* Permission Warning Banner */
.permission-warning { .permission-warning {
background: #fef3c7; background: #fef3c7;
border: 2px solid #f59e0b; border: 2px solid #f59e0b;
@ -1751,7 +1733,6 @@
flex-shrink: 0; flex-shrink: 0;
} }
/* Responsive adjustments */
@@media (max-width: 375px) { @@media (max-width: 375px) {
.voice-btn { .voice-btn {
width: 100px; width: 100px;
@ -1760,7 +1741,6 @@
} }
} }
/* Prevent zoom on input focus (iOS) */
@@media screen and (max-width: 768px) { @@media screen and (max-width: 768px) {
input, select, textarea { input, select, textarea {
font-size: 16px; font-size: 16px;

View File

@ -0,0 +1,146 @@
@inject IWebHelper webHelper
@inject IDateTimeHelper dateTimeHelper
@inject IPermissionService permissionService
@inject ICustomerService customerService
@inject IEventPublisher eventPublisher
@inject LocalizationSettings localizationSettings
@inject StoreInformationSettings storeInformationSettings
@inject Nop.Services.Localization.ILanguageService languageService
@using Nop.Core.Domain
@using Nop.Core.Domain.Localization
@using Nop.Services.Customers
@using Nop.Services.Helpers
@using Nop.Services.Security
@*
@Html.DevExpress().GetStyleSheets()
@Html.DevExpress().GetScripts() *@
@{
var returnUrl = webHelper.GetRawUrl(Context.Request);
//page title
string adminPageTitle = !string.IsNullOrWhiteSpace(ViewBag.PageTitle) ? ViewBag.PageTitle + " / " : "";
adminPageTitle += T("Admin.PageTitle").Text;
//has "Manage Maintenance" permission?
var canManageMaintenance = await permissionService.AuthorizeAsync(StandardPermission.System.MANAGE_MAINTENANCE);
//avatar
var currentCustomer = await workContext.GetCurrentCustomerAsync();
//event
await eventPublisher.PublishAsync(new PageRenderingEvent(NopHtml));
//info: we specify "Admin" area for actions and widgets here for cases when we use this layout in a plugin that is running in a different area than "admin"
}
<!DOCTYPE html>
<html lang="@CultureInfo.CurrentUICulture.TwoLetterISOLanguageName" dir="@Html.GetUIDirection(localizationSettings.IgnoreRtlPropertyForAdminArea)">
<head>
<title>@adminPageTitle</title>
<meta http-equiv="Content-type" content="text/html;charset=UTF-8" />
<meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport">
@NopHtml.GenerateHeadCustom()
@* CSS & Script resources *@
@{
await Html.RenderPartialAsync("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/_AdminScripts.cshtml");
}
@*Insert favicon and app icons head code*@
@await Component.InvokeAsync(typeof(FaviconViewComponent))
</head>
<body class="hold-transition sidebar-mini layout-fixed control-sidebar-slide-open">
<div class="throbber">
<div class="curtain">
</div>
<div class="curtain-content">
<div>
<h1 class="throbber-header">@T("Common.Wait")</h1>
<p>
<img src="@Url.Content("~/css/admin/images/throbber-synchronizing.gif")" alt="" />
</p>
</div>
</div>
</div>
<div id="ajaxBusy">
<span>&nbsp;</span>
</div>
<div class="wrapper">
<div class="">
@await Html.PartialAsync("~/Areas/Admin/Views/Shared/Notifications.cshtml")
<nop-antiforgery-token />
@RenderBody()
</div>
@* <div class="main-footer">
<div class="container-fluid">
<div class="col-md-12">
<div class="row">
@if (!storeInformationSettings.HidePoweredByNopCommerce)
{
<div class="col-md-4 col-xs-12 text-md-left text-center">
Would you like to remove the "Powered by nopCommerce" link in the bottom of the footer?
Please find more info at https://www.nopcommerce.com/nopcommerce-copyright-removal-key
Powered by <a href="@(OfficialSite.Main + Utm.OnAdminFooter)" target="_blank">nopCommerce</a>
</div>
}
else
{
<div class="col-md-4 col-xs-12"></div>
}
<div class="col-md-4 col-xs-12 text-center">
@((await dateTimeHelper.ConvertToUserTimeAsync(DateTime.Now)).ToString("f", CultureInfo.CurrentCulture))
</div>
<div class="col-md-4 col-xs-12 text-md-right text-center">
<b>nopCommerce version @NopVersion.FULL_VERSION</b>
</div>
</div>
</div>
</div>
</div> *@
</div>
<script>
var AdminLTEOptions = {
boxWidgetOptions: {
boxWidgetIcons: {
collapse: 'fa-minus',
open: 'fa-plus'
}
}
};
</script>
@{
//scroll to a selected card (if specified)
var selectedCardName = Html.GetSelectedCardName();
if (!String.IsNullOrEmpty(selectedCardName))
{
<script>
location.hash = '#@(selectedCardName)';
</script>
}
}
<a id="backTop" class="btn btn-back-top bg-teal"></a>
<script>
$(function() {
//enable "back top" arrow
$('#backTop').backTop();
//enable tooltips
$('[data-toggle="tooltip"]').tooltip({ placement: 'bottom' });
});
</script>
@NopHtml.GenerateScripts(ResourceLocation.Footer)
@NopHtml.GenerateInlineScripts(ResourceLocation.Footer)
</body>
</html>

View File

@ -25,6 +25,7 @@ using Nop.Plugin.Misc.FruitBankPlugin.Factories;
using Nop.Plugin.Misc.FruitBankPlugin.Filters; using Nop.Plugin.Misc.FruitBankPlugin.Filters;
using Nop.Plugin.Misc.FruitBankPlugin.Models; using Nop.Plugin.Misc.FruitBankPlugin.Models;
using Nop.Plugin.Misc.FruitBankPlugin.Services; using Nop.Plugin.Misc.FruitBankPlugin.Services;
using Nop.Plugin.Misc.FruitBankPlugin.Services.FileStorage;
using Nop.Services.Catalog; using Nop.Services.Catalog;
using Nop.Services.Common; using Nop.Services.Common;
using Nop.Services.Events; using Nop.Services.Events;
@ -118,6 +119,15 @@ public class PluginNopStartup : INopStartup
services.AddScoped<AICalculationService>(); services.AddScoped<AICalculationService>();
services.AddScoped<PdfToImageService>(); services.AddScoped<PdfToImageService>();
services.AddSingleton<IFileStorageProvider>(sp =>
new LocalFileStorageProvider() // Uses default wwwroot/uploads
// Or specify custom path:
// new LocalFileStorageProvider(Path.Combine(Directory.GetCurrentDirectory(), "MyCustomPath"))
);
// Register the file storage service
services.AddScoped<FileStorageService>();
services.AddControllersWithViews(options => services.AddControllersWithViews(options =>
{ {
options.Filters.AddService<PendingMeasurementCheckoutFilter>(); options.Filters.AddService<PendingMeasurementCheckoutFilter>();

View File

@ -55,11 +55,6 @@
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory> <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content> </Content>
<Content Include="Areas\Admin\Views\Product\List.cshtml">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="Areas\Admin\Views\_ViewStart.cshtml"> <Content Include="Areas\Admin\Views\_ViewStart.cshtml">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile> <ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory> <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
@ -96,6 +91,7 @@
<Folder Include="Areas\Admin\Extensions\" /> <Folder Include="Areas\Admin\Extensions\" />
<Folder Include="Areas\Admin\Factories\" /> <Folder Include="Areas\Admin\Factories\" />
<Folder Include="Areas\Admin\Validators\" /> <Folder Include="Areas\Admin\Validators\" />
<Folder Include="Areas\Admin\Views\Product\" />
<Folder Include="Domains\Entities\" /> <Folder Include="Domains\Entities\" />
<Folder Include="Extensions\" /> <Folder Include="Extensions\" />
<Folder Include="Validators\" /> <Folder Include="Validators\" />
@ -212,6 +208,9 @@
<None Update="Areas\Admin\Views\Order\List.cshtml"> <None Update="Areas\Admin\Views\Order\List.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None> </None>
<None Update="Areas\Admin\Views\Product\List.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\Shipping\Edit.cshtml"> <None Update="Areas\Admin\Views\Shipping\Edit.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None> </None>
@ -224,6 +223,9 @@
<None Update="Areas\Admin\Views\VoiceOrder\Create.cshtml"> <None Update="Areas\Admin\Views\VoiceOrder\Create.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None> </None>
<None Update="Areas\Admin\Views\_FruitBankEmptyAdminLayout.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\_FruitBankAdminLayout.cshtml"> <None Update="Areas\Admin\Views\_FruitBankAdminLayout.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None> </None>

View File

@ -121,30 +121,32 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
var shippingsListMenuItem = new AdminMenuItem var shippingsListMenuItem = new AdminMenuItem
{ {
Visible = true, Visible = true,
OpenUrlInNewTab = true,
SystemName = "FruitBank", SystemName = "FruitBank",
Title = await _localizationService.GetResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.ShippingsList"), // You can localize this with await _localizationService.GetResourceAsync("...") Title = await _localizationService.GetResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.ShippingsList"), // You can localize this with await _localizationService.GetResourceAsync("...")
IconClass = "fas fa-shipping-fast", IconClass = "fas fa-shipping-fast",
Url = _adminMenu.GetMenuItemUrl("Shipping", "List") //Url = _adminMenu.GetMenuItemUrl("Shipping", "List")
Url = "https://app.fruitbank.hu"
}; };
var createShippingMenuItem = new AdminMenuItem //var createShippingMenuItem = new AdminMenuItem
{ //{
Visible = true, // Visible = true,
SystemName = "Shippings.Create", // SystemName = "Shippings.Create",
Title = "Create Shipping", // Title = "Create Shipping",
IconClass = "far fa-circle", // IconClass = "far fa-circle",
Url = _adminMenu.GetMenuItemUrl("Shipping", "Create") // Url = _adminMenu.GetMenuItemUrl("Shipping", "Create")
}; //};
var editShippingMenuItem = new AdminMenuItem //var editShippingMenuItem = new AdminMenuItem
{ //{
Visible = true, // Visible = true,
SystemName = "Shippings.Edit", // SystemName = "Shippings.Edit",
Title = "Edit Shipping", // Title = "Edit Shipping",
IconClass = "far fa-circle", // IconClass = "far fa-circle",
Url = _adminMenu.GetMenuItemUrl("Shipping", "Edit") // Url = _adminMenu.GetMenuItemUrl("Shipping", "Edit")
}; //};
// Create a new top-level menu item // Create a new top-level menu item
var shippingsMenuItem = new AdminMenuItem var shippingsMenuItem = new AdminMenuItem
@ -154,12 +156,44 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
Title = await _localizationService.GetResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.Shippings"), // You can localize this with await _localizationService.GetResourceAsync("...") Title = await _localizationService.GetResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.Shippings"), // You can localize this with await _localizationService.GetResourceAsync("...")
IconClass = "fas fa-shipping-fast", IconClass = "fas fa-shipping-fast",
//Url = _adminMenu.GetMenuItemUrl("Shipping", "List") //Url = _adminMenu.GetMenuItemUrl("Shipping", "List")
ChildNodes = [shippingsListMenuItem, createShippingMenuItem, editShippingMenuItem] //ChildNodes = [shippingsListMenuItem, createShippingMenuItem, editShippingMenuItem]
ChildNodes = [shippingsListMenuItem]
}; };
var shippingConfigurationItem = rootNode; var shippingConfigurationItem = rootNode;
shippingConfigurationItem.ChildNodes.Insert(2, shippingsMenuItem); shippingConfigurationItem.ChildNodes.Insert(2, shippingsMenuItem);
// Create a new top-level menu item
var voiceOrderMenuItem = new AdminMenuItem
{
Visible = true,
SystemName = "FruitBank",
Title = await _localizationService.GetResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.VoiceOrder"), // You can localize this with await _localizationService.GetResourceAsync("...")
IconClass = "fas fa-microphone",
Url = _adminMenu.GetMenuItemUrl("VoiceOrder", "Create")
//ChildNodes = [shippingsListMenuItem, createShippingMenuItem, editShippingMenuItem]
};
shippingConfigurationItem.ChildNodes.Insert(3, voiceOrderMenuItem);
// Create a new top-level menu item
var InvoiceSyncMenuItem = new AdminMenuItem
{
Visible = true,
SystemName = "FruitBank",
Title = await _localizationService.GetResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.InnVoiceOrderSync"), // You can localize this with await _localizationService.GetResourceAsync("...")
IconClass = "fas fa-file-invoice",
Url = _adminMenu.GetMenuItemUrl("InnVoiceOrderSync", "Index")
//ChildNodes = [shippingsListMenuItem, createShippingMenuItem, editShippingMenuItem]
};
shippingConfigurationItem.ChildNodes.Insert(4, InvoiceSyncMenuItem);
var invoiceListMenuItem = new AdminMenuItem var invoiceListMenuItem = new AdminMenuItem
{ {

View File

@ -0,0 +1,378 @@
using FruitBank.Common.Entities;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Security.Cryptography;
using System.Threading.Tasks;
namespace Nop.Plugin.Misc.FruitBankPlugin.Services.FileStorage
{
/// <summary>
/// Generic file storage service with compression, hash calculation, and duplicate detection
/// </summary>
public class FileStorageService
{
private readonly IFileStorageProvider _storageProvider;
private readonly FruitBankDbContext _dbContext;
// File extensions that are already compressed (don't GZip these)
private static readonly HashSet<string> CompressedExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", // Images
".pdf", // PDFs
".zip", ".rar", ".7z", ".gz", ".bz2", // Archives
".mp4", ".avi", ".mov", ".mkv", // Videos
".mp3", ".flac", ".aac", ".ogg" // Audio
};
public FileStorageService(IFileStorageProvider storageProvider, FruitBankDbContext dbContext)
{
_storageProvider = storageProvider ?? throw new ArgumentNullException(nameof(storageProvider));
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
}
/// <summary>
/// Saves a file with optional compression, hash calculation, and duplicate detection
/// </summary>
/// <param name="fileStream">The file stream to save</param>
/// <param name="fileName">Original filename with extension</param>
/// <param name="userId">User ID for path organization</param>
/// <param name="featureName">Feature name (e.g., "ShippingDocumentProcessing")</param>
/// <param name="entityType">Entity type (e.g., "ShippingDocuments")</param>
/// <param name="entityId">Entity ID</param>
/// <param name="rawText">Optional raw text content (for AI-extracted documents)</param>
/// <param name="checkForDuplicates">If true, checks if file already exists by hash</param>
/// <returns>Created or existing Files entity with ID</returns>
public async Task<Files> SaveFileAsync(
Stream fileStream,
string fileName,
int userId,
string featureName,
string entityType,
int entityId,
string rawText = null,
bool checkForDuplicates = true)
{
if (fileStream == null)
throw new ArgumentNullException(nameof(fileStream));
if (string.IsNullOrWhiteSpace(fileName))
throw new ArgumentNullException(nameof(fileName));
// ✅ STEP 1: Calculate file hash from original stream
string fileHash = await CalculateFileHashAsync(fileStream);
fileStream.Position = 0; // Reset stream position after hashing
Console.WriteLine($"📝 File hash calculated: {fileHash}");
// ✅ STEP 2: Check for duplicate file by hash
if (checkForDuplicates)
{
var existingFile = await _dbContext.Files
.GetAll()
.FirstOrDefaultAsync(f => f.FileHash == fileHash);
if (existingFile != null)
{
Console.WriteLine($"♻️ Duplicate file detected! Reusing existing file ID: {existingFile.Id}");
return existingFile; // Return existing file instead of creating new one
}
}
// ✅ STEP 3: Create database record first to get ID
var fileExtension = Path.GetExtension(fileName);
var fileEntity = new Files
{
FileName = Path.GetFileNameWithoutExtension(fileName),
FileExtension = fileExtension,
RawText = rawText,
FileHash = fileHash, // ✅ Store the hash
Created = DateTime.UtcNow,
Modified = DateTime.UtcNow,
IsCompressed = !IsAlreadyCompressed(fileExtension)
};
await _dbContext.Files.InsertAsync(fileEntity);
Console.WriteLine($"✅ File record created - ID: {fileEntity.Id}, Hash: {fileHash}");
// ✅ STEP 4: Build storage path with file ID
var fileNameWithId = $"{fileEntity.FileName}_{fileEntity.Id}{fileExtension}";
var relativePath = BuildRelativePath(userId, featureName, entityType, entityId, fileNameWithId);
// ✅ STEP 5: Determine if file should be compressed
bool shouldCompress = !IsAlreadyCompressed(fileExtension);
try
{
Stream streamToSave = fileStream;
// Compress if needed
if (shouldCompress)
{
streamToSave = await CompressStreamAsync(fileStream);
// Update filename to indicate compression
fileNameWithId += ".gz";
relativePath += ".gz";
}
// Save to storage provider
await _storageProvider.SaveFileAsync(streamToSave, relativePath);
// Dispose compressed stream if we created one
if (shouldCompress && streamToSave != fileStream)
{
await streamToSave.DisposeAsync();
}
Console.WriteLine($"💾 File saved: {relativePath} (Compressed: {shouldCompress})");
return fileEntity;
}
catch (Exception ex)
{
// Rollback database record if file save fails
await _dbContext.Files.DeleteAsync(fileEntity);
Console.Error.WriteLine($"❌ Error saving file: {ex.Message}");
throw;
}
}
/// <summary>
/// Check if a file with this hash already exists
/// </summary>
public async Task<Files> FindFileByHashAsync(string fileHash)
{
return await _dbContext.Files
.GetAll()
.FirstOrDefaultAsync(f => f.FileHash == fileHash);
}
/// <summary>
/// Get all files with the same hash (duplicates)
/// </summary>
public async Task<List<Files>> FindDuplicateFilesByHashAsync(string fileHash)
{
return await _dbContext.Files
.GetAll()
.Where(f => f.FileHash == fileHash)
.ToListAsync();
}
/// <summary>
/// Calculate SHA256 hash from stream
/// </summary>
private async Task<string> CalculateFileHashAsync(Stream stream)
{
using (var sha256 = SHA256.Create())
{
var hashBytes = await Task.Run(() => sha256.ComputeHash(stream));
return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
}
}
/// <summary>
/// Retrieves a file by ID with automatic decompression
/// </summary>
public async Task<(Stream FileStream, Files FileInfo)> GetFileByIdAsync(
int fileId,
int userId,
string featureName,
string entityType,
int entityId)
{
// Get file record from database
var fileEntity = await _dbContext.Files.GetByIdAsync(fileId);
if (fileEntity == null)
throw new FileNotFoundException($"File with ID {fileId} not found in database");
// Build path
var fileNameWithId = $"{fileEntity.FileName}_{fileEntity.Id}{fileEntity.FileExtension}";
var isCompressed = !IsAlreadyCompressed(fileEntity.FileExtension);
if (isCompressed)
{
fileNameWithId += ".gz";
}
var relativePath = BuildRelativePath(userId, featureName, entityType, entityId, fileNameWithId);
// Get file from storage
var fileStream = await _storageProvider.GetFileAsync(relativePath);
// Decompress if needed
if (isCompressed)
{
var decompressedStream = await DecompressStreamAsync(fileStream);
await fileStream.DisposeAsync();
fileStream = decompressedStream;
}
return (fileStream, fileEntity);
}
/// <summary>
/// Gets all files from database
/// </summary>
public async Task<List<Files>> GetAllFilesAsync()
{
return await _dbContext.Files.GetAll().ToListAsync();
}
/// <summary>
/// Searches files by filename, hash, or raw text content
/// </summary>
public async Task<List<Files>> SearchFilesAsync(string searchTerm)
{
if (string.IsNullOrWhiteSpace(searchTerm))
return await GetAllFilesAsync();
var allFiles = await _dbContext.Files.GetAll().ToListAsync();
var results = allFiles.Where(f =>
// Search in filename
(!string.IsNullOrEmpty(f.FileName) &&
f.FileName.Contains(searchTerm, StringComparison.OrdinalIgnoreCase)) ||
// Search in file extension
(!string.IsNullOrEmpty(f.FileExtension) &&
f.FileExtension.Contains(searchTerm, StringComparison.OrdinalIgnoreCase)) ||
// Search in file hash
(!string.IsNullOrEmpty(f.FileHash) &&
f.FileHash.Contains(searchTerm, StringComparison.OrdinalIgnoreCase)) ||
// Full-text search in RawText (only if RawText is not null)
(!string.IsNullOrEmpty(f.RawText) &&
f.RawText.Contains(searchTerm, StringComparison.OrdinalIgnoreCase))
).ToList();
Console.WriteLine($"🔍 Search for '{searchTerm}' returned {results.Count} results");
return results;
}
/// <summary>
/// Adds or updates a file record in the database
/// </summary>
public async Task<Files> AddOrUpdateFileAsync(Files fileEntity)
{
if (fileEntity == null)
throw new ArgumentNullException(nameof(fileEntity));
if (fileEntity.Id > 0)
{
// Update existing
fileEntity.Modified = DateTime.UtcNow;
await _dbContext.Files.UpdateAsync(fileEntity);
}
else
{
// Add new
fileEntity.Created = DateTime.UtcNow;
fileEntity.Modified = DateTime.UtcNow;
await _dbContext.Files.InsertAsync(fileEntity);
}
return fileEntity;
}
/// <summary>
/// Deletes a file from both storage and database
/// </summary>
public async Task<bool> DeleteFileAsync(
int fileId,
int userId,
string featureName,
string entityType,
int entityId)
{
var fileEntity = await _dbContext.Files.GetByIdAsync(fileId);
if (fileEntity == null)
return false;
// Build path
var fileNameWithId = $"{fileEntity.FileName}_{fileEntity.Id}{fileEntity.FileExtension}";
var isCompressed = !IsAlreadyCompressed(fileEntity.FileExtension);
if (isCompressed)
{
fileNameWithId += ".gz";
}
var relativePath = BuildRelativePath(userId, featureName, entityType, entityId, fileNameWithId);
// Delete from storage
await _storageProvider.DeleteFileAsync(relativePath);
// Delete from database
await _dbContext.Files.DeleteAsync(fileEntity);
return true;
}
#region Private Helper Methods
/// <summary>
/// Builds the relative storage path
/// </summary>
private string BuildRelativePath(int userId, string featureName, string entityType, int entityId, string fileName)
{
return Path.Combine(
userId.ToString(),
featureName,
$"{entityType}-{entityId}",
fileName
);
}
/// <summary>
/// Checks if a file extension represents an already-compressed format
/// </summary>
private bool IsAlreadyCompressed(string extension)
{
return CompressedExtensions.Contains(extension);
}
/// <summary>
/// Compresses a stream using GZip
/// </summary>
private async Task<Stream> CompressStreamAsync(Stream inputStream)
{
var compressedStream = new MemoryStream();
using (var gzipStream = new GZipStream(compressedStream, CompressionMode.Compress, leaveOpen: true))
{
await inputStream.CopyToAsync(gzipStream);
}
compressedStream.Position = 0;
return compressedStream;
}
/// <summary>
/// Decompresses a GZip stream
/// </summary>
private async Task<Stream> DecompressStreamAsync(Stream compressedStream)
{
var decompressedStream = new MemoryStream();
using (var gzipStream = new GZipStream(compressedStream, CompressionMode.Decompress, leaveOpen: true))
{
await gzipStream.CopyToAsync(decompressedStream);
}
decompressedStream.Position = 0;
return decompressedStream;
}
#endregion
}
}

View File

@ -0,0 +1,40 @@
using System.IO;
using System.Threading.Tasks;
namespace Nop.Plugin.Misc.FruitBankPlugin.Services.FileStorage
{
/// <summary>
/// Interface for file storage providers (local, Azure, S3, etc.)
/// </summary>
public interface IFileStorageProvider
{
/// <summary>
/// Saves a file stream to the specified relative path
/// </summary>
/// <param name="fileStream">The file stream to save</param>
/// <param name="relativePath">Relative path from storage root (e.g., "123/AIdocumentprocessing/ShippingDocuments-456/file.pdf")</param>
/// <returns>The full path where the file was saved</returns>
Task<string> SaveFileAsync(Stream fileStream, string relativePath);
/// <summary>
/// Retrieves a file stream from the specified relative path
/// </summary>
/// <param name="relativePath">Relative path from storage root</param>
/// <returns>File stream</returns>
Task<Stream> GetFileAsync(string relativePath);
/// <summary>
/// Deletes a file at the specified relative path
/// </summary>
/// <param name="relativePath">Relative path from storage root</param>
/// <returns>True if deleted successfully</returns>
Task<bool> DeleteFileAsync(string relativePath);
/// <summary>
/// Checks if a file exists at the specified relative path
/// </summary>
/// <param name="relativePath">Relative path from storage root</param>
/// <returns>True if file exists</returns>
Task<bool> FileExistsAsync(string relativePath);
}
}

View File

@ -0,0 +1,139 @@
using System;
using System.IO;
using System.Threading.Tasks;
namespace Nop.Plugin.Misc.FruitBankPlugin.Services.FileStorage
{
/// <summary>
/// Local file system storage provider - saves files to wwwroot/uploads
/// </summary>
public class LocalFileStorageProvider : IFileStorageProvider
{
private readonly string _baseDirectory;
public LocalFileStorageProvider(string baseDirectory = null)
{
// Default to wwwroot/uploads if not specified
_baseDirectory = baseDirectory ?? Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "uploads");
// Ensure base directory exists
if (!Directory.Exists(_baseDirectory))
{
Directory.CreateDirectory(_baseDirectory);
}
}
/// <summary>
/// Saves a file stream to local file system
/// </summary>
public async Task<string> SaveFileAsync(Stream fileStream, string relativePath)
{
if (fileStream == null)
throw new ArgumentNullException(nameof(fileStream));
if (string.IsNullOrWhiteSpace(relativePath))
throw new ArgumentNullException(nameof(relativePath));
var fullPath = Path.Combine(_baseDirectory, relativePath);
var directory = Path.GetDirectoryName(fullPath);
// Ensure directory exists
if (!Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
// Save file
using (var fileStreamOutput = new FileStream(fullPath, FileMode.Create, FileAccess.Write, FileShare.None))
{
await fileStream.CopyToAsync(fileStreamOutput);
}
return fullPath;
}
/// <summary>
/// Retrieves a file stream from local file system
/// </summary>
public async Task<Stream> GetFileAsync(string relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath))
throw new ArgumentNullException(nameof(relativePath));
var fullPath = Path.Combine(_baseDirectory, relativePath);
if (!File.Exists(fullPath))
throw new FileNotFoundException($"File not found: {relativePath}", fullPath);
// Read file into memory stream to avoid file locks
var memoryStream = new MemoryStream();
using (var fileStream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read))
{
await fileStream.CopyToAsync(memoryStream);
}
memoryStream.Position = 0;
return memoryStream;
}
/// <summary>
/// Deletes a file from local file system
/// </summary>
public Task<bool> DeleteFileAsync(string relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath))
throw new ArgumentNullException(nameof(relativePath));
var fullPath = Path.Combine(_baseDirectory, relativePath);
if (File.Exists(fullPath))
{
File.Delete(fullPath);
// Try to clean up empty directories
CleanupEmptyDirectories(fullPath);
return Task.FromResult(true);
}
return Task.FromResult(false);
}
/// <summary>
/// Checks if a file exists in local file system
/// </summary>
public Task<bool> FileExistsAsync(string relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath))
return Task.FromResult(false);
var fullPath = Path.Combine(_baseDirectory, relativePath);
return Task.FromResult(File.Exists(fullPath));
}
/// <summary>
/// Cleans up empty parent directories after file deletion
/// </summary>
private void CleanupEmptyDirectories(string filePath)
{
try
{
var directory = Path.GetDirectoryName(filePath);
while (!string.IsNullOrEmpty(directory) &&
directory != _baseDirectory &&
Directory.Exists(directory) &&
Directory.GetFiles(directory).Length == 0 &&
Directory.GetDirectories(directory).Length == 0)
{
Directory.Delete(directory);
directory = Path.GetDirectoryName(directory);
}
}
catch
{
// Ignore cleanup errors - not critical
}
}
}
}

View File

@ -38,21 +38,34 @@
</div> </div>
<hr /> <hr />
<div class="form-group row"> <div class="form-group row">
<div class="col-md-4"> <div class="col-md-3">
<button type="button" class="btn btn-warning btn-block" data-toggle="modal" data-target="#allowRevisionModal"> <button type="button" class="btn btn-warning btn-block" data-toggle="modal" data-target="#allowRevisionModal">
<i class="fa fa-redo"></i> Újramérés engedélyezése <i class="fa fa-redo"></i> Újramérés engedélyezése
</button> </button>
</div> </div>
<div class="col-md-4"> <div class="col-md-3">
<button type="button" class="btn btn-primary btn-block" data-toggle="modal" data-target="#sendMessageModal"> <button type="button" class="btn btn-primary btn-block" data-toggle="modal" data-target="#sendMessageModal">
<i class="fas fa-paper-plane"></i> Üzenet küldése <i class="fas fa-paper-plane"></i> Üzenet küldése
</button> </button>
</div> </div>
<div class="col-md-4"> <div class="col-md-3">
<button type="button" class="btn btn-info btn-block" data-toggle="modal" data-target="#addOrderNoteModal"> <button type="button" class="btn btn-info btn-block" data-toggle="modal" data-target="#addOrderNoteModal">
<i class="fas fa-sticky-note"></i> Jegyzet hozzáadása <i class="fas fa-sticky-note"></i> Jegyzet hozzáadása
</button> </button>
</div> </div>
<div class="col-md-3">
<button type="button"
class="btn btn-danger btn-block"
data-toggle="modal"
data-target="#splitOrderModal"
@if(Model.OrderDto.MeasuringStatus == FruitBank.Common.Enums.MeasuringStatus.Audited ||
Model.OrderDto.MeasuringStatus == FruitBank.Common.Enums.MeasuringStatus.NotStarted)
{
<text>disabled</text>
}>
<i class="fas fa-cut"></i> Rendelés szétválasztása
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -200,6 +213,73 @@
</div> </div>
</div> </div>
<!-- Split Order Modal -->
<div class="modal fade" id="splitOrderModal" tabindex="-1" role="dialog" aria-labelledby="splitOrderModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="splitOrderModalLabel">
<i class="fas fa-cut"></i> Rendelés szétválasztása
</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
@if (Model.OrderDto.MeasuringStatus == FruitBank.Common.Enums.MeasuringStatus.Audited)
{
<div class="alert alert-danger">
<i class="fas fa-lock"></i>
<strong>Ez a rendelés már auditált!</strong> Auditált rendelések nem választhatók szét.
</div>
}
else if (Model.OrderDto.MeasuringStatus == FruitBank.Common.Enums.MeasuringStatus.NotStarted)
{
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle"></i>
<strong>Ez a rendelés még nem lett elkezdve!</strong> Csak elkezdett rendelések választhatók szét.
</div>
}
else
{
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle"></i>
<strong>Figyelem!</strong> Ez a művelet az auditált és nem auditált termékeket külön rendelésekbe választja szét.
</div>
<h6>Auditált termékek (maradnak ebben a rendelésben):</h6>
<ul id="auditedItemsList" class="list-group mb-3">
<!-- Will be populated by JavaScript -->
</ul>
<h6>Nem auditált termékek (új rendelésbe kerülnek):</h6>
<ul id="nonAuditedItemsList" class="list-group mb-3">
<!-- Will be populated by JavaScript -->
</ul>
}
<div id="splitOrderStatus" class="alert" style="display: none; margin-top: 15px;">
<i class="fas fa-info-circle"></i> <span id="splitOrderStatusMessage"></span>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">
<i class="fa fa-times"></i> Mégse
</button>
<button type="button" id="splitOrderBtn" class="btn btn-danger"
@if (Model.OrderDto.MeasuringStatus == FruitBank.Common.Enums.MeasuringStatus.Audited ||
Model.OrderDto.MeasuringStatus == FruitBank.Common.Enums.MeasuringStatus.NotStarted)
{
<text>disabled</text>
}
>
<i class="fas fa-cut"></i> Rendelés szétválasztása
</button>
</div>
</div>
</div>
</div>
<script> <script>
$(document).ready(function () { $(document).ready(function () {
@ -738,5 +818,171 @@
$("#orderNoteText").val(""); $("#orderNoteText").val("");
}); });
// ========== SPLIT ORDER MANAGEMENT ==========
var splitOrderUrl = '@Url.Action("SplitOrder", "CustomOrder")';
var measuringStatus = @((int)Model.OrderDto.MeasuringStatus);
var isAudited = measuringStatus === @((int)FruitBank.Common.Enums.MeasuringStatus.Audited);
var isNotStarted = measuringStatus === @((int)FruitBank.Common.Enums.MeasuringStatus.NotStarted);
// Populate split order modal when opened
$('#splitOrderModal').on('show.bs.modal', function () {
// Skip populating lists if order is audited or not started
if (isAudited || isNotStarted) {
return;
}
var auditedItems = [];
var nonAuditedItems = [];
@foreach (var orderItem in Model.OrderDto.OrderItemDtos)
{
@if (orderItem.IsAudited)
{
<text>
auditedItems.push({
id: @orderItem.Id,
name: '@Html.Raw(orderItem.ProductName)',
quantity: @orderItem.Quantity
});
</text>
}
else
{
<text>
nonAuditedItems.push({
id: @orderItem.Id,
name: '@Html.Raw(orderItem.ProductName)',
quantity: @orderItem.Quantity
});
</text>
}
}
// Populate audited items list
var auditedListHtml = '';
if (auditedItems.length > 0) {
auditedItems.forEach(function(item) {
auditedListHtml += '<li class="list-group-item">' +
'<i class="fas fa-check-circle text-success"></i> ' +
item.name + ' - ' + item.quantity + ' db</li>';
});
} else {
auditedListHtml = '<li class="list-group-item text-muted">Nincsenek auditált termékek</li>';
}
$('#auditedItemsList').html(auditedListHtml);
// Populate non-audited items list
var nonAuditedListHtml = '';
if (nonAuditedItems.length > 0) {
nonAuditedItems.forEach(function(item) {
nonAuditedListHtml += '<li class="list-group-item">' +
'<i class="fas fa-times-circle text-danger"></i> ' +
item.name + ' - ' + item.quantity + ' db</li>';
});
} else {
nonAuditedListHtml = '<li class="list-group-item text-muted">Nincsenek nem auditált termékek</li>';
}
$('#nonAuditedItemsList').html(nonAuditedListHtml);
// Disable split button if no non-audited items or all items audited
if (nonAuditedItems.length === 0 || auditedItems.length === 0) {
$('#splitOrderBtn').prop('disabled', true);
if (nonAuditedItems.length === 0) {
showSplitOrderStatus("Nincs szétválasztható termék (minden termék auditált)", "warning");
} else if (auditedItems.length === 0) {
showSplitOrderStatus("Nincs szétválasztható termék (minden termék nem auditált)", "warning");
}
} else {
$('#splitOrderBtn').prop('disabled', false);
}
});
// Split Order Button
$("#splitOrderBtn").click(function (e) {
e.preventDefault();
e.stopPropagation();
// Additional safety check
if (isAudited) {
showSplitOrderStatus("Ez a rendelés már auditált, nem választható szét!", "danger");
return false;
}
if (isNotStarted) {
showSplitOrderStatus("Ez a rendelés még nem lett elkezdve, nem választható szét!", "danger");
return false;
}
if (!confirm('Biztosan szét szeretné választani ezt a rendelést? Ez a művelet nem vonható vissza!')) {
return false;
}
var btn = $(this);
btn.prop("disabled", true).html('<i class="fas fa-spinner fa-spin"></i> Feldolgozás...');
showSplitOrderStatus("Rendelés szétválasztása folyamatban...", "info");
$.ajax({
type: "POST",
url: splitOrderUrl,
data: {
orderId: @Model.OrderId,
__RequestVerificationToken: $('input[name="__RequestVerificationToken"]').val()
},
dataType: 'json',
success: function (response) {
console.log("Split Order Response:", response);
btn.prop("disabled", false).html('<i class="fas fa-cut"></i> Rendelés szétválasztása');
if (response.success) {
showSplitOrderStatus("Rendelés sikeresen szétválasztva! Új rendelés azonosító: #" + response.newOrderId, "success");
// Redirect to the current order after 2 seconds
setTimeout(function() {
window.location.href = '@Url.Action("Edit", "Order")' + '?id=' + @Model.OrderId;
}, 2000);
} else {
showSplitOrderStatus("Hiba: " + (response.message || "Ismeretlen hiba"), "danger");
}
},
error: function (xhr, status, error) {
console.error("Split Order AJAX Error:", xhr, status, error);
btn.prop("disabled", false).html('<i class="fas fa-cut"></i> Rendelés szétválasztása');
var errorMessage = "Hiba a rendelés szétválasztása közben";
if (xhr.responseJSON && xhr.responseJSON.message) {
errorMessage = xhr.responseJSON.message;
} else if (xhr.responseText) {
try {
var errorObj = JSON.parse(xhr.responseText);
errorMessage = errorObj.message || errorMessage;
} catch (e) {
errorMessage = "Hiba: " + xhr.status + " - " + xhr.statusText;
}
}
showSplitOrderStatus(errorMessage, "danger");
}
});
return false;
});
function showSplitOrderStatus(message, type) {
var statusDiv = $("#splitOrderStatus");
statusDiv.removeClass("alert-info alert-success alert-warning alert-danger")
.addClass("alert-" + type);
$("#splitOrderStatusMessage").text(message);
statusDiv.show();
}
// Clear split order status when modal is closed
$('#splitOrderModal').on('hidden.bs.modal', function () {
$("#splitOrderStatus").hide();
if (!isAudited && !isNotStarted) {
$("#splitOrderBtn").prop('disabled', false).html('<i class="fas fa-cut"></i> Rendelés szétválasztása');
}
});
}); });
</script> </script>