From c1183dfb8a7c17d88a458ddaa0f1217631206dba Mon Sep 17 00:00:00 2001 From: Adal Date: Wed, 30 Sep 2026 11:50:04 -0700 Subject: [PATCH] initial commit --- .claude | 7 + App.xaml.cs | 77 ++++ README.md | 5 + appsettings.json | 3 +- sql/create_invoiceproductchanges.sql | 31 ++ sql/draft_907260825592619_1_products.sql | 25 ++ sql/draft_907260825592619_2_invoice.sql | 52 +++ sql/draft_907260825592619_3_batch.sql | 53 +++ sql/fix_po_0000000012.sql | 17 + sql/widen_invoicenumber.sql | 16 + src/AppConfig.cs | 9 +- src/DataService.cs | 92 ++++- src/ParseWorker.cs | 31 ++ src/SaveService.cs | 109 ++++- src/WebHost.cs | 18 +- wwwroot/archive.html | 160 ++++++-- wwwroot/changes.html | 158 ++++++++ wwwroot/css/style.css | 239 +++++++++-- wwwroot/index.html | 43 +- wwwroot/js/app.js | 486 ++++++++++++++++++++--- wwwroot/pending.html | 27 +- 21 files changed, 1460 insertions(+), 198 deletions(-) create mode 100644 .claude create mode 100644 sql/create_invoiceproductchanges.sql create mode 100644 sql/draft_907260825592619_1_products.sql create mode 100644 sql/draft_907260825592619_2_invoice.sql create mode 100644 sql/draft_907260825592619_3_batch.sql create mode 100644 sql/fix_po_0000000012.sql create mode 100644 sql/widen_invoicenumber.sql create mode 100644 wwwroot/changes.html diff --git a/.claude b/.claude new file mode 100644 index 0000000..b0327d3 --- /dev/null +++ b/.claude @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(sqlcmd:*)" + ] + } +} \ No newline at end of file diff --git a/App.xaml.cs b/App.xaml.cs index ff5c4d3..9ae790e 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -2,6 +2,8 @@ using System.Diagnostics; using System.IO; using System.Threading; using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; using InvoiceParser.Src; namespace InvoiceParser; @@ -39,6 +41,21 @@ public partial class App : Application try { var config = AppConfig.Load(AppContext.BaseDirectory); + + // The login dialog would otherwise become MainWindow and end the app when it closes. + ShutdownMode = ShutdownMode.OnExplicitShutdown; + var emp = PromptLogin(config); + if (emp == null) + { + Log.Info("login", "login cancelled"); + Shutdown(); + return; + } + config.ReceiverEmpNo = emp.Value.EmpNo; + config.ReceiverFirstName = emp.Value.FirstName; + Log.Info("login", "logged in as emp_no " + emp.Value.EmpNo); + ShutdownMode = ShutdownMode.OnMainWindowClose; + _host = new WebHost(config); await _host.StartAsync(); @@ -55,6 +72,66 @@ public partial class App : Application } } + // Returns the employee whose AdminPassword was entered, or null if cancelled. + private static (int EmpNo, string FirstName)? PromptLogin(AppConfig cfg) + { + (int EmpNo, string FirstName)? empNo = null; + var pw = new PasswordBox { FontSize = 16, Margin = new Thickness(0, 6, 0, 0) }; + var error = new TextBlock { Foreground = Brushes.Firebrick, Margin = new Thickness(0, 8, 0, 0) }; + var ok = new Button { Content = "Log in", IsDefault = true, Width = 90, Margin = new Thickness(0, 12, 8, 0) }; + var cancel = new Button { Content = "Cancel", IsCancel = true, Width = 90, Margin = new Thickness(0, 12, 0, 0) }; + var buttons = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right }; + buttons.Children.Add(ok); + buttons.Children.Add(cancel); + var panel = new StackPanel { Margin = new Thickness(20), MinWidth = 300 }; + panel.Children.Add(new TextBlock { Text = "Admin password" }); + panel.Children.Add(pw); + panel.Children.Add(error); + panel.Children.Add(buttons); + + var win = new Window + { + Title = "Invoice Parser - Log in", + Content = panel, + SizeToContent = SizeToContent.WidthAndHeight, + ResizeMode = ResizeMode.NoResize, + WindowStartupLocation = WindowStartupLocation.CenterScreen + }; + win.Loaded += (_, _) => pw.Focus(); + ok.Click += (_, _) => + { + error.Text = ""; + try + { + empNo = LookupEmployee(cfg, pw.Password); + if (empNo != null) { win.DialogResult = true; return; } + error.Text = "Invalid password"; + } + catch (Exception ex) + { + Log.Error("login", "employee lookup failed", ex); + error.Text = "Database error: " + ex.Message; + } + pw.Clear(); + pw.Focus(); + }; + return win.ShowDialog() == true ? empNo : null; + } + + private static (int EmpNo, string FirstName)? LookupEmployee(AppConfig cfg, string password) + { + if (!int.TryParse(password.Trim(), out var pw)) return null; + using var conn = new Db(cfg).Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = + "SELECT TOP 1 emp_no, FirstName FROM posbdat.dbo.Employees " + + "WHERE AdminPassword = @pw AND ISNULL(EmpActive, 0) = 1"; + cmd.Parameters.AddWithValue("@pw", pw); + using var r = cmd.ExecuteReader(); + if (!r.Read()) return null; + return (Convert.ToInt32(r.GetValue(0)), (Db.GetStr(r, "FirstName") ?? "").Trim()); + } + [System.Runtime.InteropServices.DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr hWnd); diff --git a/README.md b/README.md index ed7b531..2342f58 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,11 @@ wwwroot) to the target machine and run the exe. Requirements on the target: the (preinstalled on updated Windows 10/11; otherwise the 2 MB Evergreen bootstrapper from Microsoft), network access to SQL Server and the LLM host. +On first install, run `sql\widen_invoicenumber.sql` once (as a login with +ALTER rights on posinv) to widen `InvoiceNumber` from varchar(14) to +varchar(100). The app accepts up to 100 characters, so until this has been +run, saving an invoice number longer than 14 characters fails. + ## Layout at runtime InvoiceParser.exe diff --git a/appsettings.json b/appsettings.json index 6f53b22..45258aa 100644 --- a/appsettings.json +++ b/appsettings.json @@ -47,6 +47,7 @@ "PoHeaderTable": "posinv.dbo.POHeader", "PoDetailTable": "posinv.dbo.PODetail", "ProductBatchesHeaderTable": "posbdat.dbo.ProductBatchesHeader", - "ProductBatchesTable": "posbdat.dbo.ProductBatches" + "ProductBatchesTable": "posbdat.dbo.ProductBatches", + "ProductChangesTable": "posbdat.dbo.InvoiceProductChanges" } } diff --git a/sql/create_invoiceproductchanges.sql b/sql/create_invoiceproductchanges.sql new file mode 100644 index 0000000..f637d7a --- /dev/null +++ b/sql/create_invoiceproductchanges.sql @@ -0,0 +1,31 @@ +-- One-time install step: change log for products created and costs changed +-- by the invoice parser. Read by the Changes page. +-- Run as a login with CREATE TABLE rights on posbdat (not the app's limited login). +-- Safe to re-run: the table is only created when missing. +USE posbdat; +GO + +IF OBJECT_ID('dbo.InvoiceProductChanges', 'U') IS NULL +BEGIN + CREATE TABLE dbo.InvoiceProductChanges ( + ChangeID int IDENTITY(1,1) NOT NULL PRIMARY KEY, + ChangeDate datetime NOT NULL DEFAULT GETDATE(), + ChangeType varchar(10) NOT NULL, -- NEW = product created, COST = cost change + UPC varchar(13) NOT NULL, + Description varchar(30) NULL, + Vendor varchar(20) NULL, + InvoiceNumber varchar(100) NULL, + PONumber varchar(12) NULL, -- NULL for NEW: the PO is assigned at save + BatchNo varchar(25) NULL, + OldCost decimal(12,4) NULL, + NewCost decimal(12,4) NULL, + Retail decimal(12,4) NULL, + Pack decimal(12,2) NULL, + EmpNo int NULL + ); + CREATE INDEX IX_InvoiceProductChanges_ChangeDate ON dbo.InvoiceProductChanges (ChangeDate); +END +GO + +-- The app's login needs to write and read it. Replace and run. +-- GRANT SELECT, INSERT ON dbo.InvoiceProductChanges TO []; diff --git a/sql/draft_907260825592619_1_products.sql b/sql/draft_907260825592619_1_products.sql new file mode 100644 index 0000000..8ad94a1 --- /dev/null +++ b/sql/draft_907260825592619_1_products.sql @@ -0,0 +1,25 @@ +-- Draft p1790621868706_fb5f7cf3 - SuperValu invoice 907260825592619 +-- 1 of 3: products. The draft has 4 lines but 3 distinct UPCs (rotini is on it twice). +-- Values come from the draft; required flags copy the existing rows (dept 1, tax 0, foodstamp 1). +-- cost is left at 0, as on file today; script 3 (batch) carries the invoiced cost. +USE posbdat; +GO + +SET XACT_ABORT ON; +BEGIN TRAN; + +INSERT INTO dbo.Products + (upc, cert_code, description, cost, normal_price, pricemethod, department, vendor, pack, size, + tax, foodstamp, scale, dsd, active, advertised, deleted, attributes, modified, Created, CreatedBy) +SELECT v.upc, v.cert_code, v.description, 0, v.normal_price, 0, 1, 1, v.pack, v.size, + 0, 1, 0, 0, 1, 0, 0, 0, GETDATE(), GETDATE(), 'INVOICE' +FROM (VALUES + ('0002680000550', '645479', 'AMER/BEAUTY ROTINI', 1.39, 1, '16'), + ('0088810925309', '3491452', 'HST GLAZED HNY BUN', 2.49, 184, NULL), + ('0019269900079', '4178665', 'SMYNG BLDK ROSE BG BWL 3.', 3.29, 824, '0.65') +) AS v(upc, cert_code, description, normal_price, pack, size) +WHERE NOT EXISTS (SELECT 1 FROM dbo.Products p WHERE p.upc = v.upc); + +SELECT @@ROWCOUNT AS products_inserted; +COMMIT; +GO diff --git a/sql/draft_907260825592619_2_invoice.sql b/sql/draft_907260825592619_2_invoice.sql new file mode 100644 index 0000000..a21c5f1 --- /dev/null +++ b/sql/draft_907260825592619_2_invoice.sql @@ -0,0 +1,52 @@ +-- Draft p1790621868706_fb5f7cf3 - SuperValu invoice 907260825592619 +-- 2 of 3: InvoiceHeader + InvoiceDetail, the rows SaveService would write for this draft. +-- Vendor 1 = SuperValu (the draft has no vendor selected). +-- PO: next 10-digit number across InvoiceHeader and POHeader, same as the app. +USE posinv; +GO + +SET XACT_ABORT ON; +BEGIN TRAN; + +DECLARE @vendor int = 1, + @inv varchar(100) = '907260825592619', + @date datetime = '20260825', + @receiver int = 1, -- your emp_no from posbdat..Employees + @po varchar(12); + +IF EXISTS (SELECT 1 FROM dbo.InvoiceHeader WITH (UPDLOCK, HOLDLOCK) WHERE InvoiceNumber = @inv AND Vendor = @vendor) +BEGIN + ROLLBACK; + RAISERROR('Invoice 907260825592619 already exists for vendor 1', 16, 1); + RETURN; +END + +SELECT @po = RIGHT(REPLICATE('0', 10) + CAST(CASE WHEN InvoiceMax > POMax THEN InvoiceMax ELSE POMax END + 1 AS varchar(20)), 10) +FROM (SELECT + ISNULL((SELECT MAX(TRY_CONVERT(bigint, LTRIM(RTRIM(PONumber)))) FROM dbo.InvoiceHeader WITH (UPDLOCK, HOLDLOCK) + WHERE LTRIM(RTRIM(PONumber)) NOT LIKE '%[^0-9]%'), 0) AS InvoiceMax, + ISNULL((SELECT MAX(TRY_CONVERT(bigint, LTRIM(RTRIM(PONumber)))) FROM dbo.POHeader WITH (UPDLOCK, HOLDLOCK) + WHERE LTRIM(RTRIM(PONumber)) NOT LIKE '%[^0-9]%'), 0) AS POMax) AS M; + +-- TotalCost = sum of line Totals (21.00 + 20.53) +-- TotalPrice = sum of Price * Pack * QuantityCase (only the qty-1 rotini line: 1.39 * 1 * 1) +-- Price_Cost_Change = 1 because script 3 creates a batch +INSERT INTO dbo.InvoiceHeader + (PONumber, PODate, InvoiceDate, Vendor, InvoiceNumber, VendorTotal, ReceivedInvoice, Price_Cost_Change, + TotalCost, TotalPrice, InvoiceQuantity, Salesman, Receiver, Deliverer) +VALUES + (@po, @date, @date, @vendor, @inv, 480.11, 1, 1, + 41.53, 1.39, 1, '', @receiver, ''); + +-- Cost = case cost / pack, CaseCost = invoice cost, Price = unit retail +INSERT INTO dbo.InvoiceDetail + (PONumber, UPC, Cert_code, Description, Cost, Price, Total, Pack, CaseCost, QuantityCase, Size, UnitOfMeasure, PurchaseType) +VALUES + (@po, '0002680000550', 'F 645479', 'AMER/BEAUTY ROTINI', 0, 1.39, 21.00, 1, 0, 0, '16', 'OZ', 0), + (@po, '0088810925309', '3491452', 'HST GLAZED HNY BUN', 0, 2.49, 0, 184, 0, 0, NULL, 'OZ', 0), + (@po, '0019269900079', '4178665', 'SMYNG BLDK ROSE BG BWL 3.', 0, 3.29, 0, 824, 0, 0, '0.65', 'OZ', 0), + (@po, '0002680000550', '645479', 'AMER/BEAUTY ROTINI', 20.53, 1.39, 20.53, 1, 20.53, 1, '16', 'OZ', 0); + +SELECT @po AS PONumber; +COMMIT; +GO diff --git a/sql/draft_907260825592619_3_batch.sql b/sql/draft_907260825592619_3_batch.sql new file mode 100644 index 0000000..c95fb7b --- /dev/null +++ b/sql/draft_907260825592619_3_batch.sql @@ -0,0 +1,53 @@ +-- Draft p1790621868706_fb5f7cf3 - SuperValu invoice 907260825592619 +-- 3 of 3: ProductBatchesHeader + ProductBatches, the batch BatchService would write. +-- Only rotini qualifies: the other lines have no invoiced cost, and for a repeated UPC +-- the first row with a cost wins (20.53 / pack 1 = 20.53 unit cost vs 0 on file). +-- Detail rows copy every column Products and ProductBatches share, then set cost/pack/audit. +USE posbdat; +GO + +SET XACT_ABORT ON; +BEGIN TRAN; + +DECLARE @b varchar(25) = 'INV_907260825592619'; + +IF EXISTS (SELECT 1 FROM dbo.ProductBatchesHeader WITH (UPDLOCK, HOLDLOCK) WHERE BatchNo = @b) +BEGIN + ROLLBACK; + RAISERROR('Batch INV_907260825592619 already exists', 16, 1); + RETURN; +END + +INSERT INTO dbo.ProductBatchesHeader + (BatchNo, Description, StartDate, EndDate, StartTime, EndTime, Priority, Type, WhoApplied, WhoCreated, Created) +VALUES + (@b, 'Invoice 907260825592619 vendor 1 cost change', GETDATE(), GETDATE(), + '1900-01-01 00:00:00.000', '1900-01-01 23:59:00.000', 1, 'PB', 888, 888, GETDATE()); + +INSERT INTO dbo.ProductBatches + (BatchNo, cost, pack, Created, CreatedBy, modified, cost_modified, + active, advertised, attributes, cert_code, deleted, Department, description, discount, dsd, + effectiveschedule, end_date, EndTime, foodstamp, FSACategory, groupprice, groupprice2, groupprice3, + groupprice4, groupprice5, longdescription, MaxDiscount, mixmatchcode, normal_price, picture_name, + Points, pricemethod, QualifiedFlatAmount, QualifiedPercent, quantity, quantity2, quantity3, quantity4, + quantity5, scale, seconddescription, Section, size, special_price, specialcost, specialgroupprice, + Specialgroupprice2, Specialgroupprice3, Specialgroupprice4, Specialgroupprice5, specialpricemethod, + specialquantity, Specialquantity2, Specialquantity3, Specialquantity4, Specialquantity5, start_date, + StartTime, tareweight, target_margin, tax, TOB, unitofmeasure, upc, upc_link, validage, Vendor, + whomodified, wicable) +SELECT @b, v.cost, v.pack, GETDATE(), 'INVOICE', GETDATE(), GETDATE(), + p.active, p.advertised, p.attributes, p.cert_code, p.deleted, p.department, p.description, p.discount, p.dsd, + p.effectiveschedule, p.end_date, p.EndTime, p.foodstamp, p.FSACategory, p.groupprice, p.groupprice2, p.groupprice3, + p.groupprice4, p.groupprice5, p.longdescription, p.maxdiscount, p.mixmatchcode, p.normal_price, p.picture_name, + p.Points, p.pricemethod, p.QualifiedFlatAmount, p.QualifiedPercent, p.quantity, p.quantity2, p.quantity3, p.quantity4, + p.quantity5, p.scale, p.seconddescription, p.section, p.size, p.special_price, p.specialcost, p.specialgroupprice, + p.Specialgroupprice2, p.Specialgroupprice3, p.Specialgroupprice4, p.Specialgroupprice5, p.specialpricemethod, + p.specialquantity, p.Specialquantity2, p.Specialquantity3, p.Specialquantity4, p.Specialquantity5, p.start_date, + p.StartTime, p.tareweight, p.target_margin, p.tax, p.TOB, p.unitofmeasure, p.upc, p.upc_link, p.validage, p.vendor, + p.whomodified, p.wicable +FROM (VALUES ('0002680000550', 20.53, 1)) AS v(upc, cost, pack) +JOIN dbo.Products p ON p.upc = v.upc; + +SELECT @@ROWCOUNT AS batch_rows; +COMMIT; +GO diff --git a/sql/fix_po_0000000012.sql b/sql/fix_po_0000000012.sql new file mode 100644 index 0000000..acbd4aa --- /dev/null +++ b/sql/fix_po_0000000012.sql @@ -0,0 +1,17 @@ +-- Brings PO 0000000012 (invoice 907260825592619) in line with the POS-created headers +-- (PO 1-8): the columns the POS fills that our insert left NULL. +USE posinv; +GO + +DECLARE @receiver int = 1; -- your emp_no from posbdat..Employees + +UPDATE dbo.InvoiceHeader +SET Salesman = '', + Receiver = @receiver, + Deliverer = '', + ReceivedInvoice = 1 +WHERE PONumber = '0000000012'; + +SELECT PONumber, Salesman, Receiver, Deliverer, ReceivedInvoice +FROM dbo.InvoiceHeader WHERE PONumber = '0000000012'; +GO diff --git a/sql/widen_invoicenumber.sql b/sql/widen_invoicenumber.sql new file mode 100644 index 0000000..b17b905 --- /dev/null +++ b/sql/widen_invoicenumber.sql @@ -0,0 +1,16 @@ +-- One-time install step: widen InvoiceNumber from varchar(14) to varchar(100). +-- Run as a login with ALTER rights on posinv (not the app's limited login). +-- Safe to re-run: each column is only altered while it is still narrower. +USE posinv; +GO + +IF COL_LENGTH('dbo.InvoiceHeader', 'InvoiceNumber') < 100 + ALTER TABLE dbo.InvoiceHeader ALTER COLUMN [InvoiceNumber] varchar(100) NULL; + +IF COL_LENGTH('dbo.InvoiceDetail', 'InvoiceNumber') < 100 + ALTER TABLE dbo.InvoiceDetail ALTER COLUMN [InvoiceNumber] varchar(100) NULL; +GO + +SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_NAME IN ('InvoiceHeader', 'InvoiceDetail') AND COLUMN_NAME = 'InvoiceNumber'; diff --git a/src/AppConfig.cs b/src/AppConfig.cs index 5402fcd..fa85b01 100644 --- a/src/AppConfig.cs +++ b/src/AppConfig.cs @@ -47,7 +47,9 @@ public class LlmConfig "line_items (array where each item has: upc, description, product_code, quantity, unit_price, total, pack, size, unit_of_measure). " + "Use null for any field you cannot determine. " + "Read the column headings and keep every value in its own column. quantity is the number of cases shipped, " + - "unit_price the cost of one case, total the extended amount for the row. pack is the units per case (number). " + + "unit_price the cost of one case, total the extended amount for the row (normally quantity x unit_price). " + + "unit_price and total are dollar amounts: keep the decimal point exactly as printed (28.50, never 2850) and take total " + + "from the row's own extension column, not from a neighbouring column or line. pack is the units per case (number). " + "size is the size of ONE unit as a string without the unit; unit_of_measure is that unit (OZ, FZ, LB, CT, EA...). " + "Wholesale invoices print pack and size together in one PACK/SIZE column, e.g. '12 16OZ' means pack 12, size 16, unit_of_measure OZ; " + "'6 32 FZ' means pack 6, size 32, FZ; '4 07.5 OZ' means pack 4, size 7.5, OZ. " + @@ -115,6 +117,7 @@ public class TablesConfig public string PoDetailTable { get; set; } = "posinv.dbo.PODetail"; public string ProductBatchesHeaderTable { get; set; } = "posbdat.dbo.ProductBatchesHeader"; public string ProductBatchesTable { get; set; } = "posbdat.dbo.ProductBatches"; + public string ProductChangesTable { get; set; } = "posbdat.dbo.InvoiceProductChanges"; } public class AppConfig @@ -128,6 +131,10 @@ public class AppConfig public BatchConfig Batch { get; set; } = new(); public string BaseDirectory { get; set; } = ""; + // emp_no of the employee who logged in at startup; written as InvoiceHeader.Receiver. + public int ReceiverEmpNo { get; set; } + public string ReceiverFirstName { get; set; } = ""; + public string PendingRoot => ResolvePath(Paths.Pending); public string ArchiveRoot => ResolvePath(Paths.Archive); public string WebRoot => Path.Combine(BaseDirectory, "wwwroot"); diff --git a/src/DataService.cs b/src/DataService.cs index e249269..dd57389 100644 --- a/src/DataService.cs +++ b/src/DataService.cs @@ -123,7 +123,8 @@ public class DataService public JsonObject CreateProduct(string upcRaw, string desc, string price, string deptId, string cost, string itemCode, string tax, string foodstamp, - string scale, string wicable, string pack, string size) + string scale, string wicable, string pack, string size, + string vendorId, string invoiceNum) { var rawDigits = Util.CleanDigits(upcRaw); var upc = Util.NormalizeUpcForStorage(rawDigits); @@ -219,7 +220,7 @@ public class DataService "VALUES (" + "@upc,@cert,@desc,@cost,@price," + "@pricemethod,@groupprice,@quantity,NULL,NULL,NULL,NULL,NULL,NULL,NULL,0," + - "@dept,NULL,0,@pack,@size,@tax,@foodstamp,@scale," + + "@dept,NULL,@vendor,@pack,@size,@tax,@foodstamp,@scale," + "0,NULL,NULL,getdate(),getdate(),1,0," + "'111',NULL,NULL,NULL,NULL,0,NULL,NULL,@wicable," + "@validage,0,NULL,0,@attributes,NULL,NULL,NULL,getdate(),111,0," + @@ -235,6 +236,9 @@ public class DataService cmd.Parameters.AddWithValue("@groupprice", priceMethod == 1 ? groupPrice : DBNull.Value); cmd.Parameters.AddWithValue("@quantity", priceMethod == 1 ? groupQty : DBNull.Value); cmd.Parameters.AddWithValue("@dept", deptNo); + // Only the vendor picked on the review header - no vendor means NULL, not 0. + cmd.Parameters.AddWithValue("@vendor", + vendorId.Trim() == "" ? DBNull.Value : vendorId.Trim()); cmd.Parameters.AddWithValue("@validage", validAge); cmd.Parameters.AddWithValue("@attributes", attributes); cmd.Parameters.AddWithValue("@pack", @@ -250,15 +254,97 @@ public class DataService // Hand back the effective shelf retail so the row's margin is right // immediately - for a group price that is the per-unit slice, not the total. + var retail = priceMethod == 1 ? groupPrice / groupQty : normalPrice; + + // Change log. The product already exists, so a failed log row is only warned. + try + { + using var log = conn.CreateCommand(); + log.CommandText = + $"IF OBJECT_ID(@tbl) IS NOT NULL INSERT INTO {_t.ProductChangesTable} " + + "(ChangeType, UPC, Description, Vendor, InvoiceNumber, NewCost, Retail, Pack, EmpNo) " + + "VALUES ('NEW', @upc, @desc, @vendor, @inv, @cost, @retail, @pack, @emp)"; + log.Parameters.AddWithValue("@tbl", _t.ProductChangesTable); + log.Parameters.AddWithValue("@upc", upc); + log.Parameters.AddWithValue("@desc", desc); + log.Parameters.AddWithValue("@vendor", vendorId.Trim() == "" ? DBNull.Value : vendorId.Trim()); + log.Parameters.AddWithValue("@inv", invoiceNum.Trim() == "" ? DBNull.Value : Truncate(invoiceNum.Trim(), 100)); + log.Parameters.AddWithValue("@cost", dCost); + log.Parameters.AddWithValue("@retail", retail); + log.Parameters.AddWithValue("@pack", cmd.Parameters["@pack"].Value); + log.Parameters.AddWithValue("@emp", _cfg.ReceiverEmpNo); + log.ExecuteNonQuery(); + } + catch (Exception ex) + { + Log.Warn("changes", $"new product {upc} not logged: {ex.Message}"); + } + return new JsonObject { ["success"] = true, ["upc"] = upc, ["description"] = desc, - ["retail"] = priceMethod == 1 ? groupPrice / groupQty : normalPrice + ["retail"] = retail }; } + // ---- change log ---- + + public JsonArray ProductChanges(string dateFrom, string dateTo, string type) + { + var arr = new JsonArray(); + using var conn = _db.Open(); + using var cmd = conn.CreateCommand(); + + var where = " WHERE 1=1"; + // Typed DateTime parameters: 'yyyy-MM-dd' text is DATEFORMAT dependent. + if (Util.IsIsoDate(dateFrom)) + { + where += " AND c.ChangeDate >= @dateFrom"; + cmd.Parameters.AddWithValue("@dateFrom", DateTime.ParseExact(dateFrom, "yyyy-MM-dd", CultureInfo.InvariantCulture)); + } + if (Util.IsIsoDate(dateTo)) + { + where += " AND c.ChangeDate < @dateTo"; + cmd.Parameters.AddWithValue("@dateTo", DateTime.ParseExact(dateTo, "yyyy-MM-dd", CultureInfo.InvariantCulture).AddDays(1)); + } + if (type == "NEW" || type == "COST") + { + where += " AND c.ChangeType = @type"; + cmd.Parameters.AddWithValue("@type", type); + } + + cmd.CommandText = + "SELECT TOP 2000 CONVERT(VARCHAR(19), c.ChangeDate, 120) AS ChangeDate, c.ChangeType, c.UPC, c.Description," + + " c.Vendor, c.InvoiceNumber, c.PONumber, c.BatchNo, c.OldCost, c.NewCost, c.Retail, c.Pack," + + $" ISNULL(v.{_t.VendorNameCol},'') AS VendorName" + + $" FROM {_t.ProductChangesTable} c" + + $" LEFT JOIN {_t.VendorTable} v ON v.{_t.VendorIdCol} = c.Vendor" + + where + + " ORDER BY c.ChangeDate DESC, c.ChangeID DESC"; + + using var r = cmd.ExecuteReader(); + while (r.Read()) + arr.Add(new JsonObject + { + ["date"] = Db.GetStr(r, "ChangeDate") ?? "", + ["type"] = Db.GetStr(r, "ChangeType") ?? "", + ["upc"] = Db.GetStr(r, "UPC") ?? "", + ["description"] = Db.GetStr(r, "Description") ?? "", + ["vendor"] = Db.GetStr(r, "Vendor") ?? "", + ["vendor_name"] = Db.GetStr(r, "VendorName") ?? "", + ["invoice_number"] = Db.GetStr(r, "InvoiceNumber") ?? "", + ["po_number"] = Db.GetStr(r, "PONumber") ?? "", + ["batch_no"] = Db.GetStr(r, "BatchNo") ?? "", + ["old_cost"] = Db.GetNum(r, "OldCost"), + ["new_cost"] = Db.GetNum(r, "NewCost"), + ["retail"] = Db.GetNum(r, "Retail"), + ["pack"] = Db.GetNum(r, "Pack") + }); + return arr; + } + // ---- archive ---- public JsonArray ArchiveList(string vendor, string inv, string po, string dateFrom, diff --git a/src/ParseWorker.cs b/src/ParseWorker.cs index b8f65bf..e002d80 100644 --- a/src/ParseWorker.cs +++ b/src/ParseWorker.cs @@ -393,10 +393,41 @@ public class ParseWorker : BackgroundService if (rawArray.Count == 0) throw new Exception("LLM returned no usable response"); + foreach (var it in items) + if (it is JsonObject o) FixLineTotal(o); + merged["line_items"] = items; return (rawArray, merged); } + // The LLM sometimes drops the decimal point on a row total (28.50 -> 2850) + // or lifts it from the wrong column. When quantity and unit_price are both + // present, a total that disagrees with them is repaired: divided by 100 if + // that makes it agree, otherwise replaced with quantity x unit_price. The + // value as read is kept in total_as_read. + private static void FixLineTotal(JsonObject item) + { + var qty = Num(item["quantity"]); + var price = Num(item["unit_price"]); + var total = Num(item["total"]); + if (qty == null || price == null || total == null) return; + + var expected = qty.Value * price.Value; + if (Math.Abs(total.Value - expected) <= 0.01) return; + + var fixedTotal = Math.Abs(total.Value / 100 - expected) <= 0.01 ? total.Value / 100 : expected; + item["total_as_read"] = item["total"]!.DeepClone(); + item["total"] = Math.Round(fixedTotal, 2); + Log.Warn("worker", $"line total {total.Value} for qty {qty.Value} x {price.Value} replaced with {Math.Round(fixedTotal, 2)}"); + } + + private static double? Num(JsonNode? n) + { + var s = n?.ToString().Replace("$", "").Replace(",", "").Trim(); + return double.TryParse(s, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : null; + } + private static bool IsGrandTotalLabel(string label) => System.Text.RegularExpressions.Regex.IsMatch(label, @"invoice\s*total|total\s*due|amount\s*due|balance\s*due|net\s*total|grand\s*total|net\s*invoice|total\s*invoice", diff --git a/src/SaveService.cs b/src/SaveService.cs index 0d04c38..f8244a7 100644 --- a/src/SaveService.cs +++ b/src/SaveService.cs @@ -33,7 +33,7 @@ public class SaveService string poNumber, string vendorTotal, List items, string? sourceFile, string sourceExt) { - invoiceNum = Truncate(invoiceNum.Trim(), 14); + invoiceNum = Truncate(invoiceNum.Trim(), 100); poNumber = Truncate(poNumber.Trim(), 12); var started = DateTime.UtcNow; Log.Info("save", $"vendor={vendorId} invoice={invoiceNum} date={invoiceDate} " + @@ -90,14 +90,16 @@ public class SaveService // Auto-generate PO number when none provided (max+1 across both tables, // inside the transaction with update locks so concurrent saves cannot reuse it). + // 10 digits to match the POS's dbo.IT_GetNextPONumber, which takes a + // string MAX(PONumber) and misorders mixed widths. if (poNumber == "") { using var cmd = Cmd(conn, tx, "SELECT CASE WHEN InvoiceMax > POMax THEN InvoiceMax + 1 ELSE POMax + 1 END AS NextNum," + - " RIGHT(REPLICATE('0', 12) + CAST(CASE WHEN InvoiceMax > POMax THEN InvoiceMax + 1 ELSE POMax + 1 END AS VARCHAR(20)), 12) AS NextPO" + + " RIGHT(REPLICATE('0', 10) + CAST(CASE WHEN InvoiceMax > POMax THEN InvoiceMax + 1 ELSE POMax + 1 END AS VARCHAR(20)), 10) AS NextPO" + " FROM (SELECT" + - $" ISNULL((SELECT MAX(TRY_CONVERT(BIGINT, LTRIM(RTRIM(PONumber)))) FROM {_t.InvoiceHeaderTable} WITH (UPDLOCK, HOLDLOCK) WHERE LTRIM(RTRIM(PONumber)) NOT LIKE '%[^0-9]%'), 0) AS InvoiceMax," + - $" ISNULL((SELECT MAX(TRY_CONVERT(BIGINT, LTRIM(RTRIM(PONumber)))) FROM {_t.PoHeaderTable} WITH (UPDLOCK, HOLDLOCK) WHERE LTRIM(RTRIM(PONumber)) NOT LIKE '%[^0-9]%'), 0) AS POMax" + + $" ISNULL((SELECT MAX(CASE WHEN LEN(LTRIM(RTRIM(PONumber))) BETWEEN 1 AND 18 AND LTRIM(RTRIM(PONumber)) NOT LIKE '%[^0-9]%' THEN CONVERT(BIGINT, LTRIM(RTRIM(PONumber))) END) FROM {_t.InvoiceHeaderTable} WITH (UPDLOCK, HOLDLOCK) WHERE LTRIM(RTRIM(PONumber)) NOT LIKE '%[^0-9]%'), 0) AS InvoiceMax," + + $" ISNULL((SELECT MAX(CASE WHEN LEN(LTRIM(RTRIM(PONumber))) BETWEEN 1 AND 18 AND LTRIM(RTRIM(PONumber)) NOT LIKE '%[^0-9]%' THEN CONVERT(BIGINT, LTRIM(RTRIM(PONumber))) END) FROM {_t.PoHeaderTable} WITH (UPDLOCK, HOLDLOCK) WHERE LTRIM(RTRIM(PONumber)) NOT LIKE '%[^0-9]%'), 0) AS POMax" + ") AS M"); using var r = cmd.ExecuteReader(); long nextNum = 1; @@ -107,10 +109,10 @@ public class SaveService poNumber = Convert.ToString(r.GetValue(1)) ?? ""; } r.Close(); - if (nextNum > 999999999999L) + if (nextNum > 9999999999L) { tx.Rollback(); - return FailLogged("PO number generation failed: max + 1 exceeds 12 digits"); + return FailLogged("PO number generation failed: max + 1 exceeds 10 digits"); } } @@ -139,40 +141,77 @@ public class SaveService } } - // InvoiceHeader + // Unit shelf retail per UPC, for InvoiceDetail.Price + var retail = new Dictionary(StringComparer.Ordinal); + var upcs = items.Select(it => Truncate(it.Upc.Trim(), 13)).Where(u => u != "").Distinct().ToList(); + if (upcs.Count > 0) + { + using var cmd = Cmd(conn, tx, + $"SELECT upc, {ProductSql.Retail} FROM {_t.ProductsTable} WHERE upc IN (" + + string.Join(",", upcs.Select((_, i) => "@u" + i)) + ")"); + for (int i = 0; i < upcs.Count; i++) cmd.Parameters.AddWithValue("@u" + i, upcs[i]); + using var r = cmd.ExecuteReader(); + while (r.Read()) + if (Db.GetNum(r, "retail") is double rv) retail[Db.GetStr(r, "upc") ?? ""] = rv; + } + + // POS convention: Cost = unit cost, CaseCost = invoice (case) cost, + // Price = unit retail; header TotalPrice = sum of Price * Pack * cases. + var lines = items + .Where(it => it.Upc.Trim() != "") + .Select(it => + { + var upc = Truncate(it.Upc.Trim(), 13); + var pack = NumOrNull(it.Pack); + var div = pack is > 0 ? pack.Value : 1; + double? price = retail.TryGetValue(upc, out var rv) ? rv : null; + return (Item: it, Upc: upc, Pack: pack, Price: price, + UnitCost: Math.Round(NumOrZero(it.Cost) / div, 4), + RetailExt: (price ?? 0) * div * NumOrZero(it.Qty)); + }) + .ToList(); + + // InvoiceHeader. Receiver (the logged-in emp_no), Salesman, Deliverer and + // ReceivedInvoice = 1 match the rows the POS itself writes; without them + // the POS does not list the invoice. using (var cmd = Cmd(conn, tx, $"INSERT INTO {_t.InvoiceHeaderTable} " + - "(PONumber, InvoiceDate, Vendor, InvoiceNumber, VendorTotal, ReceivedInvoice, Price_Cost_Change, InvoiceQuantity) " + - "VALUES (@po, @date, @vendor, @inv, @total, 0, @pcc, @qty)")) + "(PONumber, PODate, InvoiceDate, Vendor, InvoiceNumber, VendorTotal, ReceivedInvoice, Price_Cost_Change, " + + "TotalCost, TotalPrice, InvoiceQuantity, Salesman, Receiver, Deliverer, Buyer, Status, Department, Description, POTag) " + + "VALUES (@po, @date, @date, @vendor, @inv, @total, 1, @pcc, @tcost, @tprice, @qty, '', @receiver, '', 1, 3, 1, '', @po)")) { + bool hasTotal = double.TryParse(vendorTotal, NumberStyles.Any, CultureInfo.InvariantCulture, out var vt); + cmd.Parameters.AddWithValue("@receiver", _cfg.ReceiverEmpNo); + // The invoice's own total; the line sum only when none was parsed. + cmd.Parameters.AddWithValue("@tcost", hasTotal ? vt : lines.Sum(l => NumOrZero(l.Item.Total))); + cmd.Parameters.AddWithValue("@tprice", lines.Sum(l => l.RetailExt)); cmd.Parameters.AddWithValue("@pcc", changes.Count > 0 ? 1 : 0); cmd.Parameters.AddWithValue("@po", poNumber); cmd.Parameters.AddWithValue("@date", invoiceDateVal); cmd.Parameters.AddWithValue("@vendor", vendorId); cmd.Parameters.AddWithValue("@inv", invoiceNum); - cmd.Parameters.AddWithValue("@total", - double.TryParse(vendorTotal, NumberStyles.Any, CultureInfo.InvariantCulture, out var vt) - ? vt : DBNull.Value); + cmd.Parameters.AddWithValue("@total", hasTotal ? vt : DBNull.Value); cmd.Parameters.AddWithValue("@qty", totalQty); cmd.ExecuteNonQuery(); } // InvoiceDetail rows (skip rows without a UPC, like save.asp) - foreach (var it in items) + foreach (var l in lines) { - var upc = Truncate(it.Upc.Trim(), 13); - if (upc == "") continue; - + var it = l.Item; using var cmd = Cmd(conn, tx, $"INSERT INTO {_t.InvoiceDetailTable} " + - "(PONumber, UPC, Cert_code, Description, Cost, Total, QuantityCase, Size, UnitOfMeasure, PurchaseType) " + - "VALUES (@po, @upc, @cert, @desc, @cost, @total, @qty, @size, @uom, 0)"); + "(PONumber, UPC, Cert_code, Description, Cost, Price, Total, Pack, CaseCost, QuantityCase, Size, UnitOfMeasure, PurchaseType) " + + "VALUES (@po, @upc, @cert, @desc, @cost, @price, @total, @pack, @casecost, @qty, @size, @uom, 0)"); cmd.Parameters.AddWithValue("@po", poNumber); - cmd.Parameters.AddWithValue("@upc", upc); + cmd.Parameters.AddWithValue("@upc", l.Upc); + cmd.Parameters.AddWithValue("@price", l.Price is double pv ? pv : DBNull.Value); + cmd.Parameters.AddWithValue("@pack", l.Pack is double pk ? pk : DBNull.Value); + cmd.Parameters.AddWithValue("@casecost", NumOrDefault(it.Cost, 0)); cmd.Parameters.AddWithValue("@cert", it.Cert.Trim() == "" ? DBNull.Value : Truncate(it.Cert.Trim(), 15)); cmd.Parameters.AddWithValue("@desc", Truncate(it.Desc.Trim(), 30)); - cmd.Parameters.AddWithValue("@cost", NumOrDefault(it.Cost, 0)); + cmd.Parameters.AddWithValue("@cost", l.UnitCost); cmd.Parameters.AddWithValue("@total", NumOrDefault(it.Total, 0)); cmd.Parameters.AddWithValue("@qty", NumOrDefault(it.Qty, 0)); cmd.Parameters.AddWithValue("@size", @@ -201,6 +240,36 @@ public class SaveService flagged = _batch.FlaggedCount(changes); } + // Change log. Skipped while the table is not installed; a failed + // log row must not cost the store its invoice. + foreach (var c in changes) + { + try + { + using var cmd = Cmd(conn, tx, + $"IF OBJECT_ID(@tbl) IS NOT NULL INSERT INTO {_t.ProductChangesTable} " + + "(ChangeType, UPC, Description, Vendor, InvoiceNumber, PONumber, BatchNo, OldCost, NewCost, Retail, Pack, EmpNo) " + + "VALUES ('COST', @upc, @desc, @vendor, @inv, @po, @batch, @old, @new, @retail, @pack, @emp)"); + cmd.Parameters.AddWithValue("@tbl", _t.ProductChangesTable); + cmd.Parameters.AddWithValue("@upc", c.Upc); + cmd.Parameters.AddWithValue("@desc", Truncate(c.Description, 30)); + cmd.Parameters.AddWithValue("@vendor", vendorId); + cmd.Parameters.AddWithValue("@inv", invoiceNum); + cmd.Parameters.AddWithValue("@po", poNumber); + cmd.Parameters.AddWithValue("@batch", (object?)batchNo ?? DBNull.Value); + cmd.Parameters.AddWithValue("@old", (object?)c.OldCost ?? DBNull.Value); + cmd.Parameters.AddWithValue("@new", c.NewCost); + cmd.Parameters.AddWithValue("@retail", (object?)c.Retail ?? DBNull.Value); + cmd.Parameters.AddWithValue("@pack", (object?)c.Pack ?? DBNull.Value); + cmd.Parameters.AddWithValue("@emp", _cfg.ReceiverEmpNo); + cmd.ExecuteNonQuery(); + } + catch (Exception ex) + { + Log.Warn("changes", $"cost change for upc {c.Upc} not logged: {ex.Message}"); + } + } + if (sourceFile != null) { Directory.CreateDirectory(_cfg.ArchiveRoot); diff --git a/src/WebHost.cs b/src/WebHost.cs index c84f5cc..70b8a2b 100644 --- a/src/WebHost.cs +++ b/src/WebHost.cs @@ -98,7 +98,13 @@ public class WebHost }); app.UseDefaultFiles(); - app.UseStaticFiles(); + // WebView2 heuristically caches wwwroot files when no Cache-Control is + // sent, so a UI edit would not show up until the cache expired. + app.UseStaticFiles(new StaticFileOptions + { + OnPrepareResponse = ctx => + ctx.Context.Response.Headers["Cache-Control"] = "no-store, must-revalidate" + }); // Warm the product catalog off the startup path so the first invoice // opened does not pay the load cost. Failures are ignored here; the @@ -172,7 +178,7 @@ public class WebHost { var arr = new JsonArray(); foreach (var item in store.List()) - arr.Add(new JsonObject { ["id"] = item.Id, ["updated_at"] = item.UpdatedAt }); + arr.Add(new JsonObject { ["id"] = item.Id, ["updated_at"] = item.UpdatedAt, ["stage"] = store.Stage(item.Id) }); return Json(arr); }); @@ -271,6 +277,7 @@ public class WebHost }); app.MapGet("/api/stats", () => Guarded(() => data.Stats(store))); + app.MapGet("/api/me", () => Results.Json(new { first_name = _cfg.ReceiverFirstName })); // ---------- live updates (SSE) ---------- app.MapGet("/api/events", async (HttpContext ctx) => @@ -390,9 +397,14 @@ public class WebHost form["dept_id"].ToString().Trim(), form["cost"].ToString().Trim(), form["item_code"].ToString(), form["tax"].ToString().Trim(), form["foodstamp"].ToString().Trim(), form["scale"].ToString().Trim(), form["wicable"].ToString().Trim(), - form["pack"].ToString().Trim(), form["size"].ToString())); + form["pack"].ToString().Trim(), form["size"].ToString(), + form["vendor_id"].ToString().Trim(), form["invoice_number"].ToString())); }); + // ---------- change log ---------- + app.MapGet("/api/changes", (string? date_from, string? date_to, string? type) => + Guarded(() => data.ProductChanges(date_from?.Trim() ?? "", date_to?.Trim() ?? "", type?.Trim() ?? ""))); + // ---------- archive ---------- app.MapGet("/api/archive", (string? vendor, string? inv, string? po, string? date_from, string? date_to, string? amt_min, string? amt_max, string? product) => diff --git a/wwwroot/archive.html b/wwwroot/archive.html index 4a024b5..f870a9a 100644 --- a/wwwroot/archive.html +++ b/wwwroot/archive.html @@ -10,9 +10,10 @@

