initial commit

This commit is contained in:
Adal 2026-09-30 11:50:04 -07:00
parent b09120189e
commit c1183dfb8a
21 changed files with 1460 additions and 198 deletions

7
.claude Normal file
View file

@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(sqlcmd:*)"
]
}
}

View file

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

View file

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

View file

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

View file

@ -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 <app_login> and run.
-- GRANT SELECT, INSERT ON dbo.InvoiceProductChanges TO [<app_login>];

View file

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

View file

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

View file

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

17
sql/fix_po_0000000012.sql Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -33,7 +33,7 @@ public class SaveService
string poNumber, string vendorTotal, List<SaveLineItem> 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<string, double>(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);

View file

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

View file

@ -10,9 +10,10 @@
<h1>
Invoice Archive
<a href="index.html">+ New Invoice</a>
<a href="index.html">Home / Upload</a>
<a href="pending.html">Pending</a>
<a href="archive.html">Archive</a>
<a href="archive.html" class="active">Archive</a>
<a href="changes.html">Changes</a>
</h1>
<div id="filterPanel">
@ -41,11 +42,11 @@
</div>
<div class="filter-field">
<label>Min $</label>
<input name="amt_min" id="fAmtMin" style="width:80px">
<input name="amt_min" id="fAmtMin" type="number" step="0.01" min="0" style="width:80px">
</div>
<div class="filter-field">
<label>Max $</label>
<input name="amt_max" id="fAmtMax" style="width:80px">
<input name="amt_max" id="fAmtMax" type="number" step="0.01" min="0" style="width:80px">
</div>
<div class="filter-field">
<label>UPC / Cert / Product</label>
@ -54,18 +55,19 @@
<div class="filter-actions">
<button class="filter-btn primary" id="btnFilter" type="submit">Filter</button>
<button class="filter-btn" id="btnReset" type="button">Reset</button>
<span id="resultCount" class="result-count"></span>
</div>
</form>
</div>
<div id="listPanel">
<div id="listPanel" title="Drag the bottom edge to resize">
<table>
<thead>
<tr>
<th>Date</th>
<th>Vendor</th>
<th>Invoice #</th>
<th style="text-align:right">Total</th>
<th data-sort="invoice_date" class="sortable">Date</th>
<th data-sort="vendor_name" class="sortable">Vendor</th>
<th data-sort="invoice_number" class="sortable">Invoice #</th>
<th data-sort="vendor_total" class="sortable" style="text-align:right">Total</th>
</tr>
</thead>
<tbody id="archiveBody"></tbody>
@ -74,6 +76,14 @@
<div id="detailLayout">
<div id="archiveLeft">
<div id="archiveToolbar" style="display:none">
<button id="btnZoomOut" type="button" title="Zoom out">&minus;</button>
<span id="zoomLevel">100%</span>
<button id="btnZoomIn" type="button" title="Zoom in">+</button>
<button id="btnZoomFit" type="button" title="Fit the page to the panel width">Fit</button>
<span class="field-note">Ctrl + scroll to zoom</span>
<a id="btnOpenOriginal" class="btn" target="_blank" style="display:none" title="Open the archived file in its own window">Open original</a>
</div>
<div id="pdfPlaceholder">Select an invoice above to preview.</div>
</div>
<div id="archiveRight">
@ -138,16 +148,83 @@
</div>
<script>
var archiveList = []
var sortKey = 'invoice_date', sortDir = -1 // server order: newest first
var ARCHIVE_LIMIT = 500 // TOP 500 in DataService.ArchiveList
$(document).ready(function () {
loadVendors()
loadList()
initListResize()
initZoom()
$('#btnFilter').on('click', loadList)
$('#btnReset').on('click', function () {
$('#filterForm')[0].reset()
loadList()
})
$('th.sortable').on('click', function () {
var key = $(this).data('sort')
sortDir = key === sortKey ? -sortDir : (key === 'invoice_date' || key === 'vendor_total' ? -1 : 1)
sortKey = key
renderList()
})
// Up/down walks the list while focus is not in a filter field.
$(document).on('keydown', function (e) {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return
if ($(e.target).closest('input,select,textarea').length) return
var rows = $('.inv-row')
if (!rows.length) return
e.preventDefault()
var cur = rows.index(rows.filter('.selected'))
var n = cur < 0 ? 0 : Math.max(0, Math.min(rows.length - 1, cur + (e.key === 'ArrowDown' ? 1 : -1)))
if (n === cur) return
rows.eq(n).trigger('click')[0].scrollIntoView({ block: 'nearest' })
})
})
// The list panel is resizable from its bottom edge; the height is remembered.
function initListResize() {
var panel = document.getElementById('listPanel')
try {
var h = parseInt(localStorage.getItem('ip_archive_list_h'), 10)
if (h > 0) panel.style.height = h + 'px'
} catch (e) {}
if (!window.ResizeObserver) return
new ResizeObserver(function () {
try { localStorage.setItem('ip_archive_list_h', panel.offsetHeight) } catch (e) {}
}).observe(panel)
}
// Preview zoom: 1 = fit the page to the panel width.
var zoom = 1
function initZoom() {
try {
var z = parseFloat(localStorage.getItem('ip_archive_zoom'))
if (z > 0) zoom = Math.max(0.25, Math.min(5, z))
} catch (e) {}
$('#btnZoomIn').on('click', function () { setZoom(zoom * 1.25) })
$('#btnZoomOut').on('click', function () { setZoom(zoom / 1.25) })
$('#btnZoomFit').on('click', function () { setZoom(1) })
$('#archiveLeft').on('wheel', function (e) {
if (!e.originalEvent.ctrlKey) return
e.preventDefault()
setZoom(zoom * (e.originalEvent.deltaY < 0 ? 1.1 : 1 / 1.1))
})
$(window).on('resize', applyZoom)
}
function setZoom(z) {
zoom = Math.max(0.25, Math.min(5, z))
try { localStorage.setItem('ip_archive_zoom', zoom) } catch (e) {}
applyZoom()
}
function applyZoom() {
$('#zoomLevel').text(Math.round(zoom * 100) + '%')
var avail = document.getElementById('archiveLeft').clientWidth - 20 // 10px padding pair
if (avail > 0) $('#archiveLeft img').css('width', Math.round(avail * zoom) + 'px')
}
async function loadVendors() {
try {
var vendors = await (await fetch('/api/vendors')).json()
@ -170,27 +247,51 @@ async function loadList() {
amt_max: $('#fAmtMax').val() || '',
product: $('#fProduct').val() || ''
})
$('#resultCount').text('Loading...')
try {
var list = await (await fetch('/api/archive?' + params)).json()
if (list.error) throw new Error(list.error)
var body = $('#archiveBody')
body.empty()
list.forEach(function (r) {
body.append('<tr class="inv-row" data-inv="' + esc(r.invoice_number) + '" data-vendor="' + r.vendor + '">' +
'<td>' + esc(r.invoice_date) + '</td>' +
'<td>' + esc(r.vendor_name) + '</td>' +
'<td>' + esc(r.invoice_number) + '</td>' +
'<td style="text-align:right">$' + (parseFloat(r.vendor_total) || 0).toFixed(2) + '</td>' +
'</tr>')
})
if (!list.length) {
body.append('<tr><td colspan="4" style="color:#777;text-align:center;padding:12px">No invoices found.</td></tr>')
}
archiveList = list
renderList()
} catch (e) {
$('#resultCount').text('')
showDbError('Could not load archive list: ' + e.message)
}
}
function renderList() {
var selected = $('.inv-row.selected')
var selKey = selected.length ? selected.data('inv') + '|' + selected.data('vendor') : null
var list = archiveList.slice().sort(function (a, b) {
var x = a[sortKey], y = b[sortKey]
if (sortKey === 'vendor_total') { x = parseFloat(x) || 0; y = parseFloat(y) || 0 }
else { x = String(x || '').toLowerCase(); y = String(y || '').toLowerCase() }
return x < y ? -sortDir : x > y ? sortDir : 0
})
$('th.sortable').removeClass('sort-asc sort-desc')
.filter('[data-sort="' + sortKey + '"]').addClass(sortDir > 0 ? 'sort-asc' : 'sort-desc')
var body = $('#archiveBody')
body.empty()
list.forEach(function (r) {
var key = r.invoice_number + '|' + r.vendor
body.append('<tr class="inv-row' + (key === selKey ? ' selected' : '') + '" data-inv="' + esc(r.invoice_number) + '" data-vendor="' + esc(r.vendor) + '">' +
'<td>' + esc(r.invoice_date) + '</td>' +
'<td>' + esc(r.vendor_name) + '</td>' +
'<td>' + esc(r.invoice_number) + '</td>' +
'<td style="text-align:right">$' + (parseFloat(r.vendor_total) || 0).toFixed(2) + '</td>' +
'</tr>')
})
if (!list.length) {
body.append('<tr><td colspan="4" style="color:#777;text-align:center;padding:12px">No invoices found.</td></tr>')
}
var total = list.reduce(function (s, r) { return s + (parseFloat(r.vendor_total) || 0) }, 0)
$('#resultCount').text(list.length >= ARCHIVE_LIMIT
? 'Showing the newest ' + ARCHIVE_LIMIT + ' - narrow the filters to see older invoices'
: list.length + ' invoice' + (list.length === 1 ? '' : 's') + (list.length ? ' - $' + total.toFixed(2) : ''))
}
function showDbError(msg) {
var el = $('#dbWarning')
if (!el.length) el = $('<div id="dbWarning" class="db-warning"></div>').insertAfter('h1')
@ -203,39 +304,49 @@ $(document).on('click', '.inv-row', function () {
loadDetail($(this).data('inv'), $(this).data('vendor'))
})
// Arrow-key browsing fires loads back to back; only the latest may render.
var detailSeq = 0
async function loadDetail(inv, vendor) {
var seq = ++detailSeq
$('#noSelection').hide()
$('#detailContent').show()
$('#itemsBody').empty()
$('#btnOpenOriginal').hide()
setPdfStatus('Loading...', true)
var q = 'inv=' + encodeURIComponent(inv) + '&v=' + encodeURIComponent(vendor)
try {
var detail = await (await fetch('/api/archive/detail?' + q)).json()
if (seq !== detailSeq) return
if (detail.error) { setPdfStatus(detail.error, false); return }
renderHeader(detail)
renderItems(detail.items || [])
renderBatch(detail.batch)
} catch (e) {
setPdfStatus('Failed to load invoice: ' + e.message, false)
if (seq === detailSeq) setPdfStatus('Failed to load invoice: ' + e.message, false)
return
}
// Preview pages are rendered server-side; images are just <img> tags.
try {
var info = await (await fetch('/api/archive/preview?' + q)).json()
if (seq !== detailSeq) return
if (info.error) { setPdfStatus(info.error, false); return }
var left = $('#archiveLeft')
left.find('img').remove()
$('#pdfPlaceholder').hide()
$('#archiveToolbar').show()
$('#btnOpenOriginal').attr('href', '/api/archive/file?' + q).show()
// version changes only when the archived file does, so revisiting an
// invoice reuses the browser-cached pages instead of refetching them.
for (var p = 1; p <= (info.pages || 1); p++) {
left.append($('<img>').attr('src', '/api/archive/preview/' + p + '?' + q + '&ver=' + (info.version || 0)))
}
applyZoom()
} catch (e) {
setPdfStatus('Could not load file.', false)
if (seq === detailSeq) setPdfStatus('Could not load file.', false)
}
}
@ -315,6 +426,7 @@ function money(v) {
function setPdfStatus(msg, loading) {
$('#archiveLeft').find('img').remove()
$('#archiveToolbar').hide()
$('#pdfPlaceholder').text(msg).css('color', loading ? '#ffee88' : '#888').show()
}

158
wwwroot/changes.html Normal file
View file

@ -0,0 +1,158 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Product Changes</title>
<link rel="stylesheet" href="css/style.css">
<script src="js/jquery-3.5.1.min.js"></script>
<style>
#changesPanel { flex: 1; overflow: auto; }
#changesPanel th { position: sticky; top: 0; z-index: 1; }
#changesPanel td.num, #changesPanel th.num { text-align: right; }
.chg-new { color: #88ff88; font-weight: bold; }
.chg-cost { color: #ffcc44; font-weight: bold; }
</style>
</head>
<body class="archive-page">
<h1>
Product Changes
<a href="index.html">Home / Upload</a>
<a href="pending.html">Pending</a>
<a href="archive.html">Archive</a>
<a href="changes.html" class="active">Changes</a>
</h1>
<div id="filterPanel">
<form id="filterForm" onsubmit="return false">
<div class="filter-field">
<label>From</label>
<input type="date" id="fDateFrom">
</div>
<div class="filter-field">
<label>To</label>
<input type="date" id="fDateTo">
</div>
<div class="filter-field">
<label>Type</label>
<select id="fType">
<option value="">All</option>
<option value="NEW">New products</option>
<option value="COST">Cost changes</option>
</select>
</div>
<div class="filter-actions">
<button class="filter-btn primary" id="btnFilter" type="submit">Show</button>
<button class="filter-btn" id="btnReset" type="button">Last 7 days</button>
<span id="resultCount" class="result-count"></span>
</div>
</form>
</div>
<div id="changesPanel">
<table class="list">
<thead>
<tr>
<th>Date</th>
<th>Type</th>
<th>UPC</th>
<th>Description</th>
<th>Vendor</th>
<th>Invoice #</th>
<th>PO #</th>
<th>Batch #</th>
<th class="num">Old Cost</th>
<th class="num">New Cost</th>
<th class="num">Retail</th>
<th class="num">Pack</th>
</tr>
</thead>
<tbody id="changesBody"></tbody>
</table>
</div>
<script>
var CHANGES_LIMIT = 2000 // TOP 2000 in DataService.ProductChanges
$(document).ready(function () {
setLastWeek()
loadChanges()
$('#btnFilter').on('click', loadChanges)
$('#fType').on('change', loadChanges)
$('#btnReset').on('click', function () { setLastWeek(); loadChanges() })
})
function isoDate(d) {
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
}
function setLastWeek() {
var to = new Date(), from = new Date()
from.setDate(to.getDate() - 6)
$('#fDateFrom').val(isoDate(from))
$('#fDateTo').val(isoDate(to))
}
async function loadChanges() {
var params = new URLSearchParams({
date_from: $('#fDateFrom').val() || '',
date_to: $('#fDateTo').val() || '',
type: $('#fType').val() || ''
})
$('#resultCount').text('Loading...')
try {
var list = await (await fetch('/api/changes?' + params)).json()
if (list.error) throw new Error(list.error)
$('#dbWarning').hide()
render(list)
} catch (e) {
$('#resultCount').text('')
$('#changesBody').empty()
showDbError('Could not load changes: ' + e.message)
}
}
function render(list) {
var body = $('#changesBody').empty()
var nNew = 0, nCost = 0
list.forEach(function (r) {
if (r.type === 'NEW') nNew++; else if (r.type === 'COST') nCost++
body.append('<tr>' +
'<td>' + esc(r.date) + '</td>' +
'<td class="' + (r.type === 'NEW' ? 'chg-new' : 'chg-cost') + '">' + (r.type === 'NEW' ? 'New' : r.type === 'COST' ? 'Cost' : esc(r.type)) + '</td>' +
'<td>' + esc(r.upc) + '</td>' +
'<td>' + esc(r.description) + '</td>' +
'<td>' + esc(r.vendor_name || r.vendor) + '</td>' +
'<td>' + esc(r.invoice_number) + '</td>' +
'<td>' + esc(r.po_number) + '</td>' +
'<td>' + esc(r.batch_no) + '</td>' +
'<td class="num">' + money(r.old_cost) + '</td>' +
'<td class="num">' + money(r.new_cost) + '</td>' +
'<td class="num">' + money(r.retail) + '</td>' +
'<td class="num">' + (r.pack != null ? r.pack : '') + '</td>' +
'</tr>')
})
if (!list.length)
body.append('<tr><td colspan="12" style="color:#777;text-align:center;padding:12px">No changes in this range.</td></tr>')
$('#resultCount').text(list.length >= CHANGES_LIMIT
? 'Showing the newest ' + CHANGES_LIMIT + ' - narrow the dates to see the rest'
: list.length + ' change' + (list.length === 1 ? '' : 's') + ' - ' + nNew + ' new, ' + nCost + ' cost')
}
function money(v) {
return v != null && v !== '' ? '$' + parseFloat(v).toFixed(3).replace(/0$/, '') : ''
}
function showDbError(msg) {
var el = $('#dbWarning')
if (!el.length) el = $('<div id="dbWarning" class="db-warning"></div>').insertAfter('h1')
el.text(msg).show()
}
function esc(s) {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
</script>
</body>
</html>

View file

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

View file

@ -11,9 +11,13 @@
<h1>
Invoice Parser <span id="pageMode" style="color:#aaa;font-size:12px;font-weight:normal"></span>
<a href="index.html">+ New Invoice</a>
<a href="index.html" id="navHome">Home / Upload</a>
<a href="pending.html">Pending</a>
<a href="archive.html">Archive</a>
<a href="changes.html">Changes</a>
<button id="btnPrevDraft" type="button" class="nav-draft" title="Previous draft ready for review" disabled>&lsaquo; Prev</button>
<span id="draftPos" class="draft-pos"></span>
<button id="btnNextDraft" type="button" class="nav-draft" title="Next draft ready for review" disabled>Next &rsaquo;</button>
<button id="btnViewParsed" type="button" title="Compare what the parser read against the products it matched">Parsed vs matched</button>
<button id="btnTogglePreview" type="button" title="Hide the invoice preview to give the form the full window">&laquo; Hide preview</button>
</h1>
@ -60,8 +64,9 @@
<div class="hdr-field">
<label for="invoiceNumber">Invoice #</label>
<div class="hdr-ctl">
<input id="invoiceNumber" name="invoice_number" style="width:130px" maxlength="14">
<input id="invoiceNumber" name="invoice_number" style="width:130px">
</div>
<small class="field-error" id="errInvoiceNumber" style="display:none"></small>
<small class="parsed-hint" id="hintInvoiceNumber" style="display:none"></small>
</div>
<div class="hdr-field">
@ -90,8 +95,9 @@
</div>
<div class="form-section items-section">
<h2>Line Items <span id="lineSummary" class="po-summary"></span><span id="poCompareSummary" class="po-summary"></span>
<h2>Line Items <span id="lineSummary" class="po-summary"></span><span id="totalCheck" class="po-summary"></span><span id="poCompareSummary" class="po-summary"></span>
<span class="h2-actions">
<label class="diff-only" title="Hide rows that are already reviewed"><input type="checkbox" id="chkAttentionOnly"> attention only</label>
<button id="btnAddLine" type="button" title="Add a line item the parser missed">+ Add Line</button>
<button id="btnAcceptAll" type="button">Accept All</button>
</span>
@ -144,12 +150,12 @@
</div>
<div class="form-section actions-section">
<span class="field-note">Draft auto-saves to Pending</span>
<button id="btnRestoreDefaults" type="button" class="btn-quiet">Restore defaults</button>
<button id="btnDeleteDraft" type="button" class="btn-quiet btn-quiet-danger">Delete draft</button>
<span id="autosaveStatus" class="field-note" title="Edits save to Pending automatically">Draft auto-saves to Pending</span>
<span id="saveBlockers" class="save-blockers" style="display:none"></span>
<span class="actions-right">
<button id="btnRestoreDefaults" type="button">Restore Defaults</button>
<button id="btnDeleteDraft" type="button">Delete Draft</button>
<button id="btnSave">Save Invoice</button>
<button id="btnSave" title="Save Invoice (Ctrl+S)">Save Invoice</button>
</span>
</div>
@ -161,23 +167,25 @@
<!-- ============ Landing dashboard ============ -->
<div id="dashboard">
<h2 id="dashWelcome" class="dash-welcome" style="display:none"></h2>
<div class="dash-hero">
<div id="uploadZone" onclick="document.getElementById('fileInput').click()">
<div id="uploadZone" role="button" tabindex="0" onclick="document.getElementById('fileInput').click()">
<input type="file" id="fileInput" accept=".pdf,.jpg,.jpeg,.png" multiple>
<span class="up-icon">&#128228;</span>
<label>Scan Invoices</label>
<div id="uploadPromptDash" style="color:#aaa;margin-top:10px;font-size:13px">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.</div>
</div>
<div id="dashStatusBar" style="display:none;padding:7px 12px;border-radius:4px;font-size:12px;margin-top:14px"></div>
<ul id="uploadList" class="upload-list" style="display:none"></ul>
</div>
<div class="dash-actions">
<a class="dash-btn primary" href="pending.html">Go to Pending Queue</a>
<button class="dash-btn" id="btnParseQueued" type="button">Parse queued now</button>
<a class="dash-btn" href="archive.html">View Archive</a>
<a class="dash-btn primary" id="btnReviewNext" href="pending.html">Review next invoice</a>
<span id="reviewNextNote" class="dash-note"></span>
</div>
<div class="stats-grid">
<div class="stats-grid stats-primary">
<a class="stat-card sc-ready" href="pending.html" title="Parsed and waiting for your review">
<div class="stat-num" id="statReady">-</div>
<div class="stat-label">Ready to review</div>
@ -187,14 +195,17 @@
<div class="stat-label">Queued</div>
<div class="stat-sub" id="statProcessingSub"></div>
</a>
<a class="stat-card sc-progress" href="pending.html" title="Drafts you have started editing">
<div class="stat-num" id="statProgress">-</div>
<div class="stat-label">In progress</div>
</a>
<a class="stat-card sc-error" href="pending.html" title="Background parsing failed - retry from Pending">
<div class="stat-num" id="statError">-</div>
<div class="stat-label">Errors</div>
</a>
</div>
<div class="stats-grid stats-secondary">
<a class="stat-card sc-progress" href="pending.html" title="Drafts you have started editing">
<div class="stat-num" id="statProgress">-</div>
<div class="stat-label">In progress</div>
</a>
<div class="stat-card" title="Uploaded today">
<div class="stat-num" id="statToday">-</div>
<div class="stat-label">Uploaded today</div>

View file

@ -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><span class="ul-name"></span><span class="stage"></span><a class="ul-open" style="display:none">Review</a></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('<span class="po-note">VALIDATE DATA</span>')
var note = qDiff && cDiff ? 'qty + cost differ from PO' : qDiff ? 'qty differs from PO' : 'cost differs from PO'
row.find('.match-badge').last().after('<span class="po-note">' + note + '</span>')
} 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('<span class="po-note">VALIDATE DATA</span>')
row.find('.match-badge').last().after('<span class="po-note">not on PO</span>')
}
})
@ -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
? ' - <span class="attention-count">' + attention + ' need attention</span>'
? ' - <span class="attention-count" title="Go to the next row that needs attention (F8, Shift+F8 for previous)">' + attention + ' need attention</span>'
: ' - <span class="attention-none">all reviewed</span>'
}
$('#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('<span class="total-ok">lines = total</span>')
else check.html('<span class="total-diff" title="Header total minus the sum of line totals. Can be tax, deposits or freight.">' +
'lines $' + lineSum.toFixed(2) + ' vs total $' + hdrTotal.toFixed(2) +
' (diff $' + (hdrTotal - lineSum).toFixed(2) + ')</span>')
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 ? '<span class="warn-badge">INACTIVE</span>' : ''
// 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 = '<br><button type="button" class="search-toggle" data-item="' + i + '"' +
(isUnmatched ? ' style="display:none"' : '') + '>Change product</button>' +
var searchHtml = '<div class="match-actions">' +
'<button type="button" class="search-toggle" data-item="' + i + '"' +
(isUnmatched ? ' style="display:none"' : '') + ' title="Search for a different product">Change</button>' +
'<button type="button" class="btn-new-item" data-item="' + i + '"' +
' title="Create this product in posbdat..Products and attach it to this row">+ New item</button>' +
'</div>' +
'<div class="search-inline-wrap"' + (isUnmatched ? '' : ' style="display:none"') + '>' +
buildSearchUI(i) + '</div>' +
'<br><button type="button" class="btn-new-item" data-item="' + i + '"' +
' title="Create this product in posbdat..Products and attach it to this row">+ New item</button>'
buildSearchUI(i) + '</div>'
// 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 = $('<div id="toast"><span class="toast-msg"></span><button type="button">Undo</button></div>').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 = $('<span class="status-actions"></span>')
if (nextId) actions.append($('<a>').attr('href', 'index.html?pending=' + encodeURIComponent(nextId)).text('Next invoice ›'))
actions.append($('<a href="pending.html">Pending list</a>'))
$('#statusBar').append(actions)
}
// -------------------------------------------------------
// Utilities
// -------------------------------------------------------

View file

@ -10,16 +10,13 @@
<h1>
Pending Invoices
<a href="index.html">+ New Invoice</a>
<a href="pending.html">Pending</a>
<a href="index.html">Home / Upload</a>
<a href="pending.html" class="active">Pending</a>
<a href="archive.html">Archive</a>
<a href="changes.html">Changes</a>
</h1>
<div id="content">
<div id="queueToolbar">
<button class="btn btn-process" id="btnProcessQueue">Process queued now</button>
<span class="muted" id="queueHint">Parses every invoice still marked <b>queued</b> (and retries errored ones) in the background.</span>
</div>
<div id="statusBar"></div>
<div id="emptyState">No pending invoices.</div>
<table id="pendingTable" class="list" style="display:none">
@ -135,24 +132,6 @@ function buildRow(rec, state) {
'</tr>'
}
// 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')