initial commit
This commit is contained in:
parent
b09120189e
commit
c1183dfb8a
21 changed files with 1460 additions and 198 deletions
7
.claude
Normal file
7
.claude
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(sqlcmd:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
77
App.xaml.cs
77
App.xaml.cs
|
|
@ -2,6 +2,8 @@ using System.Diagnostics;
|
|||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using InvoiceParser.Src;
|
||||
|
||||
namespace InvoiceParser;
|
||||
|
|
@ -39,6 +41,21 @@ public partial class App : Application
|
|||
try
|
||||
{
|
||||
var config = AppConfig.Load(AppContext.BaseDirectory);
|
||||
|
||||
// The login dialog would otherwise become MainWindow and end the app when it closes.
|
||||
ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
||||
var emp = PromptLogin(config);
|
||||
if (emp == null)
|
||||
{
|
||||
Log.Info("login", "login cancelled");
|
||||
Shutdown();
|
||||
return;
|
||||
}
|
||||
config.ReceiverEmpNo = emp.Value.EmpNo;
|
||||
config.ReceiverFirstName = emp.Value.FirstName;
|
||||
Log.Info("login", "logged in as emp_no " + emp.Value.EmpNo);
|
||||
ShutdownMode = ShutdownMode.OnMainWindowClose;
|
||||
|
||||
_host = new WebHost(config);
|
||||
await _host.StartAsync();
|
||||
|
||||
|
|
@ -55,6 +72,66 @@ public partial class App : Application
|
|||
}
|
||||
}
|
||||
|
||||
// Returns the employee whose AdminPassword was entered, or null if cancelled.
|
||||
private static (int EmpNo, string FirstName)? PromptLogin(AppConfig cfg)
|
||||
{
|
||||
(int EmpNo, string FirstName)? empNo = null;
|
||||
var pw = new PasswordBox { FontSize = 16, Margin = new Thickness(0, 6, 0, 0) };
|
||||
var error = new TextBlock { Foreground = Brushes.Firebrick, Margin = new Thickness(0, 8, 0, 0) };
|
||||
var ok = new Button { Content = "Log in", IsDefault = true, Width = 90, Margin = new Thickness(0, 12, 8, 0) };
|
||||
var cancel = new Button { Content = "Cancel", IsCancel = true, Width = 90, Margin = new Thickness(0, 12, 0, 0) };
|
||||
var buttons = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right };
|
||||
buttons.Children.Add(ok);
|
||||
buttons.Children.Add(cancel);
|
||||
var panel = new StackPanel { Margin = new Thickness(20), MinWidth = 300 };
|
||||
panel.Children.Add(new TextBlock { Text = "Admin password" });
|
||||
panel.Children.Add(pw);
|
||||
panel.Children.Add(error);
|
||||
panel.Children.Add(buttons);
|
||||
|
||||
var win = new Window
|
||||
{
|
||||
Title = "Invoice Parser - Log in",
|
||||
Content = panel,
|
||||
SizeToContent = SizeToContent.WidthAndHeight,
|
||||
ResizeMode = ResizeMode.NoResize,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterScreen
|
||||
};
|
||||
win.Loaded += (_, _) => pw.Focus();
|
||||
ok.Click += (_, _) =>
|
||||
{
|
||||
error.Text = "";
|
||||
try
|
||||
{
|
||||
empNo = LookupEmployee(cfg, pw.Password);
|
||||
if (empNo != null) { win.DialogResult = true; return; }
|
||||
error.Text = "Invalid password";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("login", "employee lookup failed", ex);
|
||||
error.Text = "Database error: " + ex.Message;
|
||||
}
|
||||
pw.Clear();
|
||||
pw.Focus();
|
||||
};
|
||||
return win.ShowDialog() == true ? empNo : null;
|
||||
}
|
||||
|
||||
private static (int EmpNo, string FirstName)? LookupEmployee(AppConfig cfg, string password)
|
||||
{
|
||||
if (!int.TryParse(password.Trim(), out var pw)) return null;
|
||||
using var conn = new Db(cfg).Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"SELECT TOP 1 emp_no, FirstName FROM posbdat.dbo.Employees " +
|
||||
"WHERE AdminPassword = @pw AND ISNULL(EmpActive, 0) = 1";
|
||||
cmd.Parameters.AddWithValue("@pw", pw);
|
||||
using var r = cmd.ExecuteReader();
|
||||
if (!r.Read()) return null;
|
||||
return (Convert.ToInt32(r.GetValue(0)), (Db.GetStr(r, "FirstName") ?? "").Trim());
|
||||
}
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("user32.dll")]
|
||||
private static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
31
sql/create_invoiceproductchanges.sql
Normal file
31
sql/create_invoiceproductchanges.sql
Normal 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>];
|
||||
25
sql/draft_907260825592619_1_products.sql
Normal file
25
sql/draft_907260825592619_1_products.sql
Normal 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
|
||||
52
sql/draft_907260825592619_2_invoice.sql
Normal file
52
sql/draft_907260825592619_2_invoice.sql
Normal 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
|
||||
53
sql/draft_907260825592619_3_batch.sql
Normal file
53
sql/draft_907260825592619_3_batch.sql
Normal 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
17
sql/fix_po_0000000012.sql
Normal 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
|
||||
16
sql/widen_invoicenumber.sql
Normal file
16
sql/widen_invoicenumber.sql
Normal 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';
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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">−</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,15 +148,82 @@
|
|||
</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 {
|
||||
|
|
@ -170,13 +247,36 @@ 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)
|
||||
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) {
|
||||
body.append('<tr class="inv-row" data-inv="' + esc(r.invoice_number) + '" data-vendor="' + r.vendor + '">' +
|
||||
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>' +
|
||||
|
|
@ -186,9 +286,10 @@ async function loadList() {
|
|||
if (!list.length) {
|
||||
body.append('<tr><td colspan="4" style="color:#777;text-align:center;padding:12px">No invoices found.</td></tr>')
|
||||
}
|
||||
} catch (e) {
|
||||
showDbError('Could not load archive list: ' + e.message)
|
||||
}
|
||||
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) {
|
||||
|
|
@ -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
158
wwwroot/changes.html
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>‹ 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 ›</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">« 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">📤</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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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 {
|
||||
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,6 +1538,9 @@ function queuePendingStateSave(stage) {
|
|||
|
||||
async function savePendingState(stage) {
|
||||
if (!pendingId) return
|
||||
saveDirty = false
|
||||
setAutosave('Saving draft...')
|
||||
try {
|
||||
var state = collectPendingState(stage || 'editing')
|
||||
var resp = await fetch('/api/pending/' + encodeURIComponent(pendingId) + '/state', {
|
||||
method: 'POST',
|
||||
|
|
@ -1284,6 +1549,56 @@ async function savePendingState(stage) {
|
|||
})
|
||||
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
|
||||
// -------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in a new issue