Invoice Archive - + New Invoice + Home / Upload Pending - Archive + Archive + Changes

@@ -41,11 +42,11 @@
- +
- +
@@ -54,18 +55,19 @@
+
-
+
- - - - + + + + @@ -74,6 +76,14 @@
+
Select an invoice above to preview.
@@ -138,16 +148,83 @@
+ + + + +

+ Product Changes + Home / Upload + Pending + Archive + Changes +

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+ +
+ +
+
DateVendorInvoice #TotalDateVendorInvoice #Total
+ + + + + + + + + + + + + + + + + +
DateTypeUPCDescriptionVendorInvoice #PO #Batch #Old CostNew CostRetailPack
+
+ + + + + diff --git a/wwwroot/css/style.css b/wwwroot/css/style.css index ffc2785..2de2ccc 100644 --- a/wwwroot/css/style.css +++ b/wwwroot/css/style.css @@ -17,9 +17,10 @@ h1 { } h1 a { color: #aaa; font-size: 12px; font-weight: normal; margin-left: 16px; text-decoration: none; } h1 a:hover { color: #eee; } +h1 a.active { color: #eee; box-shadow: inset 0 -2px 0 #6699CC; padding-bottom: 2px; } #pageMode { margin-left: 6px; } -#btnTogglePreview, #btnViewParsed { +#btnTogglePreview, #btnViewParsed, .nav-draft { display: none; background: #555; color: #eee; @@ -29,10 +30,13 @@ h1 a:hover { color: #eee; } font-size: 11px; cursor: pointer; } -#btnViewParsed { margin-left: auto; } +#btnPrevDraft { margin-left: auto; } +#btnViewParsed { margin-left: 14px; } #btnTogglePreview { margin-left: 6px; } -#btnTogglePreview:hover, #btnViewParsed:hover { border-color: #6699CC; color: #6699CC; } -body.review-page #btnTogglePreview, body.review-page #btnViewParsed { display: inline-block; } +#btnTogglePreview:hover, #btnViewParsed:hover, .nav-draft:hover:not(:disabled) { border-color: #6699CC; color: #6699CC; } +.nav-draft:disabled { color: #777; cursor: default; } +.draft-pos { color: #999; font-size: 11px; font-weight: normal; margin: 0 6px; } +body.review-page #btnTogglePreview, body.review-page #btnViewParsed, body.review-page .nav-draft { display: inline-block; } /* The review screen owns the whole viewport: nothing outside the preview and the line-item table scrolls, so the form never needs a browser zoom-out. */ @@ -118,20 +122,26 @@ body.review-page h1 { flex-shrink: 0; } left: 0; width: 16px; min-height: 3px; - background: rgba(220, 40, 40, 0.8); + /* Solid edge bar plus a see-through fill: the marker sits over the + left margin, where the UPC usually is, so the text must stay readable. */ + box-sizing: border-box; + border-left: 4px solid rgba(220, 40, 40, 0.9); + background: rgba(220, 40, 40, 0.15); border-radius: 0 2px 2px 0; cursor: pointer; z-index: 5; } .line-marker:hover { - background: #ff3030; + border-left-color: #ff3030; + background: rgba(255, 48, 48, 0.2); width: 26px; } /* The line last jumped to, from either direction. */ .line-marker.active { - background: #ffcc44; + border-left-color: #ffcc44; + background: rgba(255, 204, 68, 0.25); width: 30px; - box-shadow: 0 0 6px rgba(255, 204, 68, 0.7); + box-shadow: 0 0 0 1px rgba(255, 204, 68, 0.8); } .line-marker:hover::after { content: attr(data-tip); @@ -275,27 +285,27 @@ body.review-page h1 { flex-shrink: 0; } #lineItemsTable { width: 100%; - min-width: 1180px; /* below this the table-scroll wrapper scrolls sideways */ + min-width: 1078px; /* below this the table-scroll wrapper scrolls sideways */ table-layout: fixed; border-collapse: collapse; font-size: 11.5px; } /* Fixed layout: these widths drive the columns, Description takes the rest. */ #lineItemsTable col.c-upc { width: 92px; } -#lineItemsTable col.c-cert { width: 78px; } -#lineItemsTable col.c-po { width: 44px; } -#lineItemsTable col.c-pocost { width: 56px; } -#lineItemsTable col.c-qty { width: 52px; } -#lineItemsTable col.c-cost { width: 68px; } -#lineItemsTable col.c-pack { width: 50px; } -#lineItemsTable col.c-unitcost { width: 62px; } -#lineItemsTable col.c-retail { width: 58px; } -#lineItemsTable col.c-gm { width: 54px; } -#lineItemsTable col.c-total { width: 64px; } -#lineItemsTable col.c-size { width: 48px; } -#lineItemsTable col.c-uom { width: 42px; } -#lineItemsTable col.c-match { width: 152px; } -#lineItemsTable col.c-del { width: 28px; } +#lineItemsTable col.c-cert { width: 72px; } +#lineItemsTable col.c-po { width: 38px; } +#lineItemsTable col.c-pocost { width: 46px; } +#lineItemsTable col.c-qty { width: 44px; } +#lineItemsTable col.c-cost { width: 64px; } +#lineItemsTable col.c-pack { width: 44px; } +#lineItemsTable col.c-unitcost { width: 60px; } +#lineItemsTable col.c-retail { width: 52px; } +#lineItemsTable col.c-gm { width: 50px; } +#lineItemsTable col.c-total { width: 74px; } +#lineItemsTable col.c-size { width: 42px; } +#lineItemsTable col.c-uom { width: 36px; } +#lineItemsTable col.c-match { width: 138px; } +#lineItemsTable col.c-del { width: 24px; } #lineItemsTable th { background: #444; color: #6699CC; @@ -421,7 +431,7 @@ body.review-page h1 { flex-shrink: 0; } } .btn-search-cancel:hover { color: #6699CC; } -#btnSave, #btnScan, #btnDeleteDraft, #btnRestoreDefaults { +#btnSave, #btnScan { background: #6699CC; color: #111; border: none; @@ -431,17 +441,22 @@ body.review-page h1 { flex-shrink: 0; } font-weight: bold; cursor: pointer; } -#btnDeleteDraft { - background: #3a2a2a; - color: #ff9999; - border: 1px solid #663333; +#btnSave:hover, #btnScan:hover { opacity: 0.85; } +#btnSave:disabled { opacity: 0.5; cursor: default; } + +/* Secondary draft actions: kept small and away from Save. */ +.btn-quiet { + background: transparent; + border: 1px solid #555; + color: #aaa; + padding: 3px 9px; + border-radius: 3px; + font-size: 11px; + cursor: pointer; } -#btnRestoreDefaults { - background: #4a3a00; - color: #ffcc44; - border: 1px solid #776600; -} -#btnSave:hover, #btnScan:hover, #btnDeleteDraft:hover, #btnRestoreDefaults:hover { opacity: 0.85; } +.btn-quiet:hover { border-color: #6699CC; color: #6699CC; } +.btn-quiet-danger:hover { border-color: #884444; color: #ff8888; } +.autosave-error { color: #ff9999; } /* ---------- parsed data viewer ---------- */ #parsedModal { @@ -669,6 +684,12 @@ body.review-page h1 { flex-shrink: 0; } margin: 0 auto; padding: 26px 16px 40px; } +.dash-welcome { + color: #ddd; + font-size: 22px; + font-weight: normal; + margin: 0 0 16px; +} .dash-hero { background: linear-gradient(160deg, #3d3d3d, #353535); border: 1px solid #555; @@ -752,7 +773,37 @@ body.review-page h1 { flex-shrink: 0; } .sc-value .stat-num { color: #88ff88; } .sc-lines { border-left-color: #5a8fd0; } .sc-lines .stat-num { color: #88bbff; } -.dash-foot { color: #777; font-size: 11px; margin-top: 16px; text-align: right; } +.dash-foot { color: #999; font-size: 11px; margin-top: 16px; text-align: right; } +.dash-actions { align-items: center; } +.dash-note { color: #aaa; font-size: 12px; } +.dash-btn.disabled { background: #444; color: #999; border-color: #555; } + +/* Ready / Queued / Errors lead; the rest are context. */ +.stats-primary { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); margin-bottom: 12px; } +.stats-primary .stat-card { padding: 20px 22px; } +.stats-primary .stat-num { font-size: 40px; } +.stats-secondary { grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; } +.stats-secondary .stat-card { padding: 10px 14px; border-left-width: 3px; } +.stats-secondary .stat-num { font-size: 20px; } +.stats-secondary .stat-label { font-size: 11px; margin-top: 5px; } +.stat-card.is-zero .stat-num { color: #888; } +.stat-card.sc-error.is-zero { border-left-color: #555; } + +/* Per-file upload progress, updated live from the worker's stage events. */ +.upload-list { list-style: none; margin-top: 12px; max-height: 220px; overflow-y: auto; } +.upload-list li { + display: flex; + align-items: center; + gap: 10px; + padding: 5px 8px; + border-bottom: 1px solid #444; + font-size: 12px; +} +.upload-list .ul-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.upload-list .ul-open { color: #6699CC; text-decoration: none; font-weight: bold; } +.upload-list .ul-open:hover { text-decoration: underline; } +.stage.stage-uploading { background: #3a3a3a; color: #bbb; } +#uploadZone:focus-visible { outline: 2px solid #6699CC; outline-offset: 2px; } /* ---------- pending list page ---------- */ #content { padding: 12px; } @@ -799,14 +850,50 @@ table.list tr:hover td { background: #3f3f3f; } .btn:hover { border-color: #6699CC; color: #6699CC; } .btn-delete { border-color: #663333; color: #ff9999; background: #3a2a2a; } .btn-process { border-color: #336655; color: #88ffcc; background: #2a3a35; } -#queueToolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; } -#queueToolbar .btn-process { padding: 7px 16px; font-size: 13px; font-weight: bold; } -#queueToolbar .btn-process:disabled { opacity: 0.6; cursor: default; } #emptyState { color: #888; padding: 28px; text-align: center; background: #3a3a3a; border-radius: 5px; display: none; } /* ---------- archive page ---------- */ body.archive-page { display: flex; flex-direction: column; height: 100vh; overflow: hidden; } -#listPanel { flex-shrink: 0; height: 210px; overflow-y: auto; border-bottom: 2px solid #555; background: #2e2e2e; } +#listPanel { + flex-shrink: 0; + height: 210px; + min-height: 80px; + max-height: 70vh; + overflow-y: auto; + resize: vertical; + border-bottom: 2px solid #555; + background: #2e2e2e; +} +#listPanel th.sortable { cursor: pointer; user-select: none; } +#listPanel th.sortable:hover { color: #99bbe0; } +#listPanel th.sort-asc::after { content: " \25B2"; font-size: 9px; } +#listPanel th.sort-desc::after { content: " \25BC"; font-size: 9px; } +.result-count { color: #999; font-size: 11px; margin-left: 6px; } + +#archiveToolbar { + position: sticky; + top: 0; + margin: -10px -10px 8px; + padding: 6px 10px; + background: #222; + border-bottom: 1px solid #3a3a3a; + display: flex; + align-items: center; + gap: 6px; + z-index: 2; +} +#archiveToolbar button { + background: #555; + color: #eee; + border: 1px solid #666; + border-radius: 3px; + padding: 2px 9px; + font-size: 12px; + line-height: 1.4; + cursor: pointer; +} +#archiveToolbar button:hover { border-color: #6699CC; color: #6699CC; } +#archiveToolbar #btnOpenOriginal { margin-left: auto; margin-right: 0; } #filterPanel { flex-shrink: 0; background: #333; border-bottom: 1px solid #555; padding: 10px 12px; } #filterPanel form { display: flex; flex-wrap: wrap; gap: 8px 10px; align-items: flex-end; } .filter-field { display: flex; flex-direction: column; gap: 3px; } @@ -842,7 +929,8 @@ body.archive-page { display: flex; flex-direction: column; height: 100vh; overfl #detailLayout { display: flex; flex: 1; overflow: hidden; } #archiveLeft { flex: 1; overflow-y: auto; background: #222; padding: 10px; } -#archiveLeft img { max-width: 100%; display: block; border-radius: 2px; } +/* Width is set by the archive zoom (applyZoom). */ +#archiveLeft img { max-width: none; display: block; border-radius: 2px; } #archiveLeft img + img { margin-top: 8px; } #archiveRight { flex: 1; overflow-y: auto; padding: 12px; background: #333; border-left: 1px solid #444; } @@ -855,9 +943,9 @@ body.archive-page { display: flex; flex-direction: column; height: 100vh; overfl .info-value { color: #eee; font-size: 12px; } /* Header and the batch it produced sit side by side; the batch card takes the - space the header card was leaving empty. Both stretch to the taller of the - two so the header card does not leave a ragged gap beside the batch list. */ -.detail-top { display: flex; gap: 12px; align-items: stretch; flex-wrap: wrap; } + space the header card was leaving empty. Each card ends at its own content - + stretching the short header card to the batch list's height left dead space. */ +.detail-top { display: flex; gap: 12px; align-items: flex-start; flex-wrap: wrap; } .detail-top > .info-card { flex: 1 1 300px; min-width: 0; } .detail-top > .info-card:first-child { flex: 0 1 auto; } #batchCard h2 { display: flex; align-items: center; gap: 8px; } @@ -964,7 +1052,70 @@ body.archive-page { display: flex; flex-direction: column; height: 100vh; overfl /* ---- review sanity checks ---------------------------------------------- */ -.attention-count { color: #ffcc44; font-weight: bold; } +.attention-count { color: #ffcc44; font-weight: bold; cursor: pointer; text-decoration: underline dotted; } +.attention-count:hover { color: #ffe08a; } .attention-none { color: #88ff88; } +#lineItemsTable.attention-only tbody tr:not(.needs-attention) { display: none; } .save-blockers { color: #ffcc44; font-size: 11px; margin-left: 12px; } + +.total-ok { color: #88ff88; } +.total-diff { color: #ffcc44; font-weight: bold; } + +/* Inline validation, set by handleSave and cleared on the next edit. */ +.field-error { color: #ff9999; font-size: 10px; } +.hdr-field input.field-invalid, .hdr-field select.field-invalid, +#lineItemsTable input.field-invalid { border-color: #cc5555; background: #3a2626; } +#lineItemsBody tr.row-invalid td:first-child { box-shadow: inset 3px 0 0 #cc5555; } + +/* Line total that does not equal qty x case cost. */ +#lineItemsTable input.item-total.total-mismatch { border-color: #b08a2a; color: #ffcc44; } + +.match-actions { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 3px; } +.match-actions .search-toggle, .match-actions .btn-new-item { margin-top: 0; } +.search-result-item.active, .search-result-new.active { background: #3a3a3a; } +.search-result-item.active .search-result-desc { color: #6699CC; } + +.status-actions { margin-left: 10px; } +.status-actions a, .status-actions button { + margin-left: 6px; + background: #2a2a2a; + border: 1px solid #5a8a5a; + color: #cfe; + border-radius: 3px; + padding: 2px 9px; + font-size: 11px; + cursor: pointer; + text-decoration: none; +} +.status-actions a:hover, .status-actions button:hover { border-color: #88ff88; } +#reviewSection.saved { opacity: 0.45; pointer-events: none; } + +/* Undo toast for row removal and Accept All. */ +#toast { + display: none; + position: fixed; + left: 50%; + bottom: 22px; + transform: translateX(-50%); + background: #222; + color: #eee; + border: 1px solid #555; + border-radius: 5px; + padding: 8px 12px; + font-size: 12px; + z-index: 700; + align-items: center; + gap: 12px; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.5); +} +#toast button { + background: transparent; + border: 1px solid #6699CC; + color: #6699CC; + border-radius: 3px; + padding: 2px 10px; + font-size: 11px; + font-weight: bold; + cursor: pointer; +} diff --git a/wwwroot/index.html b/wwwroot/index.html index c9e9dbe..32c26bd 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -11,9 +11,13 @@

Invoice Parser - + New Invoice + Home / Upload Pending Archive + Changes + + +

@@ -60,8 +64,9 @@
- +
+
@@ -90,8 +95,9 @@
-

Line Items +

Line Items + @@ -144,12 +150,12 @@

- Draft auto-saves to Pending + + + Draft auto-saves to Pending - - - +
@@ -161,23 +167,25 @@
+ +
-
+
📤
Drag and drop or click - PDF or image (JPG, PNG). Select multiple to batch upload. Files parse automatically in the background; review them under Pending.
+
-
+ + +
+ +
-
+
In progress
+
-
Uploaded today
diff --git a/wwwroot/js/app.js b/wwwroot/js/app.js index eb0ed63..4140a0f 100644 --- a/wwwroot/js/app.js +++ b/wwwroot/js/app.js @@ -12,6 +12,10 @@ var rowCandidates = {} var pdfLineMap = null // [{page, top, h}] per OCR text line; fractions of page height var isDashboard = false var nextRowIndex = 0 // data-index is a row key, not a position - manual rows get fresh ones +var saveDirty = false // an edit is waiting on the autosave debounce +var currentDraftId = null +var draftNav = { prev: null, next: null } +var dashRefreshTimer = null $(document).ready(function () { var resumeID = getQueryParam('pending') @@ -23,10 +27,21 @@ $(document).ready(function () { $('#pageMode').text('- Review') initPreviewSplit() initPreviewZoom() + currentDraftId = resumeID var boot = [loadVendors(), loadDepartments(), loadPOList()] Promise.all(boot).then(function () { resumePending(resumeID) }) + loadDraftNav() } else { $('#dashboard').show() + $('#navHome').addClass('active') + $('#uploadZone').on('keydown', function (e) { + if (e.key !== 'Enter' && e.key !== ' ') return + e.preventDefault() + $('#fileInput').click() + }) + fetch('/api/me').then(function (r) { return r.json() }).then(function (me) { + if (me.first_name) $('#dashWelcome').text('Welcome, ' + me.first_name).show() + }).catch(function () {}) loadDashboard() setInterval(loadDashboard, 15000) } @@ -42,7 +57,6 @@ $(document).ready(function () { $('#btnAddLine').on('click', addBlankRow) $('#btnCreateProduct').on('click', handleCreateProduct) $('#btnCloseModal').on('click', closeNewProductModal) - $('#btnParseQueued').on('click', handleParseQueued) $('#btnViewParsed').on('click', openParsedModal) $('#btnCloseParsed').on('click', closeParsedModal) $('#parsedModal').on('click', function (e) { if (e.target === this) closeParsedModal() }) @@ -53,13 +67,67 @@ $(document).ready(function () { $('#' + $(this).data('pane')).css('display', 'flex') }) $(document).on('keydown', function (e) { - if (e.key !== 'Escape') return - closeNewProductModal() - closeParsedModal() + if (e.key === 'Escape') { + closeNewProductModal() + closeParsedModal() + return + } + if (isDashboard || $('#newProductModal').is(':visible')) return + if (e.ctrlKey && (e.key === 's' || e.key === 'S')) { + e.preventDefault() + if (!$('#btnSave').prop('disabled')) handleSave() + } else if (e.key === 'F8') { + e.preventDefault() + jumpToAttention(e.shiftKey ? -1 : 1) + } }) $(document).on('input change', '.item-upc,.item-cert,.item-qty,.item-cost', function () { if ($('#poSelect').val() && poCompareData) applyPoComparison() }) + $(document).on('input change', '.item-qty,.item-cost,.item-total', function () { + var row = $(this).closest('tr') + if ($(this).hasClass('item-total')) row.removeData('autoTotal') + else autoFillTotal(row) + refreshReviewSummary() + }) + $(document).on('input change', '#invoiceTotal', refreshReviewSummary) + $(document).on('input', '#invoiceNumber', checkInvoiceNumber) + $(document).on('input change', '.field-invalid', function () { $(this).removeClass('field-invalid') }) + $(document).on('click', '.attention-count', function () { jumpToAttention(1) }) + $('#chkAttentionOnly').on('change', function () { + $('#lineItemsTable').toggleClass('attention-only', this.checked) + }) + $('#btnPrevDraft').on('click', function () { if (draftNav.prev) leaveTo('index.html?pending=' + encodeURIComponent(draftNav.prev)) }) + $('#btnNextDraft').on('click', function () { if (draftNav.next) leaveTo('index.html?pending=' + encodeURIComponent(draftNav.next)) }) + // Leaving mid-debounce would drop the last edit; save it first. + $(document).on('click', 'a[href]', function (e) { + if (!saveDirty || !pendingId) return + e.preventDefault() + leaveTo(this.href) + }) + window.addEventListener('beforeunload', function (e) { + if (!saveDirty || !pendingId) return + // keepalive requests are capped at 64KB; past that, ask before leaving. + var body = JSON.stringify(collectPendingState('editing')) + if (new Blob([body]).size < 60000) { + fetch('/api/pending/' + encodeURIComponent(pendingId) + '/state', { + method: 'POST', body: body, keepalive: true, + headers: { 'Content-Type': 'application/json; charset=utf-8' } + }) + return + } + e.preventDefault() + e.returnValue = '' + }) + $(document).on('mousedown', function (e) { + if ($(e.target).closest('.search-results,.product-search').length) return + $('.search-results:visible').hide() + }) + $(document).on('keydown', '#newProductModal input, #newProductModal select', function (e) { + if (e.key !== 'Enter') return + e.preventDefault() + handleCreateProduct() + }) $(document).on('input change', '#reviewSection input,#reviewSection textarea,#reviewSection select,#ocrText', function () { queuePendingStateSave('editing') }) @@ -71,8 +139,8 @@ $(document).ready(function () { $('.search-results:visible').hide() }, true) $(document).on('blur', '#np_upc', function () { - var v = $(this).val().replace(/\D/g, '') - if (v.length > 0) $(this).val(v.padStart(13, '0')) + var v = padUpc13($(this).val()) + if (v) $(this).val(v) }) $('#uploadZone').on('dragover', function (e) { @@ -188,9 +256,12 @@ function connectEvents() { try { msg = JSON.parse(e.data) } catch (err) { return } if (msg.type !== 'stage') return if (isDashboard) { - loadDashboard() + setUploadStage(msg.id, msg.stage, msg.error) + clearTimeout(dashRefreshTimer) + dashRefreshTimer = setTimeout(loadDashboard, 400) // coalesce bursts return } + loadDraftNav() if (pendingId && msg.id === pendingId) { if (msg.stage === 'parsed') { resumePending(pendingId) @@ -235,14 +306,21 @@ async function queueFiles(fileList) { setStatus('Uploading ' + files.length + ' file(s)...', 'loading') var fd = new FormData() files.forEach(function (f) { fd.append('files', f, f.name) }) + var items = files.map(function (f) { return addUploadItem(f.name) }) try { var resp = await fetch('/api/upload', { method: 'POST', body: fd }) var result = await readJsonResponse(resp, 'upload') var ok = 0, fail = 0, failMsgs = [] - ;(result.results || []).forEach(function (r) { + // Results come back in the order the files were sent. + ;(result.results || []).forEach(function (r, i) { if (r.ok) ok++ else { fail++; failMsgs.push((r.file || 'file') + ': ' + (r.error || 'failed')) } + if (!items[i]) return + items[i].attr('data-id', r.id) + var seen = uploadStages[r.id] + if (seen) setUploadStage(r.id, seen.stage, seen.error) + else setUploadStage(r.id, r.ok ? 'queued' : 'error', r.error) }) var msg = 'Queued ' + ok + ' invoice(s)' + (fail ? (', ' + fail + ' failed (' + failMsgs.join('; ') + ')') : '') + '. They parse automatically - check Pending.' @@ -250,11 +328,40 @@ async function queueFiles(fileList) { setStatus(msg, (fail || rejected.length) ? 'error' : 'success') } catch (err) { setStatus('Upload failed: ' + err.message, 'error') + items.forEach(function (li) { showUploadStage(li, 'error', err.message) }) } $('#fileInput').val('') if (isDashboard) loadDashboard() } +// id -> latest {stage, error} from SSE. Events can arrive before the upload +// response has told us which row an id belongs to. +var uploadStages = {} + +function addUploadItem(name) { + var list = $('#uploadList').show() + var li = $('
  • ') + li.find('.ul-name').text(name).attr('title', name) + showUploadStage(li, 'uploading') + list.prepend(li) + list.children().slice(30).remove() + return li +} + +function setUploadStage(id, stage, error) { + if (!id) return + uploadStages[id] = { stage: stage, error: error } + var li = $('#uploadList li[data-id="' + id + '"]') + if (li.length) showUploadStage(li, stage, error) +} + +function showUploadStage(li, stage, error) { + li.find('.stage').attr('class', 'stage stage-' + stage).text(stage).attr('title', error || '') + var id = li.attr('data-id') + li.find('.ul-open').toggle(stage === 'parsed' && !!id) + .attr('href', id ? 'index.html?pending=' + encodeURIComponent(id) : null) +} + // ------------------------------------------------------- // Landing dashboard // ------------------------------------------------------- @@ -273,32 +380,32 @@ async function loadDashboard() { $('#statTotal').text(s.total) $('#statValue').text('$' + (parseFloat(s.pending_value) || 0).toFixed(2)) $('#statLines').text(s.pending_lines) + $('#statReady,#statQueued,#statError').each(function () { + $(this).closest('.stat-card').toggleClass('is-zero', $(this).text() === '0') + }) $('#dashUpdated').text('Updated ' + new Date().toLocaleTimeString()) } catch (e) { console.warn('Dashboard stats failed:', e) } + loadReviewNext() } -async function handleParseQueued() { - var btn = $('#btnParseQueued') - btn.prop('disabled', true) +// Points the main button straight at the oldest draft waiting for review. +async function loadReviewNext() { try { - var resp = await fetch('/api/parse-queued', { method: 'POST' }) - var result = await readJsonResponse(resp, 'parse-queued') - var msg, kind = 'success' - if (result.queued) { - msg = 'Queued ' + result.queued + ' invoice(s) for parsing - they process in the background.' - } else if (result.already_waiting) { - msg = result.already_waiting + ' invoice(s) are already in the queue and still waiting their turn.' + var ids = await fetchReviewableIds() + var btn = $('#btnReviewNext') + if (ids.length) { + btn.attr('href', 'index.html?pending=' + encodeURIComponent(ids[0])) + .removeClass('disabled').text('Review next invoice') + $('#reviewNextNote').text(ids.length + ' waiting for review') } else { - msg = 'Nothing to parse.' + btn.attr('href', 'pending.html').addClass('disabled').text('Open Pending') + $('#reviewNextNote').text('Nothing waiting for review') } - setStatus(msg, kind) } catch (e) { - setStatus('Parse failed: ' + e.message, 'error') + console.warn('Review-next lookup failed:', e) } - btn.prop('disabled', false) - loadDashboard() } // ------------------------------------------------------- @@ -380,6 +487,12 @@ function normalizeUpc(u) { return String(u).replace(/\D/g, '').replace(/^0+/, '') } +// Database convention: 13 digits, zero padded, no separators. +function padUpc13(u) { + var v = String(u || '').replace(/\D/g, '') + return v.length > 0 ? v.padStart(13, '0') : '' +} + function clearPoComparison() { $('#poCompareSummary').text('') $('#lineItemsBody tr') @@ -442,14 +555,15 @@ function applyPoComparison() { if (qDiff || cDiff) { reviewCount++ row.addClass('po-diff').attr('data-po-status', 'review') - row.find('.match-badge').last().after('VALIDATE DATA') + var note = qDiff && cDiff ? 'qty + cost differ from PO' : qDiff ? 'qty differs from PO' : 'cost differs from PO' + row.find('.match-badge').last().after('' + note + '') } else { row.addClass('po-match').attr('data-po-status', 'match') } } else { reviewCount++ row.addClass('po-inv-only').attr('data-po-status', 'invoice-only') - row.find('.match-badge').last().after('VALIDATE DATA') + row.find('.match-badge').last().after('not on PO') } }) @@ -515,7 +629,7 @@ function populateForm(data, matchData) { } if (v.label) { var badgeClass = v.label === 'exact' ? 'badge-exact' : v.label === 'fuzzy' ? 'badge-fuzzy' : 'badge-none' - var badgeText = v.label === 'exact' ? 'matched' : 'VALIDATE DATA' + var badgeText = v.label === 'exact' ? 'matched' : v.label === 'fuzzy' ? 'check vendor' : 'no vendor match' $('#vendorBadge').text(badgeText).attr('class', 'match-badge ' + badgeClass).show() } @@ -532,6 +646,7 @@ function populateForm(data, matchData) { if ($('#poSelect').val() && poCompareData) applyPoComparison() renderLineMarkers() refreshReviewSummary() + checkInvoiceNumber() pendingDefaultState = collectDraftSnapshot() } @@ -559,26 +674,92 @@ function rowNeedsAttention(row) { function refreshReviewSummary() { var rows = $('#lineItemsBody tr') var attention = 0, unmatched = 0 + var lineSum = 0, anyTotal = false rows.each(function () { var row = $(this) - if (rowNeedsAttention(row)) attention++ + var needs = rowNeedsAttention(row) + row.toggleClass('needs-attention', needs) + if (needs) attention++ var s = row.attr('data-status') if (s === 'unmatched' || s === 'fuzzy') unmatched++ + else row.removeClass('row-invalid') + var t = numOrNull(row.find('.item-total').val()) + if (t != null) { lineSum += t; anyTotal = true } + checkLineTotal(row) }) var summary = rows.length + ' line' + (rows.length === 1 ? '' : 's') if (rows.length) { summary += attention - ? ' - ' + attention + ' need attention' + ? ' - ' + attention + ' need attention' : ' - all reviewed' } $('#lineSummary').html(summary) + // Informational only: the header total can legitimately include tax, + // deposits or freight that are not line items. + var check = $('#totalCheck') + var hdrTotal = numOrNull($('#invoiceTotal').val()) + if (!rows.length || !anyTotal) check.html('') + else if (hdrTotal == null) check.html('lines sum $' + lineSum.toFixed(2)) + else if (Math.abs(hdrTotal - lineSum) < 0.005) check.html('lines = total') + else check.html('' + + 'lines $' + lineSum.toFixed(2) + ' vs total $' + hdrTotal.toFixed(2) + + ' (diff $' + (hdrTotal - lineSum).toFixed(2) + ')') + var blockers = $('#saveBlockers') if (unmatched) blockers.text(unmatched + ' row' + (unmatched === 1 ? '' : 's') + ' must be matched or accepted before saving').show() else blockers.hide() } +// Flags a line total that is not qty x case cost. +function checkLineTotal(row) { + var qty = numOrNull(row.find('.item-qty').val()) + var cost = numOrNull(row.find('.item-cost').val()) + var input = row.find('.item-total') + var total = numOrNull(input.val()) + var off = qty != null && cost != null && total != null && Math.abs(qty * cost - total) > 0.01 + input.toggleClass('total-mismatch', off) + .attr('title', off ? 'Qty x case cost = $' + (qty * cost).toFixed(2) : '') +} + +// A blank total is filled from qty x cost and kept in step until edited by hand. +function autoFillTotal(row) { + var input = row.find('.item-total') + if (input.val() !== '' && !row.data('autoTotal')) return + var qty = numOrNull(row.find('.item-qty').val()) + var cost = numOrNull(row.find('.item-cost').val()) + if (qty == null || cost == null) return + input.val((qty * cost).toFixed(2)) + row.data('autoTotal', true) +} + +function checkInvoiceNumber() { + var len = ($('#invoiceNumber').val() || '').trim().length + $('#errInvoiceNumber').text('max 100 characters (' + len + ')').toggle(len > 100) +} + +// Cycles through rows that still need a decision, from the last one visited. +var attentionCursor = null +function jumpToAttention(dir) { + var rows = $('#lineItemsBody tr.needs-attention') + if (!rows.length) return + var pos = -1 + rows.each(function (n) { if ($(this).attr('data-index') === attentionCursor) pos = n }) + pos = pos < 0 ? (dir > 0 ? 0 : rows.length - 1) : (pos + dir + rows.length) % rows.length + var row = rows.eq(pos) + attentionCursor = row.attr('data-index') + flashRow(row) + if (row.hasClass('has-source')) showRowOnPreview(row) +} + +function flashRow(row) { + row[0].scrollIntoView({ behavior: 'smooth', block: 'center' }) + row.removeClass('row-flash') + void row[0].offsetWidth // restart the flash animation + row.addClass('row-flash') +} + // ------------------------------------------------------- // Source-line markers on the preview (text PDFs only) // ------------------------------------------------------- @@ -671,18 +852,20 @@ function buildRow(i, item, match) { : (label === 'fuzzy' || label === 'cert_code' || label === 'upc_norm' || label === 'upc+fuzzy') ? 'badge-fuzzy' : 'badge-none' var status = badgeCls === 'badge-exact' ? 'exact' : badgeCls === 'badge-fuzzy' ? 'fuzzy' : 'unmatched' - var badgeText = label === 'exact' ? 'matched' : 'VALIDATE DATA' + var badgeText = status === 'exact' ? 'matched' : status === 'fuzzy' ? 'check match' : 'no match' var warnHtml = match.inactive ? 'INACTIVE' : '' // The search box is available on every row, matched or not - the suggested // product is not always the right one. var isUnmatched = (label === 'unmatched') - var searchHtml = '
    ' + + var searchHtml = '
    ' + + '' + + '' + + '
    ' + '' + - '
    ' + buildSearchUI(i) + '
    ' // Only a fuzzy row can be accepted: it already carries a real product. // An unmatched row has nothing to accept and must be matched or created. @@ -841,7 +1024,7 @@ function addBlankRow() { // Inline product search // ------------------------------------------------------- var searchTimers = {} -$(document).on('keyup', '.product-search', function () { +$(document).on('input', '.product-search', function () { var inp = $(this) var idx = inp.data('item') clearTimeout(searchTimers[idx]) @@ -853,6 +1036,7 @@ $(document).on('keyup', '.product-search', function () { try { var resp = await fetch('/api/products/search?q=' + encodeURIComponent(q)) var results = await resp.json() + if (inp.val().trim() !== q) return // a newer search is on its way if (results.error) { showDbError(results.error); div.hide().empty(); return } div.empty() if (results.length === 0) { @@ -875,12 +1059,34 @@ $(document).on('keyup', '.product-search', function () { .on('click', function () { applyProduct(idx, $(this).data('product')); div.hide().empty() }) .appendTo(div) }) + div.children().first().addClass('active') div.show() positionSearchResults(inp, div) } catch (e) { console.error(e) } }, 300) }) +// Arrow keys move through the results, Enter picks, Escape closes. +$(document).on('keydown', '.product-search', function (e) { + var div = $('#search_' + $(this).data('item')) + if (e.key === 'Escape') { + if (div.is(':visible')) { div.hide().empty(); e.stopPropagation() } + return + } + var items = div.children('.search-result-item,.search-result-new') + if (!div.is(':visible') || !items.length) return + var cur = items.index(items.filter('.active')) + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault() + var n = e.key === 'ArrowDown' ? Math.min(items.length - 1, cur + 1) : Math.max(0, cur - 1) + items.removeClass('active') + items.eq(n).addClass('active')[0].scrollIntoView({ block: 'nearest' }) + } else if (e.key === 'Enter') { + e.preventDefault() + items.eq(cur < 0 ? 0 : cur).trigger('click') + } +}) + // The dropdown is position:fixed so the scrolling table cannot clip it, // which means its coordinates have to be set against the input each time. function positionSearchResults(inp, div) { @@ -962,12 +1168,32 @@ function acceptRow(row, skipSave) { } function acceptAllRows() { + var undo = [] $('#lineItemsBody tr').each(function () { - if ($(this).attr('data-status') === 'unmatched') return - acceptRow($(this), true) + var row = $(this) + var status = row.attr('data-status') + if (status === 'unmatched' || status === 'confirmed') return + var badge = row.find('.match-badge').first() + undo.push({ + row: row, status: status, text: badge.text(), cls: badge.attr('class'), + // Detached, not removed, so Undo can put them back. + confirm: row.find('.btn-confirm').detach(), + cands: row.find('.cand-list').detach() + }) + acceptRow(row, true) }) refreshReviewSummary() queuePendingStateSave('editing') + if (!undo.length) return + showUndo('Accepted ' + undo.length + ' row' + (undo.length === 1 ? '' : 's') + '.', function () { + undo.forEach(function (u) { + u.row.attr('data-status', u.status) + u.row.find('.match-badge').first().text(u.text).attr('class', u.cls) + u.row.find('.match-actions').before(u.confirm).before(u.cands) + }) + refreshReviewSummary() + queuePendingStateSave('editing') + }) } $(document).on('click', '.btn-confirm', function () { @@ -976,12 +1202,40 @@ $(document).on('click', '.btn-confirm', function () { }) $(document).on('click', '.btn-remove-row', function () { - $('tr[data-index="' + $(this).data('index') + '"]').remove() + var row = $('tr[data-index="' + $(this).data('index') + '"]') + var pos = row.index() + row.detach() + afterRowsChanged() + showUndo('Row ' + (pos + 1) + ' removed.', function () { + var body = $('#lineItemsBody') + var at = body.children().eq(pos) + if (at.length) at.before(row) + else body.append(row) + afterRowsChanged() + }) +}) + +function afterRowsChanged() { if ($('#poSelect').val() && poCompareData) applyPoComparison() renderLineMarkers() refreshReviewSummary() queuePendingStateSave('editing') -}) +} + +var toastTimer = null +function showUndo(msg, undo) { + var t = $('#toast') + if (!t.length) t = $('
    ').appendTo('body') + t.find('.toast-msg').text(msg) + t.find('button').off('click').on('click', function () { + clearTimeout(toastTimer) + t.hide() + undo() + }) + t.css('display', 'flex') + clearTimeout(toastTimer) + toastTimer = setTimeout(function () { t.hide() }, 8000) +} $(document).on('click', '.btn-desc-src', function () { var btn = $(this) @@ -1143,12 +1397,13 @@ function openNewProductModal(rowIndex) { $('#np_size').val('') $('#np_code').val('') $('#newProductStatus').text('').hide() + $('#btnCreateProduct').prop('disabled', false) // Seed from the row that asked for it - the invoice already read most of this. if (_newProductTargetRow !== null) { var row = $('tr[data-index="' + _newProductTargetRow + '"]') if (row.length) { - $('#np_upc').val(row.attr('data-parsed-upc') || row.find('.item-upc').val() || '') + $('#np_upc').val(padUpc13(row.attr('data-parsed-upc') || row.find('.item-upc').val())) $('#np_desc').val((row.find('.item-desc').val() || '').slice(0, 30)) $('#np_code').val(row.find('.item-cert').val() || '') $('#np_size').val((row.find('.item-size').val() || '').slice(0, 9)) @@ -1229,7 +1484,12 @@ async function handleCreateProduct() { fd.append('foodstamp', $('#np_fs').val()) fd.append('pack', $('#np_pack').val()) fd.append('size', $('#np_size').val()) + fd.append('vendor_id', $('#vendorSelect').val() || '') + fd.append('invoice_number', $('#invoiceNumber').val() || '') + var btn = $('#btnCreateProduct') + if (btn.prop('disabled')) return + btn.prop('disabled', true) $('#newProductStatus').text('Creating...').css('color', '#ffee88').show() try { var resp = await fetch('/api/newproduct', { method: 'POST', body: fd }) @@ -1246,14 +1506,15 @@ async function handleCreateProduct() { pack: $('#np_pack').val() !== '' ? parseFloat($('#np_pack').val()) : null, retail: result.retail != null ? result.retail : null }) - setTimeout(closeNewProductModal, 800) } - } else { - $('#newProductStatus').text('Error: ' + result.error).css('color', '#ff8888').show() + setTimeout(closeNewProductModal, 800) // stays disabled until the modal reopens + return } + $('#newProductStatus').text('Error: ' + result.error).css('color', '#ff8888').show() } catch (e) { $('#newProductStatus').text('Error: ' + e.message).css('color', '#ff8888').show() } + btn.prop('disabled', false) } // ------------------------------------------------------- @@ -1266,6 +1527,7 @@ function getQueryParam(name) { function queuePendingStateSave(stage) { if (!pendingId) return + saveDirty = true clearTimeout(pendingSaveTimer) pendingSaveTimer = setTimeout(function () { savePendingState(stage || 'editing').catch(function (e) { @@ -1276,14 +1538,67 @@ function queuePendingStateSave(stage) { async function savePendingState(stage) { if (!pendingId) return - var state = collectPendingState(stage || 'editing') - var resp = await fetch('/api/pending/' + encodeURIComponent(pendingId) + '/state', { - method: 'POST', - body: JSON.stringify(state), - headers: { 'Content-Type': 'application/json; charset=utf-8' } - }) - var result = await resp.json() - if (!result.success) throw new Error(result.error || 'state save failed') + saveDirty = false + setAutosave('Saving draft...') + try { + var state = collectPendingState(stage || 'editing') + var resp = await fetch('/api/pending/' + encodeURIComponent(pendingId) + '/state', { + method: 'POST', + body: JSON.stringify(state), + headers: { 'Content-Type': 'application/json; charset=utf-8' } + }) + var result = await resp.json() + if (!result.success) throw new Error(result.error || 'state save failed') + setAutosave('Draft saved ' + new Date().toLocaleTimeString()) + } catch (e) { + saveDirty = true + setAutosave('Draft NOT saved: ' + e.message, true) + throw e + } +} + +function setAutosave(text, isError) { + $('#autosaveStatus').text(text).toggleClass('autosave-error', !!isError) +} + +// Navigates away once any pending autosave has landed. +async function leaveTo(href) { + if (saveDirty && pendingId) { + clearTimeout(pendingSaveTimer) + try { + await savePendingState('editing') + } catch (e) { + if (!confirm('Your latest edits could not be saved (' + e.message + ').\n\nLeave anyway?')) return + saveDirty = false + } + } + window.location.href = href +} + +// Drafts that can be opened for review, oldest first (ids start with the +// creation timestamp). +async function fetchReviewableIds() { + var list = await (await fetch('/api/pending')).json() + return list + .filter(function (r) { return r.stage === 'parsed' || r.stage === 'review' || r.stage === 'editing' }) + .map(function (r) { return r.id }) + .sort() +} + +async function loadDraftNav() { + if (!currentDraftId) return + try { + var ids = await fetchReviewableIds() + if (ids.indexOf(currentDraftId) < 0) { ids.push(currentDraftId); ids.sort() } + var pos = ids.indexOf(currentDraftId) + draftNav.prev = pos > 0 ? ids[pos - 1] : null + draftNav.next = pos < ids.length - 1 ? ids[pos + 1] : null + $('#btnPrevDraft').prop('disabled', !draftNav.prev) + $('#btnNextDraft').prop('disabled', !draftNav.next) + $('#draftPos').text(ids.length > 1 ? (pos + 1) + ' of ' + ids.length : '') + } catch (e) { + console.warn('Draft navigation failed:', e) + } } function collectPendingState(stage) { @@ -1490,7 +1805,11 @@ function restorePendingState(state) { totalPage = h.total_page || '' if (h.vendor_badge_text) { - $('#vendorBadge').text(h.vendor_badge_text).attr('class', h.vendor_badge_class || 'match-badge badge-none').show() + var vCls = h.vendor_badge_class || 'match-badge badge-none' + // Drafts saved before the specific labels carry the old generic text. + var vText = h.vendor_badge_text !== 'VALIDATE DATA' ? h.vendor_badge_text + : vCls.indexOf('badge-fuzzy') >= 0 ? 'check vendor' : 'no vendor match' + $('#vendorBadge').text(vText).attr('class', vCls).show() } if (h.vendor_hint) $('#hintVendor').text(h.vendor_hint).show() if (h.invoice_number) $('#hintInvoiceNumber').text(h.invoice_number).show() @@ -1511,6 +1830,7 @@ function restorePendingState(state) { renderLineMarkers() } refreshReviewSummary() + checkInvoiceNumber() } function ensureOption(sel, value, label) { @@ -1560,7 +1880,7 @@ function restorePendingRows(items) { row.attr('data-matched-cert', saved.matched_cert || saved.cert || '') row.attr('data-matched-size', saved.matched_size || saved.size || '') row.attr('data-matched-uom', saved.matched_uom || saved.uom || '') - if (saved.match_badge_text) row.find('.match-badge').first().text(saved.match_badge_text) + if (saved.match_badge_text && saved.match_badge_text !== 'VALIDATE DATA') row.find('.match-badge').first().text(saved.match_badge_text) if (saved.match_badge_class) row.find('.match-badge').first().attr('class', saved.match_badge_class) if (status !== 'unmatched' && status !== 'fuzzy') row.find('.btn-confirm').remove() }) @@ -1618,18 +1938,40 @@ async function handleRestoreDefaults() { // Save // ------------------------------------------------------- async function handleSave() { + var saveBtn = $('#btnSave') + if (saveBtn.prop('disabled')) return + $('.field-invalid').removeClass('field-invalid') + $('#lineItemsBody tr').removeClass('row-invalid') + + // Every problem is marked in place; the first one gets focus. + var problems = [], first = null + function flag(el, msg) { + el.addClass('field-invalid') + problems.push(msg) + if (!first) first = el + } var vendorId = $('#vendorSelect').val() - if (!vendorId) { alert('Please select a vendor before saving.'); return } - if (!$('#invoiceNumber').val().trim()) { alert('Please enter an invoice number before saving.'); return } - if (!$('#invoiceDate').val()) { alert('Please enter an invoice date before saving.'); return } + var invNum = $('#invoiceNumber').val().trim() + if (!vendorId) flag($('#vendorSelect'), 'select a vendor') + if (!invNum) flag($('#invoiceNumber'), 'enter the invoice number') + else if (invNum.length > 100) flag($('#invoiceNumber'), 'shorten the invoice number to 100 characters') + if (!$('#invoiceDate').val()) flag($('#invoiceDate'), 'enter the invoice date') var unconfirmed = [] $('#lineItemsBody tr').each(function (idx) { var s = $(this).attr('data-status') - if (s === 'unmatched' || s === 'fuzzy') unconfirmed.push(idx + 1) + if (s !== 'unmatched' && s !== 'fuzzy') return + $(this).addClass('row-invalid') + unconfirmed.push(idx + 1) }) if (unconfirmed.length) { - alert('Row(s) ' + unconfirmed.join(', ') + ' must be matched to an existing product before saving.') + problems.push('match or accept row' + (unconfirmed.length === 1 ? ' ' : 's ') + unconfirmed.join(', ')) + if (!first) first = $('#lineItemsBody tr.row-invalid').first() + } + if (problems.length) { + setStatus('Cannot save yet: ' + problems.join('; ') + '.', 'error') + if (first.is('tr')) flashRow(first) + else first.focus() return } @@ -1658,6 +2000,7 @@ async function handleSave() { fd.append('item_' + i + '_pack', row.find('.item-pack').val()) }) + saveBtn.prop('disabled', true).text('Saving...') setStatus('Saving...', 'loading') try { // The server archives the source file inside the save transaction. @@ -1670,25 +2013,44 @@ async function handleSave() { + (result.batch_items === 1 ? '' : 's') if (result.batch_flagged) msg += ', ' + result.batch_flagged + ' below target margin' } - setStatus(msg, 'success') + var doneId = pendingId if (pendingId) { // Stop the debounced autosave first: firing after the delete // would recreate the draft. clearTimeout(pendingSaveTimer) - var doneId = pendingId + saveDirty = false pendingId = null try { await fetch('/api/pending/' + encodeURIComponent(doneId), { method: 'DELETE' }) } catch (e) {} } - // Long enough to read the batch number before the page moves on. - setTimeout(function () { window.location.href = 'pending.html' }, result.batch_no ? 3000 : 900) + saveBtn.text('Saved') + $('#reviewSection').addClass('saved') + setAutosave('') + await showSaveDone(msg, doneId) } else { + saveBtn.prop('disabled', false).text('Save Invoice') setStatus('Save failed: ' + result.error, 'error') } } catch (e) { + saveBtn.prop('disabled', false).text('Save Invoice') setStatus('Save failed: ' + e.message, 'error') } } +// The result stays on screen until the user moves on, with a direct jump to +// the next draft waiting for review. +async function showSaveDone(msg, doneId) { + var nextId = null + try { + var ids = await fetchReviewableIds() + nextId = ids.filter(function (id) { return id > (doneId || '') })[0] || ids[0] || null + } catch (e) {} + setStatus(msg, 'success') + var actions = $('') + if (nextId) actions.append($('').attr('href', 'index.html?pending=' + encodeURIComponent(nextId)).text('Next invoice ›')) + actions.append($('Pending list')) + $('#statusBar').append(actions) +} + // ------------------------------------------------------- // Utilities // ------------------------------------------------------- diff --git a/wwwroot/pending.html b/wwwroot/pending.html index e8b1e15..3932207 100644 --- a/wwwroot/pending.html +++ b/wwwroot/pending.html @@ -10,16 +10,13 @@

    Pending Invoices - + New Invoice - Pending + Home / Upload + Pending Archive + Changes

    -
    - - Parses every invoice still marked queued (and retries errored ones) in the background. -
    No pending invoices.
    @@ -135,24 +132,6 @@ function buildRow(rec, state) { '' } -// Queue everything still queued (and errored) - the background worker does the rest. -$('#btnProcessQueue').on('click', async function (e) { - e.preventDefault() - var btn = $(this) - btn.prop('disabled', true) - try { - var resp = await fetch('/api/parse-queued', { method: 'POST' }) - var result = await resp.json() - setStatus(result.queued - ? 'Queued ' + result.queued + ' invoice(s) - parsing runs in the background and this list updates automatically.' - : 'No queued invoices to parse.', 'success') - } catch (err) { - setStatus('Parse failed: ' + err.message, 'error') - } - btn.prop('disabled', false) - loadPending() -}) - $(document).on('click', '.btn-parsenow', async function () { var btn = $(this) var id = btn.data('id')