11 Commits

27 changed files with 4177 additions and 1427 deletions

112
docs/split-up-pos.md Normal file
View File

@@ -0,0 +1,112 @@
Okay, I understand completely now. The core issue is that the previous approaches tried too hard to reconcile every receipt back to a specific PO line within the `purchase_orders` table structure, which doesn't reflect the reality where receipts can be independent events. Your downstream scripts, especially `daily_snapshots` and `product_metrics`, rely on having a complete picture of *all* receivings.
Let's pivot to a model that respects both distinct data streams: **Orders (Intent)** and **Receivings (Actuals)**.
**Proposed Solution: Separate `purchase_orders` and `receivings` Tables**
This is the cleanest way to model the reality you've described.
1. **`purchase_orders` Table:**
* **Purpose:** Tracks the status and details of purchase *orders* placed. Represents the *intent* to receive goods.
* **Key Columns:** `po_id`, `pid`, `ordered` (quantity ordered), `po_cost_price`, `date` (order/created date), `expected_date`, `status` (PO lifecycle: 'ordered', 'canceled', 'done'), `vendor`, `notes`, etc.
* **Crucially:** This table *does not* need a `received` column or a `receiving_history` column derived from complex allocations. It focuses solely on the PO itself.
2. **`receivings` Table (New or Refined):**
* **Purpose:** Tracks every single line item received, regardless of whether it was linked to a PO during the receiving process. Represents the *actual* goods that arrived.
* **Key Columns:**
* `receiving_id` (Identifier for the overall receiving document/batch)
* `pid` (Product ID received)
* `received_qty` (Quantity received for this specific line)
* `cost_each` (Actual cost paid for this item on this receiving)
* `received_date` (Actual date the item was received)
* `received_by` (Employee ID/Name)
* `source_po_id` (The `po_id` entered on the receiving screen, *nullable*. Stores the original link attempt, even if it was wrong or missing)
* `source_receiving_status` (The status from the source `receivings` table: 'partial_received', 'full_received', 'paid', 'canceled')
**How the Import Script Changes:**
1. **Fetch POs:** Fetch data from `po` and `po_products`.
2. **Populate `purchase_orders`:**
* Insert/Update rows into `purchase_orders` based directly on the fetched PO data.
* Set `po_id`, `pid`, `ordered`, `po_cost_price`, `date` (`COALESCE(date_ordered, date_created)`), `expected_date`.
* Set `status` by mapping the source `po.status` code directly ('ordered', 'canceled', 'done', etc.).
* **No complex allocation needed here.**
3. **Fetch Receivings:** Fetch data from `receivings` and `receivings_products`.
4. **Populate `receivings`:**
* For *every* line item fetched from `receivings_products`:
* Perform necessary data validation (dates, numbers).
* Insert a new row into `receivings` with all the relevant details (`receiving_id`, `pid`, `received_qty`, `cost_each`, `received_date`, `received_by`, `source_po_id`, `source_receiving_status`).
* Use `ON CONFLICT (receiving_id, pid)` (or similar unique key based on your source data) `DO UPDATE SET ...` for incremental updates if necessary, or simply delete/re-insert based on `receiving_id` for simplicity if performance allows.
**Impact on Downstream Scripts (and how to adapt):**
* **Initial Query (Active POs):**
* `SELECT ... FROM purchase_orders po WHERE po.status NOT IN ('canceled', 'done', 'paid_equivalent_status?') AND po.date >= ...`
* `active_pos`: `COUNT(DISTINCT po.po_id)` based on the filtered POs.
* `overdue_pos`: Add `AND po.expected_date < CURRENT_DATE`.
* `total_units`: `SUM(po.ordered)`. Represents total units *ordered* on active POs.
* `total_cost`: `SUM(po.ordered * po.po_cost_price)`. Cost of units *ordered*.
* `total_retail`: `SUM(po.ordered * pm.current_price)`. Retail value of units *ordered*.
* **Result:** This query now cleanly reports on the status of *orders* placed, which seems closer to its original intent. The filter `po.receiving_status NOT IN ('partial_received', 'full_received', 'paid')` is replaced by `po.status NOT IN ('canceled', 'done', 'paid_equivalent?')`. The 90% received check is removed as `received` is not reliably tracked *on the PO* anymore.
* **`daily_product_snapshots`:**
* **`SalesData` CTE:** No change needed.
* **`ReceivingData` CTE:** **Must be changed.** Query the **`receivings`** table instead of `purchase_orders`.
```sql
ReceivingData AS (
SELECT
rl.pid,
COUNT(DISTINCT rl.receiving_id) as receiving_doc_count,
SUM(rl.received_qty) AS units_received,
SUM(rl.received_qty * rl.cost_each) AS cost_received
FROM public.receivings rl
WHERE rl.received_date::date = _date
-- Optional: Filter out canceled receivings if needed
-- AND rl.source_receiving_status <> 'canceled'
GROUP BY rl.pid
),
```
* **Result:** This now accurately reflects *all* units received on a given day from the definitive source.
* **`update_product_metrics`:**
* **`CurrentInfo` CTE:** No change needed (pulls from `products`).
* **`OnOrderInfo` CTE:** Needs re-evaluation. How do you want to define "On Order"?
* **Option A (Strict PO View):** `SUM(po.ordered)` from `purchase_orders po WHERE po.status NOT IN ('canceled', 'done', 'paid_equivalent?')`. This is quantity on *open orders*, ignoring fulfillment state. Simple, but might overestimate if items arrived unlinked.
* **Option B (Approximate Fulfillment):** `SUM(po.ordered)` from open POs MINUS `SUM(rl.received_qty)` from `receivings rl` where `rl.source_po_id = po.po_id` (summing only directly linked receivings). Better, but still misses fulfillment via unlinked receivings.
* **Option C (Heuristic):** `SUM(po.ordered)` from open POs MINUS `SUM(rl.received_qty)` from `receivings rl` where `rl.pid = po.pid` and `rl.received_date >= po.date`. This *tries* to account for unlinked receivings but is imprecise.
* **Recommendation:** Start with **Option A** for simplicity, clearly labeling it "Quantity on Open POs". You might need a separate process or metric for a more nuanced view of expected vs. actual pipeline.
```sql
-- Example for Option A
OnOrderInfo AS (
SELECT
pid,
SUM(ordered) AS on_order_qty, -- Total qty on open POs
SUM(ordered * po_cost_price) AS on_order_cost -- Cost of qty on open POs
FROM public.purchase_orders
WHERE status NOT IN ('canceled', 'done', 'paid_equivalent?') -- Define your open statuses
GROUP BY pid
),
```
* **`HistoricalDates` CTE:**
* `date_first_sold`, `max_order_date`: No change (queries `orders`).
* `date_first_received_calc`, `date_last_received_calc`: **Must be changed.** Query `MIN(rl.received_date)` and `MAX(rl.received_date)` from the **`receivings`** table grouped by `pid`.
* **`SnapshotAggregates` CTE:**
* `received_qty_30d`, `received_cost_30d`: These are calculated from `daily_product_snapshots`, which are now correctly sourced from `receivings`, so this part is fine.
* **Forecasting Calculations:** Will use the chosen definition of `on_order_qty`. Be aware of the implications of Option A (potentially inflated if unlinked receivings fulfill orders).
* **Result:** Metrics are calculated based on distinct order data and complete receiving data. The definition of "on order" needs careful consideration.
**Summary of this Approach:**
* **Pros:**
* Accurately models distinct order and receiving events.
* Provides a definitive source (`receivings`) for all received inventory.
* Simplifies the `purchase_orders` table and its import logic.
* Avoids complex/potentially inaccurate allocation logic for unlinked receivings within the main tables.
* Avoids synthetic records.
* Fixes downstream reporting (`daily_snapshots` receiving data).
* **Cons:**
* Requires creating/managing the `receivings` table.
* Requires modifying downstream queries (`ReceivingData`, `OnOrderInfo`, `HistoricalDates`).
* Calculating a precise "net quantity still expected to arrive" (true on-order minus all relevant fulfillment) becomes more complex and may require specific business rules or heuristics outside the basic table structure if Option A for `OnOrderInfo` isn't sufficient.
This two-table approach (`purchase_orders` + `receivings`) seems the most robust and accurate way to handle your requirement for complete receiving records independent of potentially flawed PO linking. It directly addresses the shortcomings of the previous attempts.

View File

@@ -35,7 +35,7 @@ global.pool = pool;
app.use(express.json()); app.use(express.json());
app.use(morgan('combined')); app.use(morgan('combined'));
app.use(cors({ app.use(cors({
origin: ['http://localhost:5173', 'http://localhost:5174', 'https://inventory.kent.pw'], origin: ['http://localhost:5175', 'http://localhost:5174', 'https://inventory.kent.pw'],
credentials: true credentials: true
})); }));

View File

@@ -7,7 +7,7 @@ BEGIN
-- Check which table is being updated and use the appropriate column -- Check which table is being updated and use the appropriate column
IF TG_TABLE_NAME = 'categories' THEN IF TG_TABLE_NAME = 'categories' THEN
NEW.updated_at = CURRENT_TIMESTAMP; NEW.updated_at = CURRENT_TIMESTAMP;
ELSIF TG_TABLE_NAME IN ('products', 'orders', 'purchase_orders') THEN ELSIF TG_TABLE_NAME IN ('products', 'orders', 'purchase_orders', 'receivings') THEN
NEW.updated = CURRENT_TIMESTAMP; NEW.updated = CURRENT_TIMESTAMP;
END IF; END IF;
RETURN NEW; RETURN NEW;
@@ -159,27 +159,24 @@ CREATE INDEX idx_orders_pid_date ON orders(pid, date);
CREATE INDEX idx_orders_updated ON orders(updated); CREATE INDEX idx_orders_updated ON orders(updated);
-- Create purchase_orders table with its indexes -- Create purchase_orders table with its indexes
-- This table now focuses solely on purchase order intent, not receivings
CREATE TABLE purchase_orders ( CREATE TABLE purchase_orders (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
po_id TEXT NOT NULL, po_id TEXT NOT NULL,
vendor TEXT NOT NULL, vendor TEXT NOT NULL,
date DATE NOT NULL, date TIMESTAMP WITH TIME ZONE NOT NULL,
expected_date DATE, expected_date DATE,
pid BIGINT NOT NULL, pid BIGINT NOT NULL,
sku TEXT NOT NULL, sku TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
cost_price NUMERIC(14, 4) NOT NULL,
po_cost_price NUMERIC(14, 4) NOT NULL, po_cost_price NUMERIC(14, 4) NOT NULL,
status TEXT DEFAULT 'created', status TEXT DEFAULT 'created',
receiving_status TEXT DEFAULT 'created',
notes TEXT, notes TEXT,
long_note TEXT, long_note TEXT,
ordered INTEGER NOT NULL, ordered INTEGER NOT NULL,
received INTEGER DEFAULT 0, supplier_id INTEGER,
received_date DATE, date_created TIMESTAMP WITH TIME ZONE,
last_received_date DATE, date_ordered TIMESTAMP WITH TIME ZONE,
received_by TEXT,
receiving_history JSONB,
updated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (pid) REFERENCES products(pid) ON DELETE CASCADE, FOREIGN KEY (pid) REFERENCES products(pid) ON DELETE CASCADE,
UNIQUE (po_id, pid) UNIQUE (po_id, pid)
@@ -192,21 +189,61 @@ CREATE TRIGGER update_purchase_orders_updated
EXECUTE FUNCTION update_updated_column(); EXECUTE FUNCTION update_updated_column();
COMMENT ON COLUMN purchase_orders.name IS 'Product name from products.description'; COMMENT ON COLUMN purchase_orders.name IS 'Product name from products.description';
COMMENT ON COLUMN purchase_orders.po_cost_price IS 'Original cost from PO, before receiving adjustments'; COMMENT ON COLUMN purchase_orders.po_cost_price IS 'Original cost from PO';
COMMENT ON COLUMN purchase_orders.status IS 'canceled, created, electronically_ready_send, ordered, preordered, electronically_sent, receiving_started, done'; COMMENT ON COLUMN purchase_orders.status IS 'canceled, created, electronically_ready_send, ordered, preordered, electronically_sent, receiving_started, done';
COMMENT ON COLUMN purchase_orders.receiving_status IS 'canceled, created, partial_received, full_received, paid';
COMMENT ON COLUMN purchase_orders.receiving_history IS 'Array of receiving records with qty, date, cost, receiving_id, and alt_po flag';
CREATE INDEX idx_po_id ON purchase_orders(po_id); CREATE INDEX idx_po_id ON purchase_orders(po_id);
CREATE INDEX idx_po_sku ON purchase_orders(sku); CREATE INDEX idx_po_sku ON purchase_orders(sku);
CREATE INDEX idx_po_vendor ON purchase_orders(vendor); CREATE INDEX idx_po_vendor ON purchase_orders(vendor);
CREATE INDEX idx_po_status ON purchase_orders(status); CREATE INDEX idx_po_status ON purchase_orders(status);
CREATE INDEX idx_po_receiving_status ON purchase_orders(receiving_status);
CREATE INDEX idx_po_expected_date ON purchase_orders(expected_date); CREATE INDEX idx_po_expected_date ON purchase_orders(expected_date);
CREATE INDEX idx_po_last_received_date ON purchase_orders(last_received_date);
CREATE INDEX idx_po_pid_status ON purchase_orders(pid, status); CREATE INDEX idx_po_pid_status ON purchase_orders(pid, status);
CREATE INDEX idx_po_pid_date ON purchase_orders(pid, date); CREATE INDEX idx_po_pid_date ON purchase_orders(pid, date);
CREATE INDEX idx_po_updated ON purchase_orders(updated); CREATE INDEX idx_po_updated ON purchase_orders(updated);
CREATE INDEX idx_po_supplier_id ON purchase_orders(supplier_id);
-- Create receivings table to track actual receipt of goods
CREATE TABLE receivings (
id BIGSERIAL PRIMARY KEY,
receiving_id TEXT NOT NULL,
pid BIGINT NOT NULL,
sku TEXT NOT NULL,
name TEXT NOT NULL,
vendor TEXT,
qty_each INTEGER NOT NULL,
qty_each_orig INTEGER,
cost_each NUMERIC(14, 5) NOT NULL,
cost_each_orig NUMERIC(14, 5),
received_by INTEGER,
received_by_name TEXT,
received_date TIMESTAMP WITH TIME ZONE NOT NULL,
receiving_created_date TIMESTAMP WITH TIME ZONE,
supplier_id INTEGER,
status TEXT DEFAULT 'created',
updated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (pid) REFERENCES products(pid) ON DELETE CASCADE,
UNIQUE (receiving_id, pid)
);
-- Create trigger for receivings
CREATE TRIGGER update_receivings_updated
BEFORE UPDATE ON receivings
FOR EACH ROW
EXECUTE FUNCTION update_updated_column();
COMMENT ON COLUMN receivings.status IS 'canceled, created, partial_received, full_received, paid';
COMMENT ON COLUMN receivings.qty_each_orig IS 'Original quantity from the source system';
COMMENT ON COLUMN receivings.cost_each_orig IS 'Original cost from the source system';
COMMENT ON COLUMN receivings.vendor IS 'Vendor name, same as in purchase_orders';
CREATE INDEX idx_receivings_id ON receivings(receiving_id);
CREATE INDEX idx_receivings_pid ON receivings(pid);
CREATE INDEX idx_receivings_sku ON receivings(sku);
CREATE INDEX idx_receivings_status ON receivings(status);
CREATE INDEX idx_receivings_received_date ON receivings(received_date);
CREATE INDEX idx_receivings_supplier_id ON receivings(supplier_id);
CREATE INDEX idx_receivings_vendor ON receivings(vendor);
CREATE INDEX idx_receivings_updated ON receivings(updated);
SET session_replication_role = 'origin'; -- Re-enable foreign key checks SET session_replication_role = 'origin'; -- Re-enable foreign key checks

View File

@@ -35,7 +35,7 @@ function validateDate(mysqlDate) {
/** /**
* Imports purchase orders and receivings from a production MySQL database to a local PostgreSQL database. * Imports purchase orders and receivings from a production MySQL database to a local PostgreSQL database.
* Implements FIFO allocation of receivings to purchase orders. * Handles these as separate data streams without complex FIFO allocation.
* *
* @param {object} prodConnection - A MySQL connection to production DB * @param {object} prodConnection - A MySQL connection to production DB
* @param {object} localConnection - A PostgreSQL connection to local DB * @param {object} localConnection - A PostgreSQL connection to local DB
@@ -44,8 +44,12 @@ function validateDate(mysqlDate) {
*/ */
async function importPurchaseOrders(prodConnection, localConnection, incrementalUpdate = true) { async function importPurchaseOrders(prodConnection, localConnection, incrementalUpdate = true) {
const startTime = Date.now(); const startTime = Date.now();
let recordsAdded = 0; let poRecordsAdded = 0;
let recordsUpdated = 0; let poRecordsUpdated = 0;
let poRecordsDeleted = 0;
let receivingRecordsAdded = 0;
let receivingRecordsUpdated = 0;
let receivingRecordsDeleted = 0;
let totalProcessed = 0; let totalProcessed = 0;
// Batch size constants // Batch size constants
@@ -68,8 +72,8 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
await localConnection.query(` await localConnection.query(`
DROP TABLE IF EXISTS temp_purchase_orders; DROP TABLE IF EXISTS temp_purchase_orders;
DROP TABLE IF EXISTS temp_receivings; DROP TABLE IF EXISTS temp_receivings;
DROP TABLE IF EXISTS temp_receiving_allocations;
DROP TABLE IF EXISTS employee_names; DROP TABLE IF EXISTS employee_names;
DROP TABLE IF EXISTS temp_supplier_names;
-- Temporary table for purchase orders -- Temporary table for purchase orders
CREATE TEMP TABLE temp_purchase_orders ( CREATE TEMP TABLE temp_purchase_orders (
@@ -94,11 +98,16 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
-- Temporary table for receivings -- Temporary table for receivings
CREATE TEMP TABLE temp_receivings ( CREATE TEMP TABLE temp_receivings (
receiving_id TEXT NOT NULL, receiving_id TEXT NOT NULL,
po_id TEXT,
pid BIGINT NOT NULL, pid BIGINT NOT NULL,
sku TEXT,
name TEXT,
vendor TEXT,
qty_each INTEGER, qty_each INTEGER,
cost_each NUMERIC(14, 4), qty_each_orig INTEGER,
cost_each NUMERIC(14, 5),
cost_each_orig NUMERIC(14, 5),
received_by INTEGER, received_by INTEGER,
received_by_name TEXT,
received_date TIMESTAMP WITH TIME ZONE, received_date TIMESTAMP WITH TIME ZONE,
receiving_created_date TIMESTAMP WITH TIME ZONE, receiving_created_date TIMESTAMP WITH TIME ZONE,
supplier_id INTEGER, supplier_id INTEGER,
@@ -106,18 +115,6 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
PRIMARY KEY (receiving_id, pid) PRIMARY KEY (receiving_id, pid)
); );
-- Temporary table for tracking FIFO allocations
CREATE TEMP TABLE temp_receiving_allocations (
po_id TEXT NOT NULL,
pid BIGINT NOT NULL,
receiving_id TEXT NOT NULL,
allocated_qty INTEGER NOT NULL,
cost_each NUMERIC(14, 4) NOT NULL,
received_date TIMESTAMP WITH TIME ZONE NOT NULL,
received_by INTEGER,
PRIMARY KEY (po_id, pid, receiving_id)
);
-- Temporary table for employee names -- Temporary table for employee names
CREATE TEMP TABLE employee_names ( CREATE TEMP TABLE employee_names (
employeeid INTEGER PRIMARY KEY, employeeid INTEGER PRIMARY KEY,
@@ -128,7 +125,6 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
-- Create indexes for efficient joins -- Create indexes for efficient joins
CREATE INDEX idx_temp_po_pid ON temp_purchase_orders(pid); CREATE INDEX idx_temp_po_pid ON temp_purchase_orders(pid);
CREATE INDEX idx_temp_receiving_pid ON temp_receivings(pid); CREATE INDEX idx_temp_receiving_pid ON temp_receivings(pid);
CREATE INDEX idx_temp_receiving_po_id ON temp_receivings(po_id);
`); `);
// Map status codes to text values // Map status codes to text values
@@ -191,7 +187,56 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
`, employeeValues); `, employeeValues);
} }
// 1. First, fetch all relevant POs // Add this section before the PO import to create a supplier names mapping
outputProgress({
status: "running",
operation: "Purchase orders import",
message: "Fetching supplier data for vendor mapping"
});
// Fetch supplier data from production and store in a temp table
const [suppliers] = await prodConnection.query(`
SELECT
supplierid,
companyname
FROM suppliers
WHERE companyname IS NOT NULL AND companyname != ''
`);
if (suppliers.length > 0) {
// Create temp table for supplier names
await localConnection.query(`
DROP TABLE IF EXISTS temp_supplier_names;
CREATE TEMP TABLE temp_supplier_names (
supplier_id INTEGER PRIMARY KEY,
company_name TEXT NOT NULL
);
`);
// Insert supplier data in batches
for (let i = 0; i < suppliers.length; i += INSERT_BATCH_SIZE) {
const batch = suppliers.slice(i, i + INSERT_BATCH_SIZE);
const placeholders = batch.map((_, idx) => {
const base = idx * 2;
return `($${base + 1}, $${base + 2})`;
}).join(',');
const values = batch.flatMap(s => [
s.supplierid,
s.companyname || 'Unnamed Supplier'
]);
await localConnection.query(`
INSERT INTO temp_supplier_names (supplier_id, company_name)
VALUES ${placeholders}
ON CONFLICT (supplier_id) DO UPDATE SET
company_name = EXCLUDED.company_name
`, values);
}
}
// 1. Fetch and process purchase orders
outputProgress({ outputProgress({
status: "running", status: "running",
operation: "Purchase orders import", operation: "Purchase orders import",
@@ -214,6 +259,10 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
const totalPOs = poCount[0].total; const totalPOs = poCount[0].total;
console.log(`Found ${totalPOs} relevant purchase orders`); console.log(`Found ${totalPOs} relevant purchase orders`);
// Skip processing if no POs to process
if (totalPOs === 0) {
console.log('No purchase orders to process, skipping PO import step');
} else {
// Fetch and process POs in batches // Fetch and process POs in batches
let offset = 0; let offset = 0;
let allPOsProcessed = false; let allPOsProcessed = false;
@@ -358,6 +407,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
allPOsProcessed = true; allPOsProcessed = true;
} }
} }
}
// 2. Next, fetch all relevant receivings // 2. Next, fetch all relevant receivings
outputProgress({ outputProgress({
@@ -381,6 +431,10 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
const totalReceivings = receivingCount[0].total; const totalReceivings = receivingCount[0].total;
console.log(`Found ${totalReceivings} relevant receivings`); console.log(`Found ${totalReceivings} relevant receivings`);
// Skip processing if no receivings to process
if (totalReceivings === 0) {
console.log('No receivings to process, skipping receivings import step');
} else {
// Fetch and process receivings in batches // Fetch and process receivings in batches
offset = 0; // Reset offset for receivings offset = 0; // Reset offset for receivings
let allReceivingsProcessed = false; let allReceivingsProcessed = false;
@@ -389,10 +443,16 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
const [receivingList] = await prodConnection.query(` const [receivingList] = await prodConnection.query(`
SELECT SELECT
r.receiving_id, r.receiving_id,
r.po_id,
r.supplier_id, r.supplier_id,
r.status, r.status,
r.date_created r.notes,
r.shipping,
r.total_amount,
r.hold,
r.for_storefront,
r.date_created,
r.date_paid,
r.date_checked
FROM receivings r FROM receivings r
WHERE r.date_created >= DATE_SUB(CURRENT_DATE, INTERVAL ${yearInterval} YEAR) WHERE r.date_created >= DATE_SUB(CURRENT_DATE, INTERVAL ${yearInterval} YEAR)
${incrementalUpdate ? ` ${incrementalUpdate ? `
@@ -418,12 +478,17 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
rp.receiving_id, rp.receiving_id,
rp.pid, rp.pid,
rp.qty_each, rp.qty_each,
rp.qty_each_orig,
rp.cost_each, rp.cost_each,
rp.cost_each_orig,
rp.received_by, rp.received_by,
rp.received_date, rp.received_date,
r.date_created as receiving_created_date r.date_created as receiving_created_date,
COALESCE(p.itemnumber, 'NO-SKU') AS sku,
COALESCE(p.description, 'Unknown Product') AS name
FROM receivings_products rp FROM receivings_products rp
JOIN receivings r ON rp.receiving_id = r.receiving_id JOIN receivings r ON rp.receiving_id = r.receiving_id
LEFT JOIN products p ON rp.pid = p.pid
WHERE rp.receiving_id IN (?) WHERE rp.receiving_id IN (?)
`, [receivingIds]); `, [receivingIds]);
@@ -433,13 +498,46 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
const receiving = receivingList.find(r => r.receiving_id == product.receiving_id); const receiving = receivingList.find(r => r.receiving_id == product.receiving_id);
if (!receiving) continue; if (!receiving) continue;
// Get employee name if available
let receivedByName = null;
if (product.received_by) {
const [employeeResult] = await localConnection.query(`
SELECT CONCAT(firstname, ' ', lastname) as full_name
FROM employee_names
WHERE employeeid = $1
`, [product.received_by]);
if (employeeResult.rows.length > 0) {
receivedByName = employeeResult.rows[0].full_name;
}
}
// Get vendor name if available
let vendorName = 'Unknown Vendor';
if (receiving.supplier_id) {
const [vendorResult] = await localConnection.query(`
SELECT company_name
FROM temp_supplier_names
WHERE supplier_id = $1
`, [receiving.supplier_id]);
if (vendorResult.rows.length > 0) {
vendorName = vendorResult.rows[0].company_name;
}
}
completeReceivings.push({ completeReceivings.push({
receiving_id: receiving.receiving_id.toString(), receiving_id: receiving.receiving_id.toString(),
po_id: receiving.po_id ? receiving.po_id.toString() : null,
pid: product.pid, pid: product.pid,
sku: product.sku,
name: product.name,
vendor: vendorName,
qty_each: product.qty_each, qty_each: product.qty_each,
qty_each_orig: product.qty_each_orig,
cost_each: product.cost_each, cost_each: product.cost_each,
cost_each_orig: product.cost_each_orig,
received_by: product.received_by, received_by: product.received_by,
received_by_name: receivedByName,
received_date: validateDate(product.received_date) || validateDate(product.receiving_created_date), received_date: validateDate(product.received_date) || validateDate(product.receiving_created_date),
receiving_created_date: validateDate(product.receiving_created_date), receiving_created_date: validateDate(product.receiving_created_date),
supplier_id: receiving.supplier_id, supplier_id: receiving.supplier_id,
@@ -452,17 +550,22 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
const batch = completeReceivings.slice(i, i + INSERT_BATCH_SIZE); const batch = completeReceivings.slice(i, i + INSERT_BATCH_SIZE);
const placeholders = batch.map((_, idx) => { const placeholders = batch.map((_, idx) => {
const base = idx * 10; const base = idx * 15;
return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6}, $${base + 7}, $${base + 8}, $${base + 9}, $${base + 10})`; return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6}, $${base + 7}, $${base + 8}, $${base + 9}, $${base + 10}, $${base + 11}, $${base + 12}, $${base + 13}, $${base + 14}, $${base + 15})`;
}).join(','); }).join(',');
const values = batch.flatMap(r => [ const values = batch.flatMap(r => [
r.receiving_id, r.receiving_id,
r.po_id,
r.pid, r.pid,
r.sku,
r.name,
r.vendor,
r.qty_each, r.qty_each,
r.qty_each_orig,
r.cost_each, r.cost_each,
r.cost_each_orig,
r.received_by, r.received_by,
r.received_by_name,
r.received_date, r.received_date,
r.receiving_created_date, r.receiving_created_date,
r.supplier_id, r.supplier_id,
@@ -471,15 +574,21 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
await localConnection.query(` await localConnection.query(`
INSERT INTO temp_receivings ( INSERT INTO temp_receivings (
receiving_id, po_id, pid, qty_each, cost_each, received_by, receiving_id, pid, sku, name, vendor, qty_each, qty_each_orig,
cost_each, cost_each_orig, received_by, received_by_name,
received_date, receiving_created_date, supplier_id, status received_date, receiving_created_date, supplier_id, status
) )
VALUES ${placeholders} VALUES ${placeholders}
ON CONFLICT (receiving_id, pid) DO UPDATE SET ON CONFLICT (receiving_id, pid) DO UPDATE SET
po_id = EXCLUDED.po_id, sku = EXCLUDED.sku,
name = EXCLUDED.name,
vendor = EXCLUDED.vendor,
qty_each = EXCLUDED.qty_each, qty_each = EXCLUDED.qty_each,
qty_each_orig = EXCLUDED.qty_each_orig,
cost_each = EXCLUDED.cost_each, cost_each = EXCLUDED.cost_each,
cost_each_orig = EXCLUDED.cost_each_orig,
received_by = EXCLUDED.received_by, received_by = EXCLUDED.received_by,
received_by_name = EXCLUDED.received_by_name,
received_date = EXCLUDED.received_date, received_date = EXCLUDED.received_date,
receiving_created_date = EXCLUDED.receiving_created_date, receiving_created_date = EXCLUDED.receiving_created_date,
supplier_id = EXCLUDED.supplier_id, supplier_id = EXCLUDED.supplier_id,
@@ -505,16 +614,15 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
allReceivingsProcessed = true; allReceivingsProcessed = true;
} }
} }
}
// 3. Implement FIFO allocation of receivings to purchase orders // Add this section to filter out invalid PIDs before final import
outputProgress({ outputProgress({
status: "running", status: "running",
operation: "Purchase orders import", operation: "Purchase orders import",
message: "Validating product IDs before allocation" message: "Validating product IDs before final import"
}); });
// Add this section to filter out invalid PIDs before allocation
// This will check all PIDs in our temp tables against the products table
await localConnection.query(` await localConnection.query(`
-- Create temp table to store invalid PIDs -- Create temp table to store invalid PIDs
DROP TABLE IF EXISTS temp_invalid_pids; DROP TABLE IF EXISTS temp_invalid_pids;
@@ -552,362 +660,157 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
console.log(`Filtered out ${filteredCount} items with invalid product IDs`); console.log(`Filtered out ${filteredCount} items with invalid product IDs`);
} }
// Break FIFO allocation into steps with progress tracking // 3. Insert final purchase order records to the actual table
const fifoSteps = [
{
name: "Direct allocations",
query: `
INSERT INTO temp_receiving_allocations (
po_id, pid, receiving_id, allocated_qty, cost_each, received_date, received_by
)
SELECT
r.po_id,
r.pid,
r.receiving_id,
LEAST(r.qty_each, po.ordered) as allocated_qty,
r.cost_each,
COALESCE(r.received_date, NOW()) as received_date,
r.received_by
FROM temp_receivings r
JOIN temp_purchase_orders po ON r.po_id = po.po_id AND r.pid = po.pid
WHERE r.po_id IS NOT NULL
`
},
{
name: "Handling standalone receivings",
query: `
INSERT INTO temp_purchase_orders (
po_id, pid, sku, name, vendor, date, status,
ordered, po_cost_price, supplier_id, date_created, date_ordered
)
SELECT
r.receiving_id::text as po_id,
r.pid,
COALESCE(p.sku, 'NO-SKU') as sku,
COALESCE(p.name, 'Unknown Product') as name,
COALESCE(
(SELECT vendor FROM temp_purchase_orders
WHERE supplier_id = r.supplier_id LIMIT 1),
'Unknown Vendor'
) as vendor,
COALESCE(r.received_date, r.receiving_created_date) as date,
'created' as status,
NULL as ordered,
r.cost_each as po_cost_price,
r.supplier_id,
COALESCE(r.receiving_created_date, r.received_date) as date_created,
NULL as date_ordered
FROM temp_receivings r
LEFT JOIN (
SELECT DISTINCT pid, sku, name FROM temp_purchase_orders
) p ON r.pid = p.pid
WHERE r.po_id IS NULL
OR NOT EXISTS (
SELECT 1 FROM temp_purchase_orders po
WHERE po.po_id = r.po_id AND po.pid = r.pid
)
ON CONFLICT (po_id, pid) DO NOTHING
`
},
{
name: "Allocating standalone receivings",
query: `
INSERT INTO temp_receiving_allocations (
po_id, pid, receiving_id, allocated_qty, cost_each, received_date, received_by
)
SELECT
r.receiving_id::text as po_id,
r.pid,
r.receiving_id,
r.qty_each as allocated_qty,
r.cost_each,
COALESCE(r.received_date, NOW()) as received_date,
r.received_by
FROM temp_receivings r
WHERE r.po_id IS NULL
OR NOT EXISTS (
SELECT 1 FROM temp_purchase_orders po
WHERE po.po_id = r.po_id AND po.pid = r.pid
)
`
},
{
name: "FIFO allocation logic",
query: `
WITH
-- Calculate remaining quantities after direct allocations
remaining_po_quantities AS (
SELECT
po.po_id,
po.pid,
po.ordered,
COALESCE(SUM(ra.allocated_qty), 0) as already_allocated,
po.ordered - COALESCE(SUM(ra.allocated_qty), 0) as remaining_qty,
po.date_ordered,
po.date_created
FROM temp_purchase_orders po
LEFT JOIN temp_receiving_allocations ra ON po.po_id = ra.po_id AND po.pid = ra.pid
WHERE po.ordered IS NOT NULL
GROUP BY po.po_id, po.pid, po.ordered, po.date_ordered, po.date_created
HAVING po.ordered > COALESCE(SUM(ra.allocated_qty), 0)
),
remaining_receiving_quantities AS (
SELECT
r.receiving_id,
r.pid,
r.qty_each,
COALESCE(SUM(ra.allocated_qty), 0) as already_allocated,
r.qty_each - COALESCE(SUM(ra.allocated_qty), 0) as remaining_qty,
r.received_date,
r.cost_each,
r.received_by
FROM temp_receivings r
LEFT JOIN temp_receiving_allocations ra ON r.receiving_id = ra.receiving_id AND r.pid = ra.pid
GROUP BY r.receiving_id, r.pid, r.qty_each, r.received_date, r.cost_each, r.received_by
HAVING r.qty_each > COALESCE(SUM(ra.allocated_qty), 0)
),
-- Rank POs by age, with a cutoff for very old POs (1 year)
ranked_pos AS (
SELECT
po.po_id,
po.pid,
po.remaining_qty,
CASE
WHEN po.date_ordered IS NULL OR po.date_ordered < NOW() - INTERVAL '1 year' THEN 2
ELSE 1
END as age_group,
ROW_NUMBER() OVER (
PARTITION BY po.pid, (CASE WHEN po.date_ordered IS NULL OR po.date_ordered < NOW() - INTERVAL '1 year' THEN 2 ELSE 1 END)
ORDER BY COALESCE(po.date_ordered, po.date_created, NOW())
) as rank_in_group
FROM remaining_po_quantities po
),
-- Rank receivings by date
ranked_receivings AS (
SELECT
r.receiving_id,
r.pid,
r.remaining_qty,
r.received_date,
r.cost_each,
r.received_by,
ROW_NUMBER() OVER (PARTITION BY r.pid ORDER BY COALESCE(r.received_date, NOW())) as rank
FROM remaining_receiving_quantities r
),
-- First allocate to recent POs
allocations_recent AS (
SELECT
po.po_id,
po.pid,
r.receiving_id,
LEAST(po.remaining_qty, r.remaining_qty) as allocated_qty,
r.cost_each,
COALESCE(r.received_date, NOW()) as received_date,
r.received_by,
po.age_group,
po.rank_in_group,
r.rank,
'recent' as allocation_type
FROM ranked_pos po
JOIN ranked_receivings r ON po.pid = r.pid
WHERE po.age_group = 1
ORDER BY po.pid, po.rank_in_group, r.rank
),
-- Then allocate to older POs
remaining_after_recent AS (
SELECT
r.receiving_id,
r.pid,
r.remaining_qty - COALESCE(SUM(a.allocated_qty), 0) as remaining_qty,
r.received_date,
r.cost_each,
r.received_by,
r.rank
FROM ranked_receivings r
LEFT JOIN allocations_recent a ON r.receiving_id = a.receiving_id AND r.pid = a.pid
GROUP BY r.receiving_id, r.pid, r.remaining_qty, r.received_date, r.cost_each, r.received_by, r.rank
HAVING r.remaining_qty > COALESCE(SUM(a.allocated_qty), 0)
),
allocations_old AS (
SELECT
po.po_id,
po.pid,
r.receiving_id,
LEAST(po.remaining_qty, r.remaining_qty) as allocated_qty,
r.cost_each,
COALESCE(r.received_date, NOW()) as received_date,
r.received_by,
po.age_group,
po.rank_in_group,
r.rank,
'old' as allocation_type
FROM ranked_pos po
JOIN remaining_after_recent r ON po.pid = r.pid
WHERE po.age_group = 2
ORDER BY po.pid, po.rank_in_group, r.rank
),
-- Combine allocations
combined_allocations AS (
SELECT * FROM allocations_recent
UNION ALL
SELECT * FROM allocations_old
)
-- Insert into allocations table
INSERT INTO temp_receiving_allocations (
po_id, pid, receiving_id, allocated_qty, cost_each, received_date, received_by
)
SELECT
po_id, pid, receiving_id, allocated_qty, cost_each,
COALESCE(received_date, NOW()) as received_date,
received_by
FROM combined_allocations
WHERE allocated_qty > 0
`
}
];
// Execute FIFO steps with progress tracking
for (let i = 0; i < fifoSteps.length; i++) {
const step = fifoSteps[i];
outputProgress({ outputProgress({
status: "running", status: "running",
operation: "Purchase orders import", operation: "Purchase orders import",
message: `FIFO allocation step ${i+1}/${fifoSteps.length}: ${step.name}`, message: "Inserting final purchase order records"
current: i,
total: fifoSteps.length
}); });
await localConnection.query(step.query); // Create a temp table to track PO IDs being processed
} await localConnection.query(`
DROP TABLE IF EXISTS processed_po_ids;
CREATE TEMP TABLE processed_po_ids AS (
SELECT DISTINCT po_id FROM temp_purchase_orders
);
`);
// 4. Generate final purchase order records with receiving data // Delete products that were removed from POs and count them
outputProgress({ const [poDeletedResult] = await localConnection.query(`
status: "running", WITH deleted AS (
operation: "Purchase orders import", DELETE FROM purchase_orders
message: "Generating final purchase order records" WHERE po_id IN (SELECT po_id FROM processed_po_ids)
}); AND NOT EXISTS (
SELECT 1 FROM temp_purchase_orders tp
const [finalResult] = await localConnection.query(` WHERE purchase_orders.po_id = tp.po_id AND purchase_orders.pid = tp.pid
WITH
receiving_summaries AS (
SELECT
po_id,
pid,
SUM(allocated_qty) as total_received,
JSONB_AGG(
JSONB_BUILD_OBJECT(
'receiving_id', receiving_id,
'qty', allocated_qty,
'date', COALESCE(received_date, NOW()),
'cost', cost_each,
'received_by', received_by,
'received_by_name', CASE
WHEN received_by IS NOT NULL AND received_by > 0 THEN
(SELECT CONCAT(firstname, ' ', lastname)
FROM employee_names
WHERE employeeid = received_by)
ELSE NULL
END
) ORDER BY COALESCE(received_date, NOW())
) as receiving_history,
MIN(COALESCE(received_date, NOW())) as first_received_date,
MAX(COALESCE(received_date, NOW())) as last_received_date,
STRING_AGG(
DISTINCT CASE WHEN received_by IS NOT NULL AND received_by > 0
THEN CAST(received_by AS TEXT)
ELSE NULL
END,
','
) as received_by_list,
STRING_AGG(
DISTINCT CASE
WHEN ra.received_by IS NOT NULL AND ra.received_by > 0 THEN
(SELECT CONCAT(firstname, ' ', lastname)
FROM employee_names
WHERE employeeid = ra.received_by)
ELSE NULL
END,
', '
) as received_by_names
FROM temp_receiving_allocations ra
GROUP BY po_id, pid
),
cost_averaging AS (
SELECT
ra.po_id,
ra.pid,
SUM(ra.allocated_qty * ra.cost_each) / NULLIF(SUM(ra.allocated_qty), 0) as avg_cost
FROM temp_receiving_allocations ra
GROUP BY ra.po_id, ra.pid
) )
RETURNING po_id, pid
)
SELECT COUNT(*) as count FROM deleted
`);
poRecordsDeleted = poDeletedResult.rows[0]?.count || 0;
console.log(`Deleted ${poRecordsDeleted} products that were removed from purchase orders`);
const [poResult] = await localConnection.query(`
INSERT INTO purchase_orders ( INSERT INTO purchase_orders (
po_id, vendor, date, expected_date, pid, sku, name, po_id, vendor, date, expected_date, pid, sku, name,
cost_price, po_cost_price, status, receiving_status, notes, long_note, po_cost_price, status, notes, long_note,
ordered, received, received_date, last_received_date, received_by, ordered, supplier_id, date_created, date_ordered
receiving_history
) )
SELECT SELECT
po.po_id, po_id,
po.vendor, vendor,
CASE COALESCE(date, date_created, now()) as date,
WHEN po.date IS NOT NULL THEN po.date expected_date,
-- For standalone receivings, try to use the receiving date from history pid,
WHEN po.po_id LIKE 'R%' AND rs.first_received_date IS NOT NULL THEN rs.first_received_date sku,
-- As a last resort for data integrity, use Unix epoch (Jan 1, 1970) name,
ELSE to_timestamp(0) po_cost_price,
END as date, status,
NULLIF(po.expected_date::text, '0000-00-00')::date as expected_date, notes,
po.pid, long_note,
po.sku, ordered,
po.name, supplier_id,
COALESCE(ca.avg_cost, po.po_cost_price) as cost_price, date_created,
po.po_cost_price, date_ordered
COALESCE(po.status, 'created'), FROM temp_purchase_orders
CASE
WHEN rs.total_received IS NULL THEN 'created'
WHEN rs.total_received = 0 THEN 'created'
WHEN rs.total_received < po.ordered THEN 'partial_received'
WHEN rs.total_received >= po.ordered THEN 'full_received'
ELSE 'created'
END as receiving_status,
po.notes,
po.long_note,
COALESCE(po.ordered, 0),
COALESCE(rs.total_received, 0),
NULLIF(rs.first_received_date::text, '0000-00-00 00:00:00')::timestamp with time zone as received_date,
NULLIF(rs.last_received_date::text, '0000-00-00 00:00:00')::timestamp with time zone as last_received_date,
CASE
WHEN rs.received_by_list IS NULL THEN NULL
ELSE rs.received_by_names
END as received_by,
rs.receiving_history
FROM temp_purchase_orders po
LEFT JOIN receiving_summaries rs ON po.po_id = rs.po_id AND po.pid = rs.pid
LEFT JOIN cost_averaging ca ON po.po_id = ca.po_id AND po.pid = ca.pid
ON CONFLICT (po_id, pid) DO UPDATE SET ON CONFLICT (po_id, pid) DO UPDATE SET
vendor = EXCLUDED.vendor, vendor = EXCLUDED.vendor,
date = EXCLUDED.date, date = EXCLUDED.date,
expected_date = EXCLUDED.expected_date, expected_date = EXCLUDED.expected_date,
sku = EXCLUDED.sku, sku = EXCLUDED.sku,
name = EXCLUDED.name, name = EXCLUDED.name,
cost_price = EXCLUDED.cost_price,
po_cost_price = EXCLUDED.po_cost_price, po_cost_price = EXCLUDED.po_cost_price,
status = EXCLUDED.status, status = EXCLUDED.status,
receiving_status = EXCLUDED.receiving_status,
notes = EXCLUDED.notes, notes = EXCLUDED.notes,
long_note = EXCLUDED.long_note, long_note = EXCLUDED.long_note,
ordered = EXCLUDED.ordered, ordered = EXCLUDED.ordered,
received = EXCLUDED.received, supplier_id = EXCLUDED.supplier_id,
received_date = EXCLUDED.received_date, date_created = EXCLUDED.date_created,
last_received_date = EXCLUDED.last_received_date, date_ordered = EXCLUDED.date_ordered,
received_by = EXCLUDED.received_by,
receiving_history = EXCLUDED.receiving_history,
updated = CURRENT_TIMESTAMP updated = CURRENT_TIMESTAMP
RETURNING (xmax = 0) as inserted RETURNING (xmax = 0) as inserted
`); `);
recordsAdded = finalResult.rows.filter(r => r.inserted).length; poRecordsAdded = poResult.rows.filter(r => r.inserted).length;
recordsUpdated = finalResult.rows.filter(r => !r.inserted).length; poRecordsUpdated = poResult.rows.filter(r => !r.inserted).length;
// 4. Insert final receiving records to the actual table
outputProgress({
status: "running",
operation: "Purchase orders import",
message: "Inserting final receiving records"
});
// Create a temp table to track receiving IDs being processed
await localConnection.query(`
DROP TABLE IF EXISTS processed_receiving_ids;
CREATE TEMP TABLE processed_receiving_ids AS (
SELECT DISTINCT receiving_id FROM temp_receivings
);
`);
// Delete products that were removed from receivings and count them
const [receivingDeletedResult] = await localConnection.query(`
WITH deleted AS (
DELETE FROM receivings
WHERE receiving_id IN (SELECT receiving_id FROM processed_receiving_ids)
AND NOT EXISTS (
SELECT 1 FROM temp_receivings tr
WHERE receivings.receiving_id = tr.receiving_id AND receivings.pid = tr.pid
)
RETURNING receiving_id, pid
)
SELECT COUNT(*) as count FROM deleted
`);
receivingRecordsDeleted = receivingDeletedResult.rows[0]?.count || 0;
console.log(`Deleted ${receivingRecordsDeleted} products that were removed from receivings`);
const [receivingsResult] = await localConnection.query(`
INSERT INTO receivings (
receiving_id, pid, sku, name, vendor, qty_each, qty_each_orig,
cost_each, cost_each_orig, received_by, received_by_name,
received_date, receiving_created_date, supplier_id, status
)
SELECT
receiving_id,
pid,
sku,
name,
vendor,
qty_each,
qty_each_orig,
cost_each,
cost_each_orig,
received_by,
received_by_name,
COALESCE(received_date, receiving_created_date, now()) as received_date,
receiving_created_date,
supplier_id,
status
FROM temp_receivings
ON CONFLICT (receiving_id, pid) DO UPDATE SET
sku = EXCLUDED.sku,
name = EXCLUDED.name,
vendor = EXCLUDED.vendor,
qty_each = EXCLUDED.qty_each,
qty_each_orig = EXCLUDED.qty_each_orig,
cost_each = EXCLUDED.cost_each,
cost_each_orig = EXCLUDED.cost_each_orig,
received_by = EXCLUDED.received_by,
received_by_name = EXCLUDED.received_by_name,
received_date = EXCLUDED.received_date,
receiving_created_date = EXCLUDED.receiving_created_date,
supplier_id = EXCLUDED.supplier_id,
status = EXCLUDED.status,
updated = CURRENT_TIMESTAMP
RETURNING (xmax = 0) as inserted
`);
receivingRecordsAdded = receivingsResult.rows.filter(r => r.inserted).length;
receivingRecordsUpdated = receivingsResult.rows.filter(r => !r.inserted).length;
// Update sync status // Update sync status
await localConnection.query(` await localConnection.query(`
@@ -921,8 +824,11 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
await localConnection.query(` await localConnection.query(`
DROP TABLE IF EXISTS temp_purchase_orders; DROP TABLE IF EXISTS temp_purchase_orders;
DROP TABLE IF EXISTS temp_receivings; DROP TABLE IF EXISTS temp_receivings;
DROP TABLE IF EXISTS temp_receiving_allocations;
DROP TABLE IF EXISTS employee_names; DROP TABLE IF EXISTS employee_names;
DROP TABLE IF EXISTS temp_supplier_names;
DROP TABLE IF EXISTS temp_invalid_pids;
DROP TABLE IF EXISTS processed_po_ids;
DROP TABLE IF EXISTS processed_receiving_ids;
`); `);
// Commit transaction // Commit transaction
@@ -930,8 +836,15 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
return { return {
status: "complete", status: "complete",
recordsAdded: recordsAdded || 0, recordsAdded: poRecordsAdded + receivingRecordsAdded,
recordsUpdated: recordsUpdated || 0, recordsUpdated: poRecordsUpdated + receivingRecordsUpdated,
recordsDeleted: poRecordsDeleted + receivingRecordsDeleted,
poRecordsAdded,
poRecordsUpdated,
poRecordsDeleted,
receivingRecordsAdded,
receivingRecordsUpdated,
receivingRecordsDeleted,
totalRecords: totalProcessed totalRecords: totalProcessed
}; };
} catch (error) { } catch (error) {
@@ -949,6 +862,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
error: error.message, error: error.message,
recordsAdded: 0, recordsAdded: 0,
recordsUpdated: 0, recordsUpdated: 0,
recordsDeleted: 0,
totalRecords: 0 totalRecords: 0
}; };
} }

View File

@@ -91,6 +91,287 @@ function cancelCalculation() {
process.on('SIGTERM', cancelCalculation); process.on('SIGTERM', cancelCalculation);
process.on('SIGINT', cancelCalculation); process.on('SIGINT', cancelCalculation);
const calculateInitialMetrics = (client, onProgress) => {
return client.query(`
-- Truncate the existing metrics tables to ensure clean data
TRUNCATE TABLE public.daily_product_snapshots;
TRUNCATE TABLE public.product_metrics;
-- First let's create daily snapshots for all products with order activity
WITH SalesData AS (
SELECT
p.pid,
p.sku,
o.date::date AS order_date,
-- Count orders to ensure we only include products with real activity
COUNT(o.id) as order_count,
-- Aggregate Sales (Quantity > 0, Status not Canceled/Returned)
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.quantity ELSE 0 END), 0) AS units_sold,
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.price * o.quantity ELSE 0 END), 0.00) AS gross_revenue_unadjusted,
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.discount ELSE 0 END), 0.00) AS discounts,
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN COALESCE(o.costeach, p.landing_cost_price, p.cost_price) * o.quantity ELSE 0 END), 0.00) AS cogs,
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN p.regular_price * o.quantity ELSE 0 END), 0.00) AS gross_regular_revenue,
-- Aggregate Returns (Quantity < 0 or Status = Returned)
COALESCE(SUM(CASE WHEN o.quantity < 0 OR COALESCE(o.status, 'pending') = 'returned' THEN ABS(o.quantity) ELSE 0 END), 0) AS units_returned,
COALESCE(SUM(CASE WHEN o.quantity < 0 OR COALESCE(o.status, 'pending') = 'returned' THEN o.price * ABS(o.quantity) ELSE 0 END), 0.00) AS returns_revenue
FROM public.products p
LEFT JOIN public.orders o ON p.pid = o.pid
GROUP BY p.pid, p.sku, o.date::date
HAVING COUNT(o.id) > 0 -- Only include products with actual orders
),
ReceivingData AS (
SELECT
r.pid,
r.received_date::date AS receiving_date,
-- Count receiving documents to ensure we only include products with real activity
COUNT(DISTINCT r.receiving_id) as receiving_count,
-- Calculate received quantity for this day
SUM(r.received_quantity) AS units_received,
-- Calculate received cost for this day
SUM(r.received_quantity * r.unit_cost) AS cost_received
FROM public.receivings r
GROUP BY r.pid, r.received_date::date
HAVING COUNT(DISTINCT r.receiving_id) > 0 OR SUM(r.received_quantity) > 0
),
-- Get current stock quantities
StockData AS (
SELECT
p.pid,
p.stock_quantity,
COALESCE(p.landing_cost_price, p.cost_price, 0.00) as effective_cost_price,
COALESCE(p.price, 0.00) as current_price,
COALESCE(p.regular_price, 0.00) as current_regular_price
FROM public.products p
),
-- Combine sales and receiving dates to get all activity dates
DatePidCombos AS (
SELECT DISTINCT pid, order_date AS activity_date FROM SalesData
UNION
SELECT DISTINCT pid, receiving_date FROM ReceivingData
),
-- Insert daily snapshots for all product-date combinations
SnapshotInsert AS (
INSERT INTO public.daily_product_snapshots (
snapshot_date,
pid,
sku,
eod_stock_quantity,
eod_stock_cost,
eod_stock_retail,
eod_stock_gross,
stockout_flag,
units_sold,
units_returned,
gross_revenue,
discounts,
returns_revenue,
net_revenue,
cogs,
gross_regular_revenue,
profit,
units_received,
cost_received,
calculation_timestamp
)
SELECT
d.activity_date AS snapshot_date,
d.pid,
p.sku,
-- Use current stock as approximation, since historical stock data is not available
s.stock_quantity AS eod_stock_quantity,
s.stock_quantity * s.effective_cost_price AS eod_stock_cost,
s.stock_quantity * s.current_price AS eod_stock_retail,
s.stock_quantity * s.current_regular_price AS eod_stock_gross,
(s.stock_quantity <= 0) AS stockout_flag,
-- Sales metrics
COALESCE(sd.units_sold, 0),
COALESCE(sd.units_returned, 0),
COALESCE(sd.gross_revenue_unadjusted, 0.00),
COALESCE(sd.discounts, 0.00),
COALESCE(sd.returns_revenue, 0.00),
COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) AS net_revenue,
COALESCE(sd.cogs, 0.00),
COALESCE(sd.gross_regular_revenue, 0.00),
(COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00)) - COALESCE(sd.cogs, 0.00) AS profit,
-- Receiving metrics
COALESCE(rd.units_received, 0),
COALESCE(rd.cost_received, 0.00),
now() -- calculation timestamp
FROM DatePidCombos d
JOIN public.products p ON d.pid = p.pid
LEFT JOIN SalesData sd ON d.pid = sd.pid AND d.activity_date = sd.order_date
LEFT JOIN ReceivingData rd ON d.pid = rd.pid AND d.activity_date = rd.receiving_date
LEFT JOIN StockData s ON d.pid = s.pid
RETURNING pid, snapshot_date
),
-- Now build the aggregated product metrics from the daily snapshots
MetricsInsert AS (
INSERT INTO public.product_metrics (
pid,
sku,
current_stock_quantity,
current_stock_cost,
current_stock_retail,
current_stock_msrp,
is_out_of_stock,
total_units_sold,
total_units_returned,
return_rate,
gross_revenue,
total_discounts,
total_returns,
net_revenue,
total_cogs,
total_gross_revenue,
total_profit,
profit_margin,
avg_daily_units,
reorder_point,
reorder_alert,
days_of_supply,
sales_velocity,
sales_velocity_score,
rank_by_revenue,
rank_by_quantity,
rank_by_profit,
total_received_quantity,
total_received_cost,
last_sold_date,
last_received_date,
days_since_last_sale,
days_since_last_received,
calculation_timestamp
)
SELECT
p.pid,
p.sku,
p.stock_quantity AS current_stock_quantity,
p.stock_quantity * COALESCE(p.landing_cost_price, p.cost_price, 0) AS current_stock_cost,
p.stock_quantity * COALESCE(p.price, 0) AS current_stock_retail,
p.stock_quantity * COALESCE(p.regular_price, 0) AS current_stock_msrp,
(p.stock_quantity <= 0) AS is_out_of_stock,
-- Aggregate metrics
COALESCE(SUM(ds.units_sold), 0) AS total_units_sold,
COALESCE(SUM(ds.units_returned), 0) AS total_units_returned,
CASE
WHEN COALESCE(SUM(ds.units_sold), 0) > 0
THEN COALESCE(SUM(ds.units_returned), 0)::float / NULLIF(COALESCE(SUM(ds.units_sold), 0), 0)
ELSE 0
END AS return_rate,
COALESCE(SUM(ds.gross_revenue), 0) AS gross_revenue,
COALESCE(SUM(ds.discounts), 0) AS total_discounts,
COALESCE(SUM(ds.returns_revenue), 0) AS total_returns,
COALESCE(SUM(ds.net_revenue), 0) AS net_revenue,
COALESCE(SUM(ds.cogs), 0) AS total_cogs,
COALESCE(SUM(ds.gross_regular_revenue), 0) AS total_gross_revenue,
COALESCE(SUM(ds.profit), 0) AS total_profit,
CASE
WHEN COALESCE(SUM(ds.net_revenue), 0) > 0
THEN COALESCE(SUM(ds.profit), 0) / NULLIF(COALESCE(SUM(ds.net_revenue), 0), 0)
ELSE 0
END AS profit_margin,
-- Calculate average daily units
COALESCE(AVG(ds.units_sold), 0) AS avg_daily_units,
-- Calculate reorder point (simplified, can be enhanced with lead time and safety stock)
CEILING(COALESCE(AVG(ds.units_sold) * 14, 0)) AS reorder_point,
(p.stock_quantity <= CEILING(COALESCE(AVG(ds.units_sold) * 14, 0))) AS reorder_alert,
-- Days of supply based on average daily sales
CASE
WHEN COALESCE(AVG(ds.units_sold), 0) > 0
THEN p.stock_quantity / NULLIF(COALESCE(AVG(ds.units_sold), 0), 0)
ELSE NULL
END AS days_of_supply,
-- Sales velocity (average units sold per day over last 30 days)
(SELECT COALESCE(AVG(recent.units_sold), 0)
FROM public.daily_product_snapshots recent
WHERE recent.pid = p.pid
AND recent.snapshot_date >= CURRENT_DATE - INTERVAL '30 days'
) AS sales_velocity,
-- Placeholder for sales velocity score (can be calculated based on velocity)
0 AS sales_velocity_score,
-- Will be updated later by ranking procedure
0 AS rank_by_revenue,
0 AS rank_by_quantity,
0 AS rank_by_profit,
-- Receiving data
COALESCE(SUM(ds.units_received), 0) AS total_received_quantity,
COALESCE(SUM(ds.cost_received), 0) AS total_received_cost,
-- Date metrics
(SELECT MAX(sd.snapshot_date)
FROM public.daily_product_snapshots sd
WHERE sd.pid = p.pid AND sd.units_sold > 0
) AS last_sold_date,
(SELECT MAX(rd.snapshot_date)
FROM public.daily_product_snapshots rd
WHERE rd.pid = p.pid AND rd.units_received > 0
) AS last_received_date,
-- Calculate days since last sale/received
CASE
WHEN (SELECT MAX(sd.snapshot_date)
FROM public.daily_product_snapshots sd
WHERE sd.pid = p.pid AND sd.units_sold > 0) IS NOT NULL
THEN (CURRENT_DATE - (SELECT MAX(sd.snapshot_date)
FROM public.daily_product_snapshots sd
WHERE sd.pid = p.pid AND sd.units_sold > 0))::integer
ELSE NULL
END AS days_since_last_sale,
CASE
WHEN (SELECT MAX(rd.snapshot_date)
FROM public.daily_product_snapshots rd
WHERE rd.pid = p.pid AND rd.units_received > 0) IS NOT NULL
THEN (CURRENT_DATE - (SELECT MAX(rd.snapshot_date)
FROM public.daily_product_snapshots rd
WHERE rd.pid = p.pid AND rd.units_received > 0))::integer
ELSE NULL
END AS days_since_last_received,
now() -- calculation timestamp
FROM public.products p
LEFT JOIN public.daily_product_snapshots ds ON p.pid = ds.pid
GROUP BY p.pid, p.sku, p.stock_quantity, p.landing_cost_price, p.cost_price, p.price, p.regular_price
)
-- Update the calculate_status table
INSERT INTO public.calculate_status (module_name, last_calculation_timestamp)
VALUES
('daily_snapshots', now()),
('product_metrics', now())
ON CONFLICT (module_name) DO UPDATE
SET last_calculation_timestamp = now();
-- Finally, update the ranks for products
UPDATE public.product_metrics pm SET
rank_by_revenue = rev_ranks.rank
FROM (
SELECT pid, RANK() OVER (ORDER BY net_revenue DESC) AS rank
FROM public.product_metrics
WHERE net_revenue > 0
) rev_ranks
WHERE pm.pid = rev_ranks.pid;
UPDATE public.product_metrics pm SET
rank_by_quantity = qty_ranks.rank
FROM (
SELECT pid, RANK() OVER (ORDER BY total_units_sold DESC) AS rank
FROM public.product_metrics
WHERE total_units_sold > 0
) qty_ranks
WHERE pm.pid = qty_ranks.pid;
UPDATE public.product_metrics pm SET
rank_by_profit = profit_ranks.rank
FROM (
SELECT pid, RANK() OVER (ORDER BY total_profit DESC) AS rank
FROM public.product_metrics
WHERE total_profit > 0
) profit_ranks
WHERE pm.pid = profit_ranks.pid;
-- Return count of products with metrics
SELECT COUNT(*) AS product_count FROM public.product_metrics
`);
};
async function populateInitialMetrics() { async function populateInitialMetrics() {
let connection; let connection;
const startTime = Date.now(); const startTime = Date.now();

View File

@@ -2,7 +2,7 @@
-- historically backfilled daily_product_snapshots and current product/PO data. -- historically backfilled daily_product_snapshots and current product/PO data.
-- Calculates all metrics considering the full available history up to 'yesterday'. -- Calculates all metrics considering the full available history up to 'yesterday'.
-- Run ONCE after backfill_historical_snapshots_final.sql completes successfully. -- Run ONCE after backfill_historical_snapshots_final.sql completes successfully.
-- Dependencies: Core import tables (products, purchase_orders), daily_product_snapshots (historically populated), -- Dependencies: Core import tables (products, purchase_orders, receivings), daily_product_snapshots (historically populated),
-- configuration tables (settings_*), product_metrics table must exist. -- configuration tables (settings_*), product_metrics table must exist.
-- Frequency: Run ONCE. -- Frequency: Run ONCE.
DO $$ DO $$
@@ -39,35 +39,26 @@ BEGIN
-- Calculates current on-order quantities and costs -- Calculates current on-order quantities and costs
SELECT SELECT
pid, pid,
COALESCE(SUM(ordered - received), 0) AS on_order_qty, SUM(ordered) AS on_order_qty,
COALESCE(SUM((ordered - received) * cost_price), 0.00) AS on_order_cost, SUM(ordered * po_cost_price) AS on_order_cost,
MIN(expected_date) AS earliest_expected_date MIN(expected_date) AS earliest_expected_date
FROM public.purchase_orders FROM public.purchase_orders
-- Use the most common statuses representing active, unfulfilled POs -- Use the most common statuses representing active, unfulfilled POs
WHERE status IN ('open', 'partially_received', 'ordered', 'preordered', 'receiving_started', 'electronically_sent', 'electronically_ready_send') WHERE status IN ('created', 'ordered', 'preordered', 'electronically_sent', 'electronically_ready_send', 'receiving_started')
AND (ordered - received) > 0 AND status NOT IN ('canceled', 'done')
GROUP BY pid GROUP BY pid
), ),
HistoricalDates AS ( HistoricalDates AS (
-- Determines key historical dates from orders and PO history (receiving_history) -- Determines key historical dates from orders and receivings
SELECT SELECT
p.pid, p.pid,
MIN(o.date)::date AS date_first_sold, MIN(o.date)::date AS date_first_sold,
MAX(o.date)::date AS max_order_date, -- Used as fallback for date_last_sold MAX(o.date)::date AS max_order_date, -- Used as fallback for date_last_sold
MIN(rh.first_receipt_date) AS date_first_received_calc, MIN(r.received_date)::date AS date_first_received_calc,
MAX(rh.last_receipt_date) AS date_last_received_calc MAX(r.received_date)::date AS date_last_received_calc
FROM public.products p FROM public.products p
LEFT JOIN public.orders o ON p.pid = o.pid AND o.quantity > 0 AND o.status NOT IN ('canceled', 'returned') LEFT JOIN public.orders o ON p.pid = o.pid AND o.quantity > 0 AND o.status NOT IN ('canceled', 'returned')
LEFT JOIN ( LEFT JOIN public.receivings r ON p.pid = r.pid
SELECT
po.pid,
MIN((rh.item->>'received_at')::date) as first_receipt_date,
MAX((rh.item->>'received_at')::date) as last_receipt_date
FROM public.purchase_orders po
CROSS JOIN LATERAL jsonb_array_elements(po.receiving_history) AS rh(item)
WHERE jsonb_typeof(po.receiving_history) = 'array' AND jsonb_array_length(po.receiving_history) > 0
GROUP BY po.pid
) rh ON p.pid = rh.pid
GROUP BY p.pid GROUP BY p.pid
), ),
SnapshotAggregates AS ( SnapshotAggregates AS (
@@ -165,22 +156,23 @@ BEGIN
LEFT JOIN public.settings_vendor sv ON p.vendor = sv.vendor LEFT JOIN public.settings_vendor sv ON p.vendor = sv.vendor
), ),
AvgLeadTime AS ( AvgLeadTime AS (
-- Calculate Average Lead Time from historical POs -- Calculate Average Lead Time by joining purchase_orders with receivings
SELECT SELECT
pid, po.pid,
AVG(GREATEST(1, AVG(GREATEST(1,
CASE CASE
WHEN last_received_date IS NOT NULL AND date IS NOT NULL WHEN r.received_date IS NOT NULL AND po.date IS NOT NULL
THEN (last_received_date::date - date::date) THEN (r.received_date::date - po.date::date)
ELSE 1 ELSE 1
END END
))::int AS avg_lead_time_days_calc ))::int AS avg_lead_time_days_calc
FROM public.purchase_orders FROM public.purchase_orders po
WHERE status = 'received' -- Assumes 'received' marks full receipt JOIN public.receivings r ON r.pid = po.pid
AND last_received_date IS NOT NULL WHERE po.status = 'done' -- Completed POs
AND date IS NOT NULL AND r.received_date IS NOT NULL
AND last_received_date >= date AND po.date IS NOT NULL
GROUP BY pid AND r.received_date >= po.date
GROUP BY po.pid
), ),
RankedForABC AS ( RankedForABC AS (
-- Ranks products based on the configured ABC metric (using historical data) -- Ranks products based on the configured ABC metric (using historical data)
@@ -198,7 +190,7 @@ BEGIN
WHEN 'sales_30d' THEN COALESCE(sa.sales_30d, 0) WHEN 'sales_30d' THEN COALESCE(sa.sales_30d, 0)
WHEN 'lifetime_revenue' THEN COALESCE(sa.lifetime_revenue, 0)::numeric WHEN 'lifetime_revenue' THEN COALESCE(sa.lifetime_revenue, 0)::numeric
ELSE COALESCE(sa.revenue_30d, 0) ELSE COALESCE(sa.revenue_30d, 0)
END) > 0 -- Exclude zero-value products from ranking END) > 0 -- Only include products with non-zero contribution
), ),
CumulativeABC AS ( CumulativeABC AS (
-- Calculates cumulative metric values for ABC ranking -- Calculates cumulative metric values for ABC ranking

View File

@@ -1,6 +1,6 @@
-- Description: Rebuilds daily product snapshots from scratch using real orders data. -- Description: Rebuilds daily product snapshots from scratch using real orders data.
-- Fixes issues with duplicated/inflated metrics. -- Fixes issues with duplicated/inflated metrics.
-- Dependencies: Core import tables (products, orders, purchase_orders). -- Dependencies: Core import tables (products, orders, receivings).
-- Frequency: One-time run to clear out problematic data. -- Frequency: One-time run to clear out problematic data.
DO $$ DO $$
@@ -51,65 +51,17 @@ BEGIN
), ),
ReceivingData AS ( ReceivingData AS (
SELECT SELECT
po.pid, r.pid,
-- Count POs to ensure we only include products with real activity -- Count receiving documents to ensure we only include products with real activity
COUNT(po.po_id) as po_count, COUNT(DISTINCT r.receiving_id) as receiving_count,
-- Calculate received quantity for this day -- Calculate received quantity for this day
COALESCE( SUM(r.qty_each) AS units_received,
-- First try the received field from purchase_orders table (if received on this date) -- Calculate received cost for this day
SUM(CASE WHEN po.date::date = _date THEN po.received ELSE 0 END), SUM(r.qty_each * r.cost_each) AS cost_received
FROM public.receivings r
-- Otherwise try receiving_history JSON WHERE r.received_date::date = _date
SUM( GROUP BY r.pid
CASE HAVING COUNT(DISTINCT r.receiving_id) > 0 OR SUM(r.qty_each) > 0
WHEN (rh.item->>'date')::date = _date THEN (rh.item->>'qty')::numeric
WHEN (rh.item->>'received_at')::date = _date THEN (rh.item->>'qty')::numeric
WHEN (rh.item->>'receipt_date')::date = _date THEN (rh.item->>'qty')::numeric
ELSE 0
END
),
0
) AS units_received,
COALESCE(
-- First try the actual cost_price from purchase_orders
SUM(CASE WHEN po.date::date = _date THEN po.received * po.cost_price ELSE 0 END),
-- Otherwise try receiving_history JSON
SUM(
CASE
WHEN (rh.item->>'date')::date = _date THEN (rh.item->>'qty')::numeric
WHEN (rh.item->>'received_at')::date = _date THEN (rh.item->>'qty')::numeric
WHEN (rh.item->>'receipt_date')::date = _date THEN (rh.item->>'qty')::numeric
ELSE 0
END
* COALESCE((rh.item->>'cost')::numeric, po.cost_price)
),
0.00
) AS cost_received
FROM public.purchase_orders po
LEFT JOIN LATERAL jsonb_array_elements(po.receiving_history) AS rh(item) ON
jsonb_typeof(po.receiving_history) = 'array' AND
jsonb_array_length(po.receiving_history) > 0 AND
(
(rh.item->>'date')::date = _date OR
(rh.item->>'received_at')::date = _date OR
(rh.item->>'receipt_date')::date = _date
)
-- Include POs with the current date or relevant receiving_history
WHERE
po.date::date = _date OR
jsonb_typeof(po.receiving_history) = 'array' AND
jsonb_array_length(po.receiving_history) > 0
GROUP BY po.pid
HAVING COUNT(po.po_id) > 0 OR SUM(
CASE
WHEN (rh.item->>'date')::date = _date THEN (rh.item->>'qty')::numeric
WHEN (rh.item->>'received_at')::date = _date THEN (rh.item->>'qty')::numeric
WHEN (rh.item->>'receipt_date')::date = _date THEN (rh.item->>'qty')::numeric
ELSE 0
END
) > 0
), ),
-- Get stock quantities for the day - note this is approximate since we're using current products data -- Get stock quantities for the day - note this is approximate since we're using current products data
StockData AS ( StockData AS (
@@ -170,7 +122,7 @@ BEGIN
FROM SalesData sd FROM SalesData sd
FULL OUTER JOIN ReceivingData rd ON sd.pid = rd.pid FULL OUTER JOIN ReceivingData rd ON sd.pid = rd.pid
LEFT JOIN StockData s ON COALESCE(sd.pid, rd.pid) = s.pid LEFT JOIN StockData s ON COALESCE(sd.pid, rd.pid) = s.pid
WHERE (COALESCE(sd.order_count, 0) > 0 OR COALESCE(rd.po_count, 0) > 0); WHERE (COALESCE(sd.order_count, 0) > 0 OR COALESCE(rd.receiving_count, 0) > 0);
-- Get record count for this day -- Get record count for this day
GET DIAGNOSTICS _count = ROW_COUNT; GET DIAGNOSTICS _count = ROW_COUNT;

View File

@@ -45,19 +45,26 @@ BEGIN
GROUP BY p.vendor GROUP BY p.vendor
), ),
VendorPOAggregates AS ( VendorPOAggregates AS (
-- Aggregate PO related stats -- Aggregate PO related stats including lead time calculated from POs to receivings
SELECT SELECT
vendor, po.vendor,
COUNT(DISTINCT po_id) AS po_count_365d, COUNT(DISTINCT po.po_id) AS po_count_365d,
AVG(GREATEST(1, CASE WHEN last_received_date IS NOT NULL AND date IS NOT NULL THEN (last_received_date::date - date::date) ELSE NULL END))::int AS avg_lead_time_days_hist -- Avg lead time from HISTORICAL received POs -- Calculate lead time by averaging the days between PO date and receiving date
FROM public.purchase_orders AVG(GREATEST(1, CASE
WHERE vendor IS NOT NULL AND vendor <> '' WHEN r.received_date IS NOT NULL AND po.date IS NOT NULL
AND date >= CURRENT_DATE - INTERVAL '1 year' -- Look at POs created in the last year THEN (r.received_date::date - po.date::date)
AND status = 'received' -- Only calculate lead time on fully received POs ELSE NULL
AND last_received_date IS NOT NULL END))::int AS avg_lead_time_days_hist -- Avg lead time from HISTORICAL received POs
AND date IS NOT NULL FROM public.purchase_orders po
AND last_received_date >= date -- Join to receivings table to find when items were received
GROUP BY vendor LEFT JOIN public.receivings r ON r.pid = po.pid
WHERE po.vendor IS NOT NULL AND po.vendor <> ''
AND po.date >= CURRENT_DATE - INTERVAL '1 year' -- Look at POs created in the last year
AND po.status = 'done' -- Only calculate lead time on completed POs
AND r.received_date IS NOT NULL
AND po.date IS NOT NULL
AND r.received_date >= po.date
GROUP BY po.vendor
), ),
AllVendors AS ( AllVendors AS (
-- Ensure all vendors from products table are included -- Ensure all vendors from products table are included

View File

@@ -101,66 +101,20 @@ BEGIN
), ),
ReceivingData AS ( ReceivingData AS (
SELECT SELECT
po.pid, r.pid,
-- Track number of POs to ensure we have real data -- Track number of receiving docs to ensure we have real data
COUNT(po.po_id) as po_count, COUNT(DISTINCT r.receiving_id) as receiving_doc_count,
-- Prioritize the actual table fields over the JSON data -- Sum the quantities received on this date
COALESCE( SUM(r.qty_each) AS units_received,
-- First try the received field from purchase_orders table -- Calculate the cost received (qty * cost)
SUM(CASE WHEN po.date::date = _target_date THEN po.received ELSE 0 END), SUM(r.qty_each * r.cost_each) AS cost_received
FROM public.receivings r
-- Otherwise fall back to the receiving_history JSON as secondary source WHERE r.received_date::date = _target_date
SUM( -- Optional: Filter out canceled receivings if needed
CASE -- AND r.status <> 'canceled'
WHEN (rh.item->>'date')::date = _target_date THEN (rh.item->>'qty')::numeric GROUP BY r.pid
WHEN (rh.item->>'received_at')::date = _target_date THEN (rh.item->>'qty')::numeric -- Only include products with actual receiving activity
WHEN (rh.item->>'receipt_date')::date = _target_date THEN (rh.item->>'qty')::numeric HAVING COUNT(DISTINCT r.receiving_id) > 0 OR SUM(r.qty_each) > 0
ELSE 0
END
),
0
) AS units_received,
COALESCE(
-- First try the actual cost_price from purchase_orders
SUM(CASE WHEN po.date::date = _target_date THEN po.received * po.cost_price ELSE 0 END),
-- Otherwise fall back to receiving_history JSON
SUM(
CASE
WHEN (rh.item->>'date')::date = _target_date THEN (rh.item->>'qty')::numeric
WHEN (rh.item->>'received_at')::date = _target_date THEN (rh.item->>'qty')::numeric
WHEN (rh.item->>'receipt_date')::date = _target_date THEN (rh.item->>'qty')::numeric
ELSE 0
END
* COALESCE((rh.item->>'cost')::numeric, po.cost_price)
),
0.00
) AS cost_received
FROM public.purchase_orders po
LEFT JOIN LATERAL jsonb_array_elements(po.receiving_history) AS rh(item) ON
jsonb_typeof(po.receiving_history) = 'array' AND
jsonb_array_length(po.receiving_history) > 0 AND
(
(rh.item->>'date')::date = _target_date OR
(rh.item->>'received_at')::date = _target_date OR
(rh.item->>'receipt_date')::date = _target_date
)
-- Include POs with the current date or relevant receiving_history
WHERE
po.date::date = _target_date OR
jsonb_typeof(po.receiving_history) = 'array' AND
jsonb_array_length(po.receiving_history) > 0
GROUP BY po.pid
-- CRITICAL: Only include products with actual receiving activity
HAVING COUNT(po.po_id) > 0 OR SUM(
CASE
WHEN (rh.item->>'date')::date = _target_date THEN (rh.item->>'qty')::numeric
WHEN (rh.item->>'received_at')::date = _target_date THEN (rh.item->>'qty')::numeric
WHEN (rh.item->>'receipt_date')::date = _target_date THEN (rh.item->>'qty')::numeric
ELSE 0
END
) > 0
), ),
CurrentStock AS ( CurrentStock AS (
-- Select current stock values directly from products table -- Select current stock values directly from products table

View File

@@ -24,14 +24,17 @@ BEGIN
RAISE NOTICE 'Calculating Average Lead Time...'; RAISE NOTICE 'Calculating Average Lead Time...';
WITH LeadTimes AS ( WITH LeadTimes AS (
SELECT SELECT
pid, po.pid,
AVG(GREATEST(1, (last_received_date::date - date::date))) AS avg_days -- Use GREATEST(1,...) to avoid 0 or negative days -- Calculate lead time by looking at when items ordered on POs were received
FROM public.purchase_orders AVG(GREATEST(1, (r.received_date::date - po.date::date))) AS avg_days -- Use GREATEST(1,...) to avoid 0 or negative days
WHERE status = 'received' -- Or potentially 'full_received' if using that status FROM public.purchase_orders po
AND last_received_date IS NOT NULL -- Join to receivings table to find actual receipts
AND date IS NOT NULL JOIN public.receivings r ON r.pid = po.pid
AND last_received_date >= date -- Ensure received date is not before order date WHERE po.status = 'done' -- Only include completed POs
GROUP BY pid AND r.received_date >= po.date -- Ensure received date is not before order date
-- Optional: add check to make sure receiving is related to PO if you have source_po_id
-- AND (r.source_po_id = po.po_id OR r.source_po_id IS NULL)
GROUP BY po.pid
) )
UPDATE public.product_metrics pm UPDATE public.product_metrics pm
SET avg_lead_time_days = lt.avg_days::int SET avg_lead_time_days = lt.avg_days::int

View File

@@ -64,12 +64,12 @@ BEGIN
OnOrderInfo AS ( OnOrderInfo AS (
SELECT SELECT
pid, pid,
COALESCE(SUM(ordered - received), 0) AS on_order_qty, SUM(ordered) AS on_order_qty,
COALESCE(SUM((ordered - received) * cost_price), 0.00) AS on_order_cost, SUM(ordered * po_cost_price) AS on_order_cost,
MIN(expected_date) AS earliest_expected_date MIN(expected_date) AS earliest_expected_date
FROM public.purchase_orders FROM public.purchase_orders
WHERE status IN ('open', 'partially_received', 'ordered', 'preordered', 'receiving_started', 'electronically_sent', 'electronically_ready_send') -- Adjust based on your status workflow representing active POs not fully received WHERE status IN ('created', 'ordered', 'preordered', 'electronically_sent', 'electronically_ready_send', 'receiving_started')
AND (ordered - received) > 0 AND status NOT IN ('canceled', 'done')
GROUP BY pid GROUP BY pid
), ),
HistoricalDates AS ( HistoricalDates AS (
@@ -80,45 +80,14 @@ BEGIN
MIN(o.date)::date AS date_first_sold, MIN(o.date)::date AS date_first_sold,
MAX(o.date)::date AS max_order_date, -- Use MAX for potential recalc of date_last_sold MAX(o.date)::date AS max_order_date, -- Use MAX for potential recalc of date_last_sold
-- For first received date, try table data first then fall back to JSON -- For first received, use the new receivings table
COALESCE( MIN(r.received_date)::date AS date_first_received_calc,
MIN(po.date)::date, -- Try purchase_order date first
MIN(rh.first_receipt_date) -- Fall back to JSON data if needed
) AS date_first_received_calc,
-- If we only have one receipt date (first = last), use that for last_received too -- For last received, use the new receivings table
COALESCE( MAX(r.received_date)::date AS date_last_received_calc
MAX(po.date)::date, -- Try purchase_order date first
NULLIF(MAX(rh.last_receipt_date), NULL),
MIN(rh.first_receipt_date)
) AS date_last_received_calc
FROM public.products p FROM public.products p
LEFT JOIN public.orders o ON p.pid = o.pid AND o.quantity > 0 AND o.status NOT IN ('canceled', 'returned') LEFT JOIN public.orders o ON p.pid = o.pid AND o.quantity > 0 AND o.status NOT IN ('canceled', 'returned')
LEFT JOIN public.purchase_orders po ON p.pid = po.pid AND po.received > 0 LEFT JOIN public.receivings r ON p.pid = r.pid
LEFT JOIN (
SELECT
po.pid,
MIN(
CASE
WHEN rh.item->>'date' IS NOT NULL THEN (rh.item->>'date')::date
WHEN rh.item->>'received_at' IS NOT NULL THEN (rh.item->>'received_at')::date
WHEN rh.item->>'receipt_date' IS NOT NULL THEN (rh.item->>'receipt_date')::date
ELSE NULL
END
) as first_receipt_date,
MAX(
CASE
WHEN rh.item->>'date' IS NOT NULL THEN (rh.item->>'date')::date
WHEN rh.item->>'received_at' IS NOT NULL THEN (rh.item->>'received_at')::date
WHEN rh.item->>'receipt_date' IS NOT NULL THEN (rh.item->>'receipt_date')::date
ELSE NULL
END
) as last_receipt_date
FROM public.purchase_orders po
CROSS JOIN LATERAL jsonb_array_elements(po.receiving_history) AS rh(item)
WHERE jsonb_typeof(po.receiving_history) = 'array' AND jsonb_array_length(po.receiving_history) > 0
GROUP BY po.pid
) rh ON p.pid = rh.pid
GROUP BY p.pid GROUP BY p.pid
), ),
SnapshotAggregates AS ( SnapshotAggregates AS (

View File

@@ -4,7 +4,7 @@ const cors = require('cors');
const corsMiddleware = cors({ const corsMiddleware = cors({
origin: [ origin: [
'https://inventory.kent.pw', 'https://inventory.kent.pw',
'http://localhost:5173', 'http://localhost:5175',
/^http:\/\/192\.168\.\d+\.\d+(:\d+)?$/, /^http:\/\/192\.168\.\d+\.\d+(:\d+)?$/,
/^http:\/\/10\.\d+\.\d+\.\d+(:\d+)?$/ /^http:\/\/10\.\d+\.\d+\.\d+(:\d+)?$/
], ],
@@ -26,7 +26,7 @@ const corsErrorHandler = (err, req, res, next) => {
res.status(403).json({ res.status(403).json({
error: 'CORS not allowed', error: 'CORS not allowed',
origin: req.get('Origin'), origin: req.get('Origin'),
message: 'Origin not in allowed list: https://inventory.kent.pw, localhost:5173, 192.168.x.x, or 10.x.x.x' message: 'Origin not in allowed list: https://inventory.kent.pw, localhost:5175, 192.168.x.x, or 10.x.x.x'
}); });
} else { } else {
next(err); next(err);

View File

@@ -108,47 +108,52 @@ router.get('/purchase/metrics', async (req, res) => {
`); `);
const { rows: [poMetrics] } = await executeQuery(` const { rows: [poMetrics] } = await executeQuery(`
WITH po_metrics AS (
SELECT SELECT
COALESCE(COUNT(DISTINCT CASE po_id,
WHEN po.receiving_status NOT IN ('partial_received', 'full_received', 'paid') status,
THEN po.po_id date,
END), 0)::integer as active_pos, expected_date,
COALESCE(COUNT(DISTINCT CASE pid,
WHEN po.receiving_status NOT IN ('partial_received', 'full_received', 'paid') ordered,
AND po.expected_date < CURRENT_DATE po_cost_price
THEN po.po_id
END), 0)::integer as overdue_pos,
COALESCE(SUM(CASE
WHEN po.receiving_status NOT IN ('partial_received', 'full_received', 'paid')
THEN po.ordered
ELSE 0
END), 0)::integer as total_units,
ROUND(COALESCE(SUM(CASE
WHEN po.receiving_status NOT IN ('partial_received', 'full_received', 'paid')
THEN po.ordered * po.cost_price
ELSE 0
END), 0)::numeric, 3) as total_cost,
ROUND(COALESCE(SUM(CASE
WHEN po.receiving_status NOT IN ('partial_received', 'full_received', 'paid')
THEN po.ordered * pm.current_price
ELSE 0
END), 0)::numeric, 3) as total_retail
FROM purchase_orders po FROM purchase_orders po
WHERE po.status NOT IN ('canceled', 'done')
AND po.date >= CURRENT_DATE - INTERVAL '6 months'
)
SELECT
COUNT(DISTINCT po_id)::integer as active_pos,
COUNT(DISTINCT CASE WHEN expected_date < CURRENT_DATE THEN po_id END)::integer as overdue_pos,
SUM(ordered)::integer as total_units,
ROUND(SUM(ordered * po_cost_price)::numeric, 3) as total_cost,
ROUND(SUM(ordered * pm.current_price)::numeric, 3) as total_retail
FROM po_metrics po
JOIN product_metrics pm ON po.pid = pm.pid JOIN product_metrics pm ON po.pid = pm.pid
`); `);
const { rows: vendorOrders } = await executeQuery(` const { rows: vendorOrders } = await executeQuery(`
WITH po_by_vendor AS (
SELECT SELECT
po.vendor, vendor,
COUNT(DISTINCT po.po_id)::integer as orders, po_id,
COALESCE(SUM(po.ordered), 0)::integer as units, SUM(ordered) as total_ordered,
ROUND(COALESCE(SUM(po.ordered * po.cost_price), 0)::numeric, 3) as cost, SUM(ordered * po_cost_price) as total_cost
ROUND(COALESCE(SUM(po.ordered * pm.current_price), 0)::numeric, 3) as retail FROM purchase_orders
FROM purchase_orders po WHERE status NOT IN ('canceled', 'done')
AND date >= CURRENT_DATE - INTERVAL '6 months'
GROUP BY vendor, po_id
)
SELECT
pv.vendor,
COUNT(DISTINCT pv.po_id)::integer as orders,
SUM(pv.total_ordered)::integer as units,
ROUND(SUM(pv.total_cost)::numeric, 3) as cost,
ROUND(SUM(pv.total_ordered * pm.current_price)::numeric, 3) as retail
FROM po_by_vendor pv
JOIN purchase_orders po ON pv.po_id = po.po_id
JOIN product_metrics pm ON po.pid = pm.pid JOIN product_metrics pm ON po.pid = pm.pid
WHERE po.receiving_status NOT IN ('partial_received', 'full_received', 'paid') GROUP BY pv.vendor
GROUP BY po.vendor HAVING ROUND(SUM(pv.total_cost)::numeric, 3) > 0
HAVING ROUND(COALESCE(SUM(po.ordered * po.cost_price), 0)::numeric, 3) > 0
ORDER BY cost DESC ORDER BY cost DESC
`); `);

View File

@@ -351,7 +351,7 @@ router.get('/status/table-counts', async (req, res) => {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
const tables = [ const tables = [
// Core tables // Core tables
'products', 'categories', 'product_categories', 'orders', 'purchase_orders', 'products', 'categories', 'product_categories', 'orders', 'purchase_orders', 'receivings',
// New metrics tables // New metrics tables
'product_metrics', 'daily_product_snapshots','brand_metrics','category_metrics','vendor_metrics', 'product_metrics', 'daily_product_snapshots','brand_metrics','category_metrics','vendor_metrics',
// Config tables // Config tables
@@ -375,7 +375,7 @@ router.get('/status/table-counts', async (req, res) => {
// Group tables by type // Group tables by type
const groupedCounts = { const groupedCounts = {
core: counts.filter(c => ['products', 'categories', 'product_categories', 'orders', 'purchase_orders'].includes(c.table_name)), core: counts.filter(c => ['products', 'categories', 'product_categories', 'orders', 'purchase_orders', 'receivings'].includes(c.table_name)),
metrics: counts.filter(c => ['product_metrics', 'daily_product_snapshots','brand_metrics','category_metrics','vendor_metrics'].includes(c.table_name)), metrics: counts.filter(c => ['product_metrics', 'daily_product_snapshots','brand_metrics','category_metrics','vendor_metrics'].includes(c.table_name)),
config: counts.filter(c => ['settings_global', 'settings_vendor', 'settings_product'].includes(c.table_name)) config: counts.filter(c => ['settings_global', 'settings_vendor', 'settings_product'].includes(c.table_name))
}; };

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,383 @@
import { useState, useEffect } from "react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { Skeleton } from "../../components/ui/skeleton";
import { BarChart3, Loader2 } from "lucide-react";
import { Button } from "../../components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../components/ui/dialog";
import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table";
// Add this constant for pie chart colors
const COLORS = [
"#0088FE",
"#00C49F",
"#FFBB28",
"#FF8042",
"#8884D8",
"#82CA9D",
"#FFC658",
"#FF7C43",
];
// The renderActiveShape function for pie charts
const renderActiveShape = (props: any) => {
const {
cx,
cy,
innerRadius,
outerRadius,
startAngle,
endAngle,
fill,
category,
total_spend,
} = props;
// Split category name into words and create lines of max 12 chars
const words = category.split(" ");
const lines: string[] = [];
let currentLine = "";
words.forEach((word: string) => {
if ((currentLine + " " + word).length <= 12) {
currentLine = currentLine ? `${currentLine} ${word}` : word;
} else {
if (currentLine) lines.push(currentLine);
currentLine = word;
}
});
if (currentLine) lines.push(currentLine);
return (
<g>
<Sector
cx={cx}
cy={cy}
innerRadius={innerRadius}
outerRadius={outerRadius}
startAngle={startAngle}
endAngle={endAngle}
fill={fill}
/>
<Sector
cx={cx}
cy={cy}
startAngle={startAngle}
endAngle={endAngle}
innerRadius={outerRadius - 1}
outerRadius={outerRadius + 4}
fill={fill}
/>
{lines.map((line, i) => (
<text
key={i}
x={cx}
y={cy}
dy={-20 + i * 16}
textAnchor="middle"
fill="#888888"
className="text-xs"
>
{line}
</text>
))}
<text
x={cx}
y={cy}
dy={lines.length * 16 - 10}
textAnchor="middle"
fill="#000000"
className="text-base font-medium"
>
{`$${Number(total_spend).toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`}
</text>
</g>
);
};
interface CategoryMetricsCardProps {
loading: boolean;
yearlyCategoryData: {
category: string;
unique_products?: number;
total_spend: number;
percentage?: number;
avg_cost?: number;
cost_variance?: number;
}[];
yearlyDataLoading: boolean;
}
export default function CategoryMetricsCard({
loading,
yearlyCategoryData,
yearlyDataLoading,
}: CategoryMetricsCardProps) {
const [costAnalysisOpen, setCostAnalysisOpen] = useState(false);
const [activeSpendingIndex, setActiveSpendingIndex] = useState<
number | undefined
>();
const [initialLoading, setInitialLoading] = useState(true);
// Only show loading state on initial load, not during table refreshes
useEffect(() => {
if (yearlyCategoryData.length > 0 && !yearlyDataLoading) {
setInitialLoading(false);
}
}, [yearlyCategoryData, yearlyDataLoading]);
const formatNumber = (value: number) => {
return value.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
};
const formatCurrency = (value: number) => {
return `$${formatNumber(value)}`;
};
const formatPercent = (value: number) => {
return (
(value * 100).toLocaleString("en-US", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}) + "%"
);
};
// Prepare spending chart data
const prepareSpendingChartData = () => {
if (!yearlyCategoryData.length) return [];
// Make a copy to avoid modifying state directly
const categoryArray = [...yearlyCategoryData];
const totalSpend = categoryArray.reduce(
(sum, cat) => sum + cat.total_spend,
0
);
// Split into significant categories (>=1%) and others
const significantCategories = categoryArray.filter(
(cat) => cat.total_spend / totalSpend >= 0.01
);
const otherCategories = categoryArray.filter(
(cat) => cat.total_spend / totalSpend < 0.01
);
let result = [...significantCategories];
// Add "Other" category if needed
if (otherCategories.length > 0) {
const otherTotalSpend = otherCategories.reduce(
(sum, cat) => sum + cat.total_spend,
0
);
result.push({
category: "Other",
total_spend: otherTotalSpend,
percentage: otherTotalSpend / totalSpend,
unique_products: otherCategories.reduce(
(sum, cat) => sum + (cat.unique_products || 0),
0
),
avg_cost:
otherTotalSpend /
otherCategories.reduce(
(sum, cat) => sum + (cat.unique_products || 0),
1
),
cost_variance: 0,
});
}
// Sort by spend amount descending
return result.sort((a, b) => b.total_spend - a.total_spend);
};
// Cost analysis table component
const CostAnalysisTable = () => {
if (!yearlyCategoryData.length) {
return yearlyDataLoading ? (
<div className="flex justify-center p-4">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
) : (
<div className="text-center p-4 text-muted-foreground">
No category data available for the past 12 months
</div>
);
}
return (
<div>
{yearlyDataLoading ? (
<div className="flex justify-center p-4">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
) : (
<>
<div className="text-sm font-medium mb-2 px-4 flex justify-between">
<span>
Showing received inventory by category for the past 12 months
</span>
<span>{yearlyCategoryData.length} categories found</span>
</div>
<div className="text-xs text-muted-foreground px-4 mb-2">
Note: items can be in multiple categories, so the sum of the
categories will not equal the total spend.
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Category</TableHead>
<TableHead>Products</TableHead>
<TableHead>Avg. Cost</TableHead>
<TableHead>Price Variance</TableHead>
<TableHead>Total Spend</TableHead>
<TableHead>% of Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{yearlyCategoryData.map((category) => {
// Calculate percentage of total spend
const totalSpendPercentage =
"percentage" in category &&
typeof category.percentage === "number"
? category.percentage
: yearlyCategoryData.reduce(
(sum, cat) => sum + cat.total_spend,
0
) > 0
? category.total_spend /
yearlyCategoryData.reduce(
(sum, cat) => sum + cat.total_spend,
0
)
: 0;
return (
<TableRow key={category.category}>
<TableCell className="font-medium">
{category.category || "Uncategorized"}
</TableCell>
<TableCell>
{category.unique_products?.toLocaleString() || "N/A"}
</TableCell>
<TableCell>
{category.avg_cost !== undefined
? formatCurrency(category.avg_cost)
: "N/A"}
</TableCell>
<TableCell>
{category.cost_variance !== undefined
? parseFloat(
category.cost_variance.toFixed(2)
).toLocaleString()
: "N/A"}
</TableCell>
<TableCell>
{formatCurrency(category.total_spend)}
</TableCell>
<TableCell>
{formatPercent(totalSpendPercentage)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</>
)}
</div>
);
};
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Received by Category
</CardTitle>
<Dialog open={costAnalysisOpen} onOpenChange={setCostAnalysisOpen}>
<DialogTrigger asChild>
<Button variant="outline" disabled={initialLoading || loading}>
<BarChart3 className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-[90%] w-fit">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5" />
<span>Received Inventory by Category</span>
</DialogTitle>
</DialogHeader>
<div className="overflow-auto max-h-[70vh]">
<CostAnalysisTable />
</div>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent>
{initialLoading || loading ? (
<div className="flex flex-col items-center justify-center h-[170px]">
<Skeleton className="h-[170px] w-[170px] rounded-full" />
</div>
) : (
<>
<div className="h-[170px] relative">
<ResponsiveContainer width="100%" height="100%">
<PieChart margin={{ top: 30, right: 0, left: 0, bottom: 30 }}>
<Pie
data={prepareSpendingChartData()}
dataKey="total_spend"
nameKey="category"
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
paddingAngle={1}
activeIndex={activeSpendingIndex}
activeShape={renderActiveShape}
onMouseEnter={(_, index) => setActiveSpendingIndex(index)}
onMouseLeave={() => setActiveSpendingIndex(undefined)}
>
{prepareSpendingChartData().map((entry, index) => (
<Cell
key={entry.category}
fill={COLORS[index % COLORS.length]}
/>
))}
</Pie>
</PieChart>
</ResponsiveContainer>
</div>
</>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,155 @@
import { Input } from "../../components/ui/input";
import { Button } from "../../components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../components/ui/select";
import {
PurchaseOrderStatus,
getPurchaseOrderStatusLabel
} from "../../types/status-codes";
interface FilterControlsProps {
searchInput: string;
setSearchInput: (value: string) => void;
filterValues: {
search: string;
status: string;
vendor: string;
recordType: string;
};
handleStatusChange: (value: string) => void;
handleVendorChange: (value: string) => void;
handleRecordTypeChange: (value: string) => void;
clearFilters: () => void;
filterOptions: {
vendors: string[];
statuses: number[];
};
loading: boolean;
}
const STATUS_FILTER_OPTIONS = [
{ value: "all", label: "All Statuses" },
{
value: String(PurchaseOrderStatus.Created),
label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.Created),
},
{
value: String(PurchaseOrderStatus.ElectronicallyReadySend),
label: getPurchaseOrderStatusLabel(
PurchaseOrderStatus.ElectronicallyReadySend
),
},
{
value: String(PurchaseOrderStatus.Ordered),
label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.Ordered),
},
{
value: String(PurchaseOrderStatus.ReceivingStarted),
label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.ReceivingStarted),
},
{
value: String(PurchaseOrderStatus.Done),
label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.Done),
},
{
value: String(PurchaseOrderStatus.Canceled),
label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.Canceled),
},
];
const RECORD_TYPE_FILTER_OPTIONS = [
{ value: "all", label: "All Records" },
{ value: "po_only", label: "PO Only" },
{ value: "po_with_receiving", label: "PO with Receiving" },
{ value: "receiving_only", label: "Receiving Only" },
];
export default function FilterControls({
searchInput,
setSearchInput,
filterValues,
handleStatusChange,
handleVendorChange,
handleRecordTypeChange,
clearFilters,
filterOptions,
loading,
}: FilterControlsProps) {
return (
<div className="mb-4 flex flex-wrap items-center gap-4">
<Input
placeholder="Search orders..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="max-w-xs"
disabled={loading}
/>
<Select
value={filterValues.status}
onValueChange={handleStatusChange}
disabled={loading}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
{STATUS_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filterValues.vendor}
onValueChange={handleVendorChange}
disabled={loading}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select supplier" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Suppliers</SelectItem>
{filterOptions?.vendors?.map((vendor) => (
<SelectItem key={vendor} value={vendor}>
{vendor}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filterValues.recordType}
onValueChange={handleRecordTypeChange}
disabled={loading}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Record Type" />
</SelectTrigger>
<SelectContent>
{RECORD_TYPE_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{(filterValues.search || filterValues.status !== "all" || filterValues.vendor !== "all" || filterValues.recordType !== "all") && (
<Button
variant="outline"
size="sm"
onClick={clearFilters}
disabled={loading}
title="Clear filters"
className="gap-1"
>
<span>Clear</span>
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,122 @@
import { useState, useEffect } from "react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { Skeleton } from "../../components/ui/skeleton";
type ReceivingStatus = {
order_count: number;
total_ordered: number;
total_received: number;
fulfillment_rate: number;
total_value: number;
avg_cost: number;
avg_delivery_days?: number;
max_delivery_days?: number;
};
interface OrderMetricsCardProps {
summary: ReceivingStatus | null;
loading: boolean;
}
export default function OrderMetricsCard({
summary,
loading,
}: OrderMetricsCardProps) {
const [initialLoading, setInitialLoading] = useState(true);
// Only show loading state on initial load, not during table refreshes
useEffect(() => {
if (summary) {
setInitialLoading(false);
}
}, [summary]);
const formatNumber = (value: number) => {
return value.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
};
const formatCurrency = (value: number) => {
return `$${formatNumber(value)}`;
};
const formatPercent = (value: number) => {
return (
(value * 100).toLocaleString("en-US", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}) + "%"
);
};
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Order Metrics</CardTitle>
</CardHeader>
<CardContent>
{initialLoading || loading ? (
<div className="flex flex-col gap-2">
{/* 5 rows of skeleton metrics */}
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-baseline justify-between">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-6 w-16" />
</div>
))}
</div>
) : (
<div className="flex flex-col gap-2">
<div className="flex items-baseline justify-between">
<p className="text-sm font-medium text-muted-foreground">
Avg. Cost per PO
</p>
<p className="text-lg font-bold">
{formatCurrency(summary?.avg_cost || 0)}
</p>
</div>
<div className="flex items-baseline justify-between">
<p className="text-sm font-medium text-muted-foreground">
Overall Fulfillment Rate
</p>
<p className="text-lg font-bold">
{formatPercent(summary?.fulfillment_rate || 0)}
</p>
</div>
<div className="flex items-baseline justify-between">
<p className="text-sm font-medium text-muted-foreground">
Total Orders
</p>
<p className="text-lg font-bold">
{summary?.order_count.toLocaleString() || 0}
</p>
</div>
<div className="flex items-baseline justify-between">
<p className="text-sm font-medium text-muted-foreground">
Avg. Delivery Days
</p>
<p className="text-lg font-bold">
{summary?.avg_delivery_days ? summary.avg_delivery_days.toFixed(1) : "N/A"}
</p>
</div>
<div className="flex items-baseline justify-between">
<p className="text-sm font-medium text-muted-foreground">
Longest Delivery Days
</p>
<p className="text-lg font-bold">
{summary?.max_delivery_days ? summary.max_delivery_days.toFixed(0) : "N/A"}
</p>
</div>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,140 @@
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "../../components/ui/pagination";
interface PaginationControlsProps {
pagination: {
total: number;
pages: number;
page: number;
limit: number;
};
currentPage: number;
onPageChange: (page: number) => void;
}
export default function PaginationControls({
pagination,
currentPage,
onPageChange,
}: PaginationControlsProps) {
// Generate pagination items
const getPaginationItems = () => {
const items = [];
const totalPages = pagination.pages;
// Always show first page
if (totalPages > 0) {
items.push(
<PaginationItem key="first">
<PaginationLink
isActive={currentPage === 1}
onClick={() => currentPage !== 1 && onPageChange(1)}
>
1
</PaginationLink>
</PaginationItem>
);
}
// Add ellipsis if needed
if (currentPage > 3) {
items.push(
<PaginationItem key="ellipsis-1">
<PaginationEllipsis />
</PaginationItem>
);
}
// Add pages around current page
const startPage = Math.max(2, currentPage - 1);
const endPage = Math.min(totalPages - 1, currentPage + 1);
for (let i = startPage; i <= endPage; i++) {
if (i <= 1 || i >= totalPages) continue; // Skip first and last page as they're handled separately
items.push(
<PaginationItem key={i}>
<PaginationLink
isActive={currentPage === i}
onClick={() => currentPage !== i && onPageChange(i)}
>
{i}
</PaginationLink>
</PaginationItem>
);
}
// Add ellipsis if needed
if (currentPage < totalPages - 2) {
items.push(
<PaginationItem key="ellipsis-2">
<PaginationEllipsis />
</PaginationItem>
);
}
// Always show last page if there are multiple pages
if (totalPages > 1) {
items.push(
<PaginationItem key="last">
<PaginationLink
isActive={currentPage === totalPages}
onClick={() => currentPage !== totalPages && onPageChange(totalPages)}
>
{totalPages}
</PaginationLink>
</PaginationItem>
);
}
return items;
};
if (pagination.pages <= 1) {
return null;
}
return (
<div className="flex justify-center mb-6">
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => {
e.preventDefault();
if (currentPage > 1) onPageChange(currentPage - 1);
}}
aria-disabled={currentPage === 1}
className={currentPage === 1 ? "pointer-events-none opacity-50" : ""}
/>
</PaginationItem>
{getPaginationItems()}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => {
e.preventDefault();
if (currentPage < pagination.pages) onPageChange(currentPage + 1);
}}
aria-disabled={currentPage === pagination.pages}
className={
currentPage === pagination.pages
? "pointer-events-none opacity-50"
: ""
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
);
}

View File

@@ -0,0 +1,241 @@
import React, { useState, useEffect } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../ui/table";
import { Skeleton } from "../ui/skeleton";
// Define the structure of purchase order items
interface PurchaseOrderItem {
id: string | number;
pid: string | number;
product_name: string;
sku: string;
upc: string;
ordered: number;
received: number;
po_cost_price: number;
cost_each?: number; // For receiving items
qty_each?: number; // For receiving items
total_cost: number;
receiving_status?: string;
}
interface PurchaseOrder {
id: number | string;
vendor_name: string;
order_date: string | null;
receiving_date: string | null;
status: number;
total_items: number;
total_quantity: number;
total_cost: number;
total_received: number;
fulfillment_rate: number;
short_note: string | null;
record_type: "po_only" | "po_with_receiving" | "receiving_only";
}
interface PurchaseOrderAccordionProps {
purchaseOrder: PurchaseOrder;
children: React.ReactNode;
rowClassName?: string;
}
export default function PurchaseOrderAccordion({
purchaseOrder,
children,
rowClassName,
}: PurchaseOrderAccordionProps) {
const [isOpen, setIsOpen] = useState(false);
const [items, setItems] = useState<PurchaseOrderItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Clone the TableRow (children) and add the onClick handler and className
const enhancedRow = React.cloneElement(children as React.ReactElement, {
onClick: () => setIsOpen(!isOpen),
className: `${(children as React.ReactElement).props.className || ""} cursor-pointer ${isOpen ? 'bg-gray-100' : ''} ${rowClassName || ""}`.trim(),
"data-state": isOpen ? "open" : "closed"
});
// Format currency
const formatCurrency = (value: number) => {
return `$${value.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
};
useEffect(() => {
// Only fetch items when the accordion is open
if (!isOpen) return;
const fetchItems = async () => {
setLoading(true);
setError(null);
try {
// Endpoint path will depend on the type of record
const endpoint = purchaseOrder.record_type === "receiving_only"
? `/api/purchase-orders/receiving/${purchaseOrder.id}/items`
: `/api/purchase-orders/${purchaseOrder.id}/items`;
const response = await fetch(endpoint);
if (!response.ok) {
throw new Error(`Failed to fetch items: ${response.statusText}`);
}
const data = await response.json();
setItems(data);
} catch (err) {
console.error("Error fetching purchase order items:", err);
setError(err instanceof Error ? err.message : "Unknown error occurred");
} finally {
setLoading(false);
}
};
fetchItems();
}, [purchaseOrder.id, purchaseOrder.record_type, isOpen]);
// Render purchase order items list
const renderItemsList = () => {
if (error) {
return (
<div className="p-4 text-red-500">
Error loading items: {error}
</div>
);
}
return (
<div className="max-h-[600px] overflow-y-auto bg-gray-50 rounded-md p-2">
<Table className="w-full">
<TableHeader className="bg-white sticky top-0 z-10">
<TableRow>
<TableHead className="w-[100px]">Item Number</TableHead>
<TableHead className="w-auto">Product</TableHead>
<TableHead className="w-[100px]">UPC</TableHead>
<TableHead className="w-[80px] text-right">Ordered</TableHead>
<TableHead className="w-[80px] text-right">Received</TableHead>
<TableHead className="w-[100px] text-right">Unit Cost</TableHead>
<TableHead className="w-[100px] text-right">Total Cost</TableHead>
{purchaseOrder.record_type !== "po_only" && (
<TableHead className="w-[120px] text-right">Status</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
// Loading skeleton
Array(5).fill(0).map((_, i) => (
<TableRow key={`skeleton-${i}`}>
<TableCell><Skeleton className="h-5 w-full" /></TableCell>
<TableCell><Skeleton className="h-5 w-full" /></TableCell>
<TableCell><Skeleton className="h-5 w-full" /></TableCell>
<TableCell><Skeleton className="h-5 w-full" /></TableCell>
<TableCell><Skeleton className="h-5 w-full" /></TableCell>
<TableCell><Skeleton className="h-5 w-full" /></TableCell>
<TableCell><Skeleton className="h-5 w-full" /></TableCell>
{purchaseOrder.record_type !== "po_only" && (
<TableCell><Skeleton className="h-5 w-full" /></TableCell>
)}
</TableRow>
))
) : (
items.map((item) => (
<TableRow key={item.id} className="hover:bg-gray-100">
<TableCell className="">
<a
href={`https://backend.acherryontop.com/product/${item.pid}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
onClick={(e) => e.stopPropagation()}
>
{item.sku}
</a>
</TableCell>
<TableCell className="font-medium">
<a
href={`https://backend.acherryontop.com/product/${item.pid}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
onClick={(e) => e.stopPropagation()}
>
{item.product_name}
</a>
</TableCell>
<TableCell className="">
<a
href={`https://backend.acherryontop.com/product/${item.pid}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
onClick={(e) => e.stopPropagation()}
>
{item.upc}
</a>
</TableCell>
<TableCell className="text-right">
{item.ordered}
</TableCell>
<TableCell className="text-right">
{item.received || 0}
</TableCell>
<TableCell className="text-right">
{formatCurrency(item.po_cost_price || item.cost_each || 0)}
</TableCell>
<TableCell className="text-right">
{formatCurrency(item.total_cost)}
</TableCell>
{purchaseOrder.record_type !== "po_only" && (
<TableCell className="text-right">
{item.receiving_status || "Unknown"}
</TableCell>
)}
</TableRow>
))
)}
{!loading && items.length === 0 && (
<TableRow>
<TableCell colSpan={purchaseOrder.record_type === "po_only" ? 7 : 8} className="text-center py-4 text-muted-foreground">
No items found for this order
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
};
return (
<>
{/* First render the row which will serve as the trigger */}
{enhancedRow}
{/* Then render the accordion content conditionally if open */}
{isOpen && (
<TableRow className="p-0 border-0">
<TableCell colSpan={12} className="p-0 border-0">
<div className="pt-2 pb-4 px-4 bg-gray-50 border-t border-b">
<div className="mb-2 text-sm text-muted-foreground">
{purchaseOrder.total_items} product{purchaseOrder.total_items !== 1 ? "s" : ""} in this {purchaseOrder.record_type === "receiving_only" ? "receiving" : "purchase order"}
</div>
{renderItemsList()}
</div>
</TableCell>
</TableRow>
)}
</>
);
}

View File

@@ -0,0 +1,436 @@
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../ui/table";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import { Skeleton } from "../ui/skeleton";
import { FileText } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../ui/tooltip";
import {
getPurchaseOrderStatusLabel,
getReceivingStatusLabel,
getPurchaseOrderStatusVariant,
getReceivingStatusVariant,
} from "../../types/status-codes";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../ui/card";
import PurchaseOrderAccordion from "./PurchaseOrderAccordion";
interface PurchaseOrder {
id: number | string;
vendor_name: string;
order_date: string | null;
receiving_date: string | null;
status: number;
total_items: number;
total_quantity: number;
total_cost: number;
total_received: number;
fulfillment_rate: number;
short_note: string | null;
record_type: "po_only" | "po_with_receiving" | "receiving_only";
}
interface PurchaseOrdersTableProps {
purchaseOrders: PurchaseOrder[];
loading: boolean;
summary: { order_count: number } | null;
sortColumn: string;
sortDirection: "asc" | "desc";
handleSort: (column: string) => void;
}
export default function PurchaseOrdersTable({
purchaseOrders,
loading,
summary,
sortColumn,
sortDirection,
handleSort
}: PurchaseOrdersTableProps) {
// Helper functions
const getRecordTypeIndicator = (recordType: string) => {
switch (recordType) {
case "po_with_receiving":
return (
<Badge
variant="outline"
className="flex items-center justify-center border-green-500 text-green-700 bg-green-50 px-0 text-xs w-[85px]"
>
Received PO
</Badge>
);
case "po_only":
return (
<Badge
variant="outline"
className="flex items-center justify-center border-blue-500 text-blue-700 bg-blue-50 px-0 text-xs w-[85px]"
>
PO
</Badge>
);
case "receiving_only":
return (
<Badge
variant="outline"
className="flex items-center justify-center border-amber-500 text-amber-700 bg-amber-50 px-0 text-xs w-[85px]"
>
Receiving
</Badge>
);
default:
return (
<Badge
variant="outline"
className="flex items-center justify-center border-gray-500 text-gray-700 bg-gray-50 px-0 text-xs w-[85px]"
>
{recordType || "Unknown"}
</Badge>
);
}
};
const getStatusBadge = (status: number, recordType: string) => {
if (recordType === "receiving_only") {
return (
<Badge
className="w-[115px] flex items-center text-xs justify-center px-1"
variant={getReceivingStatusVariant(status)}
>
{getReceivingStatusLabel(status)}
</Badge>
);
}
return (
<Badge
className="w-[115px] flex items-center text-xs justify-center px-1"
variant={getPurchaseOrderStatusVariant(status)}
>
{getPurchaseOrderStatusLabel(status)}
</Badge>
);
};
const formatNumber = (value: number) => {
return value.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
};
const formatCurrency = (value: number) => {
return `$${formatNumber(value)}`;
};
const formatPercent = (value: number) => {
return (
(value * 100).toLocaleString("en-US", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}) + "%"
);
};
// Update sort indicators in table headers
const getSortIndicator = (column: string) => {
if (sortColumn !== column) return null;
return sortDirection === "asc" ? " ↑" : " ↓";
};
return (
<Card className="mb-6">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Purchase Orders & Receivings</CardTitle>
<div className="text-sm text-muted-foreground">
{loading ? (
<Skeleton className="h-4 w-24" />
) : (
`${summary?.order_count.toLocaleString()} orders`
)}
</div>
</CardHeader>
<CardContent>
<Table
className="table-fixed"
style={{ tableLayout: "fixed", width: "100%"}}
>
<TableHeader>
<TableRow>
<TableHead className="w-[100px] text-center">Type</TableHead>
<TableHead className="w-[60px] text-center">
<Button
className="w-full"
variant="ghost"
onClick={() => !loading && handleSort("id")}
disabled={loading}
>
ID{getSortIndicator("id")}
</Button>
</TableHead>
<TableHead className="w-[140px] text-center">
<Button
className="w-full"
variant="ghost"
onClick={() => !loading && handleSort("vendor_name")}
disabled={loading}
>
Supplier{getSortIndicator("vendor_name")}
</Button>
</TableHead>
<TableHead className="w-[115px] text-center">
<Button
className="w-full"
variant="ghost"
onClick={() => !loading && handleSort("status")}
disabled={loading}
>
Status{getSortIndicator("status")}
</Button>
</TableHead>
<TableHead className="w-[150px] text-center">Note</TableHead>
<TableHead className="w-[90px] text-center">
<Button
className="w-full"
variant="ghost"
onClick={() => !loading && handleSort("total_cost")}
disabled={loading}
>
Total Cost{getSortIndicator("total_cost")}
</Button>
</TableHead>
<TableHead className="w-[70px] text-center">
<Button
className="w-full"
variant="ghost"
onClick={() => !loading && handleSort("total_items")}
disabled={loading}
>
Products{getSortIndicator("total_items")}
</Button>
</TableHead>
<TableHead className="w-[90px] text-center">
<Button
className="w-full"
variant="ghost"
onClick={() => !loading && handleSort("order_date")}
disabled={loading}
>
Order Date{getSortIndicator("order_date")}
</Button>
</TableHead>
<TableHead className="w-[90px] text-center">
<Button
className="w-full"
variant="ghost"
onClick={() => !loading && handleSort("receiving_date")}
disabled={loading}
>
Rec'd Date{getSortIndicator("receiving_date")}
</Button>
</TableHead>
<TableHead className="w-[70px] text-center">
<Button
className="w-full"
variant="ghost"
onClick={() => !loading && handleSort("total_quantity")}
disabled={loading}
>
Ordered{getSortIndicator("total_quantity")}
</Button>
</TableHead>
<TableHead className="w-[80px] text-center">
<Button
className="w-full"
variant="ghost"
onClick={() => !loading && handleSort("total_received")}
disabled={loading}
>
Received{getSortIndicator("total_received")}
</Button>
</TableHead>
<TableHead className="w-[80px] text-center">
<Button
className="w-full"
variant="ghost"
onClick={() => !loading && handleSort("fulfillment_rate")}
disabled={loading}
>
% Fulfilled{getSortIndicator("fulfillment_rate")}
</Button>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
// Skeleton rows for loading state
Array(50)
.fill(0)
.map((_, index) => (
<TableRow key={`skeleton-${index}`}>
<TableCell className="w-[100px]">
<Skeleton className="h-6 w-full" />
</TableCell>
<TableCell className="w-[60px]">
<Skeleton className="h-5 w-full" />
</TableCell>
<TableCell className="w-[140px]">
<Skeleton className="h-5 w-full" />
</TableCell>
<TableCell className="w-[115px]">
<Skeleton className="h-6 w-full" />
</TableCell>
<TableCell className="w-[150px]">
<Skeleton className="h-5 w-full" />
</TableCell>
<TableCell className="w-[90px]">
<Skeleton className="h-5 w-full" />
</TableCell>
<TableCell className="w-[70px]">
<Skeleton className="h-5 w-full" />
</TableCell>
<TableCell className="w-[90px]">
<Skeleton className="h-5 w-full" />
</TableCell>
<TableCell className="w-[90px]">
<Skeleton className="h-5 w-full" />
</TableCell>
<TableCell className="w-[70px]">
<Skeleton className="h-5 w-full" />
</TableCell>
<TableCell className="w-[80px]">
<Skeleton className="h-5 w-full" />
</TableCell>
<TableCell className="w-[80px]">
<Skeleton className="h-5 w-full" />
</TableCell>
</TableRow>
))
) : purchaseOrders.length > 0 ? (
purchaseOrders.map((po) => {
// Determine row styling based on record type
let rowClassName = "border-l-4 border-l-gray-300"; // Default
if (po.record_type === "po_with_receiving") {
rowClassName = "border-l-4 border-l-green-500";
} else if (po.record_type === "po_only") {
rowClassName = "border-l-4 border-l-blue-500";
} else if (po.record_type === "receiving_only") {
rowClassName = "border-l-4 border-l-amber-500";
}
return (
<PurchaseOrderAccordion
key={`${po.id}-${po.record_type}`}
purchaseOrder={po}
rowClassName={rowClassName}
>
<TableRow>
<TableCell className="text-center">
{getRecordTypeIndicator(po.record_type)}
</TableCell>
<TableCell className="font-semibold text-center">
<a
href={po.record_type === "po_only"
? `https://backend.acherryontop.com/po/edit/${po.id}`
: `https://backend.acherryontop.com/receiving/edit/${po.id}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{po.id}
</a>
</TableCell>
<TableCell>{po.vendor_name}</TableCell>
<TableCell>
{getStatusBadge(po.status, po.record_type)}
</TableCell>
<TableCell className="truncate text-center">
{po.short_note ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="text-left flex items-center gap-1">
<FileText className="h-3 w-3" />
<span className="truncate">
{po.short_note}
</span>
</TooltipTrigger>
<TooltipContent>
<p>{po.short_note}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
""
)}
</TableCell>
<TableCell>{formatCurrency(po.total_cost)}</TableCell>
<TableCell className="text-center">{po.total_items.toLocaleString()}</TableCell>
<TableCell className="text-center">
{po.order_date
? new Date(po.order_date).toLocaleDateString(
"en-US",
{
month: "numeric",
day: "numeric",
year: "numeric",
}
)
: "-"}
</TableCell>
<TableCell className="text-center">
{po.receiving_date
? new Date(po.receiving_date).toLocaleDateString(
"en-US",
{
month: "numeric",
day: "numeric",
year: "numeric",
}
)
: "-"}
</TableCell>
<TableCell className="text-center">
{po.record_type === "receiving_only" ? "-" : po.total_quantity.toLocaleString()}
</TableCell>
<TableCell className="text-center">
{po.record_type === "po_only" ? "-" : po.total_received.toLocaleString()}
</TableCell>
<TableCell className="text-center" >
{po.record_type === "po_with_receiving"
? (po.fulfillment_rate === null ? "N/A" : formatPercent(po.fulfillment_rate))
: "-"}
</TableCell>
</TableRow>
</PurchaseOrderAccordion>
);
})
) : (
<TableRow>
<TableCell
colSpan={12}
className="text-center text-muted-foreground"
>
No purchase orders found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,354 @@
import { useState, useEffect } from "react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { Skeleton } from "../../components/ui/skeleton";
import { BarChart3, Loader2 } from "lucide-react";
import { Button } from "../../components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../components/ui/dialog";
import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table";
// Add this constant for pie chart colors
const COLORS = [
"#0088FE",
"#00C49F",
"#FFBB28",
"#FF8042",
"#8884D8",
"#82CA9D",
"#FFC658",
"#FF7C43",
];
// The renderActiveShape function for pie charts
const renderActiveShape = (props: any) => {
const {
cx,
cy,
innerRadius,
outerRadius,
startAngle,
endAngle,
fill,
category,
total_spend,
} = props;
// Split category name into words and create lines of max 12 chars
const words = category.split(" ");
const lines: string[] = [];
let currentLine = "";
words.forEach((word: string) => {
if ((currentLine + " " + word).length <= 12) {
currentLine = currentLine ? `${currentLine} ${word}` : word;
} else {
if (currentLine) lines.push(currentLine);
currentLine = word;
}
});
if (currentLine) lines.push(currentLine);
return (
<g>
<Sector
cx={cx}
cy={cy}
innerRadius={innerRadius}
outerRadius={outerRadius}
startAngle={startAngle}
endAngle={endAngle}
fill={fill}
/>
<Sector
cx={cx}
cy={cy}
startAngle={startAngle}
endAngle={endAngle}
innerRadius={outerRadius - 1}
outerRadius={outerRadius + 4}
fill={fill}
/>
{lines.map((line, i) => (
<text
key={i}
x={cx}
y={cy}
dy={-20 + i * 16}
textAnchor="middle"
fill="#888888"
className="text-xs"
>
{line}
</text>
))}
<text
x={cx}
y={cy}
dy={lines.length * 16 - 10}
textAnchor="middle"
fill="#000000"
className="text-base font-medium"
>
{`$${Number(total_spend).toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`}
</text>
</g>
);
};
interface VendorMetricsCardProps {
loading: boolean;
yearlyVendorData: {
vendor: string;
orders: number;
total_spend: number;
percentage?: number;
}[];
yearlyDataLoading: boolean;
}
export default function VendorMetricsCard({
loading,
yearlyVendorData,
yearlyDataLoading,
}: VendorMetricsCardProps) {
const [vendorAnalysisOpen, setVendorAnalysisOpen] = useState(false);
const [activeVendorIndex, setActiveVendorIndex] = useState<
number | undefined
>();
const [initialLoading, setInitialLoading] = useState(true);
// Only show loading state on initial load, not during table refreshes
useEffect(() => {
if (yearlyVendorData.length > 0 && !yearlyDataLoading) {
setInitialLoading(false);
}
}, [yearlyVendorData, yearlyDataLoading]);
const formatNumber = (value: number) => {
return value.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
};
const formatCurrency = (value: number) => {
return `$${formatNumber(value)}`;
};
const formatPercent = (value: number) => {
return (
(value * 100).toLocaleString("en-US", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}) + "%"
);
};
// Prepare vendor chart data
const prepareVendorChartData = () => {
if (!yearlyVendorData.length) return [];
// Make a copy to avoid modifying state directly
const vendorArray = [...yearlyVendorData];
const totalSpend = vendorArray.reduce(
(sum, vendor) => sum + vendor.total_spend,
0
);
// Split into significant vendors (>=1%) and others
const significantVendors = vendorArray.filter(
(vendor) => vendor.total_spend / totalSpend >= 0.01
);
const otherVendors = vendorArray.filter(
(vendor) => vendor.total_spend / totalSpend < 0.01
);
let result = [...significantVendors];
// Add "Other" category if needed
if (otherVendors.length > 0) {
const otherTotalSpend = otherVendors.reduce(
(sum, vendor) => sum + vendor.total_spend,
0
);
result.push({
vendor: "Other Vendors",
total_spend: otherTotalSpend,
percentage: otherTotalSpend / totalSpend,
orders: otherVendors.reduce((sum, vendor) => sum + vendor.orders, 0),
});
}
// Sort by spend amount descending
return result.sort((a, b) => b.total_spend - a.total_spend);
};
// Get all vendors for table
const getAllVendorsForTable = () => {
if (!yearlyVendorData.length) return [];
return [...yearlyVendorData].sort((a, b) => b.total_spend - a.total_spend);
};
// Vendor analysis table component
const VendorAnalysisTable = () => {
const vendorData = getAllVendorsForTable();
if (!vendorData.length) {
return yearlyDataLoading ? (
<div className="flex justify-center p-4">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
) : (
<div className="text-center p-4 text-muted-foreground">
No supplier data available for the past 12 months
</div>
);
}
return (
<div>
{yearlyDataLoading ? (
<div className="flex justify-center p-4">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
) : (
<>
<div className="text-sm font-medium mb-2 flex justify-between items-center px-4">
<span>
Showing received inventory by supplier for the past 12 months
</span>
<span>{vendorData.length} suppliers found</span>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Supplier</TableHead>
<TableHead>Orders</TableHead>
<TableHead>Total Spend</TableHead>
<TableHead>% of Total</TableHead>
<TableHead>Avg. Order Value</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{vendorData.map((vendor) => {
return (
<TableRow key={vendor.vendor}>
<TableCell className="font-medium">
{vendor.vendor}
</TableCell>
<TableCell>{vendor.orders.toLocaleString()}</TableCell>
<TableCell>
{formatCurrency(vendor.total_spend)}
</TableCell>
<TableCell>
{formatPercent(vendor.percentage || 0)}
</TableCell>
<TableCell>
{formatCurrency(
vendor.orders ? vendor.total_spend / vendor.orders : 0
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</>
)}
</div>
);
};
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Received by Supplier
</CardTitle>
<Dialog
open={vendorAnalysisOpen}
onOpenChange={setVendorAnalysisOpen}
>
<DialogTrigger asChild>
<Button variant="outline" disabled={initialLoading || loading}>
<BarChart3 className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-[90%] w-fit">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5" />
<span>Received Inventory by Supplier</span>
</DialogTitle>
</DialogHeader>
<div className="overflow-auto max-h-[70vh]">
<VendorAnalysisTable />
</div>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent>
{initialLoading || loading ? (
<div className="flex flex-col items-center justify-center h-[170px]">
<Skeleton className="h-[170px] w-[170px] rounded-full" />
</div>
) : (
<>
<div className="h-[170px] relative">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={prepareVendorChartData()}
dataKey="total_spend"
nameKey="vendor"
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
paddingAngle={1}
activeIndex={activeVendorIndex}
activeShape={(props: any) =>
renderActiveShape({ ...props, category: props.vendor })
}
onMouseEnter={(_, index) => setActiveVendorIndex(index)}
onMouseLeave={() => setActiveVendorIndex(undefined)}
>
{prepareVendorChartData().map((entry, index) => (
<Cell
key={entry.vendor}
fill={COLORS[index % COLORS.length]}
/>
))}
</Pie>
</PieChart>
</ResponsiveContainer>
</div>
</>
)}
</CardContent>
</Card>
);
}

View File

@@ -801,7 +801,7 @@ export function DataManagement() {
); );
return ( return (
<Card className="md:col-start-2 md:row-span-2 h-[550px]"> <Card className="md:col-start-2 md:row-span-2 h-[580px]">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle>Table Record Counts</CardTitle> <CardTitle>Table Record Counts</CardTitle>
</CardHeader> </CardHeader>
@@ -953,7 +953,7 @@ export function DataManagement() {
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
{/* Table Status */} {/* Table Status */}
<div className="space-y-4 flex flex-col h-[550px]"> <div className="space-y-4 flex flex-col h-[580px]">
<Card className="flex-1"> <Card className="flex-1">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle>Last Import Times</CardTitle> <CardTitle>Last Import Times</CardTitle>

View File

@@ -1,50 +1,31 @@
import { useEffect, useState } from 'react'; import { useEffect, useState, useRef, useMemo } from "react";
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card'; import OrderMetricsCard from "../components/purchase-orders/OrderMetricsCard";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../components/ui/table'; import VendorMetricsCard from "../components/purchase-orders/VendorMetricsCard";
import { Loader2, ArrowUpDown } from 'lucide-react'; import CategoryMetricsCard from "../components/purchase-orders/CategoryMetricsCard";
import { Button } from '../components/ui/button'; import PaginationControls from "../components/purchase-orders/PaginationControls";
import { Input } from '../components/ui/input'; import PurchaseOrdersTable from "../components/purchase-orders/PurchaseOrdersTable";
import { Badge } from '../components/ui/badge'; import FilterControls from "../components/purchase-orders/FilterControls";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../components/ui/select';
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationNext,
PaginationPrevious,
} from '../components/ui/pagination';
import { motion } from 'motion/react';
import {
PurchaseOrderStatus,
getPurchaseOrderStatusLabel,
getReceivingStatusLabel,
getPurchaseOrderStatusVariant,
getReceivingStatusVariant
} from '../types/status-codes';
interface PurchaseOrder { interface PurchaseOrder {
id: number; id: number | string;
vendor_name: string; vendor_name: string;
order_date: string; order_date: string | null;
receiving_date: string | null;
status: number; status: number;
receiving_status: number;
total_items: number; total_items: number;
total_quantity: number; total_quantity: number;
total_cost: number; total_cost: number;
total_received: number; total_received: number;
fulfillment_rate: number; fulfillment_rate: number;
short_note: string | null;
record_type: "po_only" | "po_with_receiving" | "receiving_only";
} }
interface VendorMetrics { interface VendorMetrics {
vendor_name: string; vendor_name: string;
total_orders: number; total_orders: number;
avg_delivery_days: number; avg_delivery_days: number;
max_delivery_days: number;
fulfillment_rate: number; fulfillment_rate: number;
avg_unit_cost: number; avg_unit_cost: number;
total_spend: number; total_spend: number;
@@ -59,6 +40,9 @@ interface CostAnalysis {
total_spend_by_category: { total_spend_by_category: {
category: string; category: string;
total_spend: number; total_spend: number;
unique_products?: number;
avg_cost?: number;
cost_variance?: number;
}[]; }[];
} }
@@ -69,50 +53,33 @@ interface ReceivingStatus {
fulfillment_rate: number; fulfillment_rate: number;
total_value: number; total_value: number;
avg_cost: number; avg_cost: number;
avg_delivery_days?: number;
max_delivery_days?: number;
} }
interface PurchaseOrdersResponse {
orders: PurchaseOrder[];
summary: {
order_count: number;
total_ordered: number;
total_received: number;
fulfillment_rate: number;
total_value: number;
avg_cost: number;
};
pagination: {
total: number;
pages: number;
page: number;
limit: number;
};
filters: {
vendors: string[];
statuses: string[];
};
}
export default function PurchaseOrders() { export default function PurchaseOrders() {
const [purchaseOrders, setPurchaseOrders] = useState<PurchaseOrder[]>([]); const [purchaseOrders, setPurchaseOrders] = useState<PurchaseOrder[]>([]);
const [, setVendorMetrics] = useState<VendorMetrics[]>([]); const [, setVendorMetrics] = useState<VendorMetrics[]>([]);
const [costAnalysis, setCostAnalysis] = useState<CostAnalysis | null>(null); const [, setCostAnalysis] = useState<CostAnalysis | null>(null);
const [summary, setSummary] = useState<ReceivingStatus | null>(null); const [summary, setSummary] = useState<ReceivingStatus | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [sortColumn, setSortColumn] = useState<string>('order_date'); const [sortColumn, setSortColumn] = useState<string>("order_date");
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
const [filters, setFilters] = useState({ const [searchInput, setSearchInput] = useState("");
search: '', const [filterValues, setFilterValues] = useState({
status: 'all', search: "",
vendor: 'all', status: "all",
vendor: "all",
recordType: "all",
}); });
const [filterOptions, setFilterOptions] = useState<{ const [filterOptions, setFilterOptions] = useState<{
vendors: string[]; vendors: string[];
statuses: string[]; statuses: number[];
}>({ }>({
vendors: [], vendors: [],
statuses: [] statuses: [],
}); });
const [pagination, setPagination] = useState({ const [pagination, setPagination] = useState({
total: 0, total: 0,
@@ -120,99 +87,173 @@ export default function PurchaseOrders() {
page: 1, page: 1,
limit: 100, limit: 100,
}); });
const [] = useState(false);
const [] = useState<
number | undefined
>();
const [] = useState<
number | undefined
>();
const [] = useState(false);
const [yearlyVendorData, setYearlyVendorData] = useState<
{
vendor: string;
orders: number;
total_spend: number;
percentage?: number;
}[]
>([]);
const [yearlyCategoryData, setYearlyCategoryData] = useState<
{
category: string;
unique_products?: number;
total_spend: number;
percentage?: number;
avg_cost?: number;
cost_variance?: number;
}[]
>([]);
const [yearlyDataLoading, setYearlyDataLoading] = useState(false);
const hasInitialFetchRef = useRef(false);
const hasInitialYearlyFetchRef = useRef(false);
const STATUS_FILTER_OPTIONS = [ // Use useMemo to compute filters only when filterValues change
{ value: 'all', label: 'All Statuses' }, const filters = useMemo(() => filterValues, [filterValues]);
{ value: String(PurchaseOrderStatus.Created), label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.Created) },
{ value: String(PurchaseOrderStatus.ElectronicallyReadySend), label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.ElectronicallyReadySend) },
{ value: String(PurchaseOrderStatus.Ordered), label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.Ordered) },
{ value: String(PurchaseOrderStatus.ReceivingStarted), label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.ReceivingStarted) },
{ value: String(PurchaseOrderStatus.Done), label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.Done) },
{ value: String(PurchaseOrderStatus.Canceled), label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.Canceled) },
];
const fetchData = async () => { const fetchData = async () => {
try { try {
const searchParams = new URLSearchParams({ setLoading(true);
page: page.toString(),
limit: '100',
sortColumn,
sortDirection,
...filters.search && { search: filters.search },
...filters.status && { status: filters.status },
...filters.vendor && { vendor: filters.vendor },
});
const [ // Build search params with proper encoding
purchaseOrdersRes, const searchParams = new URLSearchParams();
vendorMetricsRes, searchParams.append('page', page.toString());
costAnalysisRes searchParams.append('limit', '100');
] = await Promise.all([ searchParams.append('sortColumn', sortColumn);
fetch(`/api/purchase-orders?${searchParams}`), searchParams.append('sortDirection', sortDirection);
fetch('/api/purchase-orders/vendor-metrics'),
fetch('/api/purchase-orders/cost-analysis') if (filters.search) {
searchParams.append('search', filters.search);
}
if (filters.status !== 'all') {
searchParams.append('status', filters.status);
}
if (filters.vendor !== 'all') {
searchParams.append('vendor', filters.vendor);
}
if (filters.recordType !== 'all') {
searchParams.append('recordType', filters.recordType);
}
console.log("Fetching data with params:", searchParams.toString());
// Fetch orders first separately to handle errors better
const purchaseOrdersRes = await fetch(`/api/purchase-orders?${searchParams.toString()}`);
if (!purchaseOrdersRes.ok) {
const errorText = await purchaseOrdersRes.text();
console.error("Failed to fetch purchase orders:", errorText);
throw new Error(`Failed to fetch purchase orders: ${errorText}`);
}
const purchaseOrdersData = await purchaseOrdersRes.json();
// Process orders data immediately
const processedOrders = purchaseOrdersData.orders.map((order: any) => ({
...order,
status: Number(order.status),
total_items: Number(order.total_items) || 0,
total_quantity: Number(order.total_quantity) || 0,
total_cost: Number(order.total_cost) || 0,
total_received: Number(order.total_received) || 0,
fulfillment_rate: Number(order.fulfillment_rate) || 0,
}));
// Update the main data state
setPurchaseOrders(processedOrders);
setPagination(purchaseOrdersData.pagination);
setFilterOptions(purchaseOrdersData.filters);
// Now fetch the additional data in parallel
const [vendorMetricsRes, costAnalysisRes, deliveryMetricsRes] = await Promise.all([
fetch("/api/purchase-orders/vendor-metrics"),
fetch("/api/purchase-orders/cost-analysis"),
fetch("/api/purchase-orders/delivery-metrics"),
]); ]);
// Initialize default data let vendorMetricsData = [];
let purchaseOrdersData: PurchaseOrdersResponse = { let costAnalysisData = {
orders: [],
summary: {
order_count: 0,
total_ordered: 0,
total_received: 0,
fulfillment_rate: 0,
total_value: 0,
avg_cost: 0
},
pagination: {
total: 0,
pages: 0,
page: 1,
limit: 100
},
filters: {
vendors: [],
statuses: []
}
};
let vendorMetricsData: VendorMetrics[] = [];
let costAnalysisData: CostAnalysis = {
unique_products: 0, unique_products: 0,
avg_cost: 0, avg_cost: 0,
min_cost: 0, min_cost: 0,
max_cost: 0, max_cost: 0,
cost_variance: 0, cost_variance: 0,
total_spend_by_category: [] total_spend_by_category: [],
}; };
// Only try to parse responses if they were successful let deliveryMetricsData = {
if (purchaseOrdersRes.ok) { avg_delivery_days: 0,
purchaseOrdersData = await purchaseOrdersRes.json(); max_delivery_days: 0
} else { };
console.error('Failed to fetch purchase orders:', await purchaseOrdersRes.text());
}
if (vendorMetricsRes.ok) { if (vendorMetricsRes.ok) {
vendorMetricsData = await vendorMetricsRes.json(); vendorMetricsData = await vendorMetricsRes.json();
setVendorMetrics(vendorMetricsData);
} else { } else {
console.error('Failed to fetch vendor metrics:', await vendorMetricsRes.text()); console.error(
"Failed to fetch vendor metrics:",
await vendorMetricsRes.text()
);
setVendorMetrics([]);
} }
if (costAnalysisRes.ok) { if (costAnalysisRes.ok) {
costAnalysisData = await costAnalysisRes.json(); costAnalysisData = await costAnalysisRes.json();
setCostAnalysis(costAnalysisData);
} else { } else {
console.error('Failed to fetch cost analysis:', await costAnalysisRes.text()); console.error(
"Failed to fetch cost analysis:",
await costAnalysisRes.text()
);
setCostAnalysis({
unique_products: 0,
avg_cost: 0,
min_cost: 0,
max_cost: 0,
cost_variance: 0,
total_spend_by_category: [],
});
} }
setPurchaseOrders(purchaseOrdersData.orders); if (deliveryMetricsRes.ok) {
setPagination(purchaseOrdersData.pagination); deliveryMetricsData = await deliveryMetricsRes.json();
setFilterOptions(purchaseOrdersData.filters);
setSummary(purchaseOrdersData.summary); // Merge delivery metrics into summary
setVendorMetrics(vendorMetricsData); const summaryWithDelivery = {
setCostAnalysis(costAnalysisData); ...purchaseOrdersData.summary,
avg_delivery_days: deliveryMetricsData.avg_delivery_days,
max_delivery_days: deliveryMetricsData.max_delivery_days
};
setSummary(summaryWithDelivery);
} else {
console.error(
"Failed to fetch delivery metrics:",
await deliveryMetricsRes.text()
);
setSummary({
...purchaseOrdersData.summary,
avg_delivery_days: 0,
max_delivery_days: 0
});
}
// Mark that we've completed an initial fetch
hasInitialFetchRef.current = true;
} catch (error) { } catch (error) {
console.error('Error fetching data:', error); console.error("Error fetching data:", error);
// Set default values in case of error // Set default values in case of error
setPurchaseOrders([]); setPurchaseOrders([]);
setPagination({ total: 0, pages: 0, page: 1, limit: 100 }); setPagination({ total: 0, pages: 0, page: 1, limit: 100 });
@@ -223,7 +264,7 @@ export default function PurchaseOrders() {
total_received: 0, total_received: 0,
fulfillment_rate: 0, fulfillment_rate: 0,
total_value: 0, total_value: 0,
avg_cost: 0 avg_cost: 0,
}); });
setVendorMetrics([]); setVendorMetrics([]);
setCostAnalysis({ setCostAnalysis({
@@ -232,284 +273,209 @@ export default function PurchaseOrders() {
min_cost: 0, min_cost: 0,
max_cost: 0, max_cost: 0,
cost_variance: 0, cost_variance: 0,
total_spend_by_category: [] total_spend_by_category: [],
}); });
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
// Setup debounced search
useEffect(() => { useEffect(() => {
fetchData(); const timer = setTimeout(() => {
}, [page, sortColumn, sortDirection, filters]); if (searchInput !== filterValues.search) {
setFilterValues(prev => ({ ...prev, search: searchInput }));
}
}, 300); // Use 300ms for better response time
return () => clearTimeout(timer);
}, [searchInput, filterValues.search]);
// Reset page to 1 when filters change
useEffect(() => {
// Reset to page 1 when filters change to ensure proper pagination
setPage(1);
}, [filterValues]); // Use filterValues directly to avoid unnecessary renders
// Fetch data when page, sort or filters change
useEffect(() => {
// Log the current filter state for debugging
console.log("Fetching with filters:", filterValues);
console.log("Page:", page, "Sort:", sortColumn, sortDirection);
// Always fetch data - don't use conditional checks that might prevent it
fetchData();
}, [page, sortColumn, sortDirection, filterValues]);
// Handle column sorting more consistently
const handleSort = (column: string) => { const handleSort = (column: string) => {
// Reset to page 1 when changing sort to ensure we see the first page of results
setPage(1);
if (sortColumn === column) { if (sortColumn === column) {
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc'); setSortDirection((prev) => (prev === "asc" ? "desc" : "asc"));
} else { } else {
setSortColumn(column); setSortColumn(column);
setSortDirection('asc'); // For most columns, start with descending to show highest values first
if (column === 'id' || column === 'vendor_name') {
setSortDirection("asc");
} else {
setSortDirection("desc");
}
} }
}; };
const getStatusBadge = (status: number, receivingStatus: number) => { // Update filter handlers
// If the PO is canceled, show that status const handleStatusChange = (value: string) => {
if (status === PurchaseOrderStatus.Canceled) { setFilterValues(prev => ({ ...prev, status: value }));
return <Badge variant={getPurchaseOrderStatusVariant(status)}>
{getPurchaseOrderStatusLabel(status)}
</Badge>;
}
// If receiving has started, show receiving status
if (status >= PurchaseOrderStatus.ReceivingStarted) {
return <Badge variant={getReceivingStatusVariant(receivingStatus)}>
{getReceivingStatusLabel(receivingStatus)}
</Badge>;
}
// Otherwise show PO status
return <Badge variant={getPurchaseOrderStatusVariant(status)}>
{getPurchaseOrderStatusLabel(status)}
</Badge>;
}; };
const formatNumber = (value: number) => { const handleVendorChange = (value: string) => {
return value.toLocaleString('en-US', { setFilterValues(prev => ({ ...prev, vendor: value }));
minimumFractionDigits: 2, };
maximumFractionDigits: 2
const handleRecordTypeChange = (value: string) => {
setFilterValues(prev => ({ ...prev, recordType: value }));
};
// Clear all filters handler
const clearFilters = () => {
setSearchInput("");
setFilterValues({
search: "",
status: "all",
vendor: "all",
recordType: "all",
}); });
}; };
const formatPercent = (value: number) => { // Update this function to fetch yearly data
return (value * 100).toLocaleString('en-US', { const fetchYearlyData = async () => {
minimumFractionDigits: 1, if (
maximumFractionDigits: 1 hasInitialYearlyFetchRef.current &&
}) + '%'; import.meta.hot &&
}; (yearlyVendorData.length > 0 || yearlyCategoryData.length > 0)
) {
return;
}
if (loading) { try {
return ( setYearlyDataLoading(true);
<div className="flex h-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin" /> // Create a date for 1 year ago
</div> const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
const dateParam = oneYearAgo.toISOString().split("T")[0]; // Format as YYYY-MM-DD
const [vendorResponse, categoryResponse] = await Promise.all([
fetch(`/api/purchase-orders/vendor-analysis?since=${dateParam}`),
fetch(`/api/purchase-orders/category-analysis?since=${dateParam}`),
]);
if (vendorResponse.ok) {
const vendorData = await vendorResponse.json();
// Calculate percentages before setting state
const totalSpend = vendorData.reduce(
(sum: number, v: any) => sum + v.total_spend,
0
);
setYearlyVendorData(
vendorData.map((v: any) => ({
...v,
percentage: totalSpend > 0 ? v.total_spend / totalSpend : 0,
}))
);
} else {
console.error(
"Failed to fetch yearly vendor data:",
await vendorResponse.text()
); );
} }
if (categoryResponse.ok) {
const categoryData = await categoryResponse.json();
// Calculate percentages before setting state
const totalSpend = categoryData.reduce(
(sum: number, c: any) => sum + c.total_spend,
0
);
setYearlyCategoryData(
categoryData.map((c: any) => ({
...c,
percentage: totalSpend > 0 ? c.total_spend / totalSpend : 0,
}))
);
} else {
console.error(
"Failed to fetch yearly category data:",
await categoryResponse.text()
);
}
// Mark that we've completed an initial fetch
hasInitialYearlyFetchRef.current = true;
} catch (error) {
console.error("Error fetching yearly data:", error);
} finally {
setYearlyDataLoading(false);
}
};
// Fetch yearly data when component mounts, not just when dialogs open
useEffect(() => {
fetchYearlyData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return ( return (
<motion.div layout className="container mx-auto py-6"> <div className="container mx-auto py-6">
<h1 className="mb-6 text-3xl font-bold">Purchase Orders</h1> <h1 className="mb-6 text-3xl font-bold">Purchase Orders</h1>
{/* Metrics Overview */} <div className="mb-4 grid gap-4 md:grid-cols-3">
<div className="mb-6 grid gap-4 md:grid-cols-4"> <OrderMetricsCard
<Card> summary={summary}
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> loading={loading}
<CardTitle className="text-sm font-medium">Total Orders</CardTitle> />
</CardHeader> <VendorMetricsCard
<CardContent> loading={loading}
<div className="text-2xl font-bold">{summary?.order_count.toLocaleString() || 0}</div> yearlyVendorData={yearlyVendorData}
</CardContent> yearlyDataLoading={yearlyDataLoading}
</Card> />
<Card> <CategoryMetricsCard
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> loading={loading}
<CardTitle className="text-sm font-medium">Total Value</CardTitle> yearlyCategoryData={yearlyCategoryData}
</CardHeader> yearlyDataLoading={yearlyDataLoading}
<CardContent>
<div className="text-2xl font-bold">
${formatNumber(summary?.total_value || 0)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Fulfillment Rate</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatPercent(summary?.fulfillment_rate || 0)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Avg Cost per PO</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
${formatNumber(summary?.avg_cost || 0)}
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<div className="mb-4 flex items-center gap-4">
<Input
placeholder="Search orders..."
value={filters.search}
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
className="max-w-xs"
/> />
<Select
value={filters.status}
onValueChange={(value) => setFilters(prev => ({ ...prev, status: value }))}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
{STATUS_FILTER_OPTIONS.map(option => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filters.vendor}
onValueChange={(value) => setFilters(prev => ({ ...prev, vendor: value }))}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select vendor" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Vendors</SelectItem>
{filterOptions?.vendors?.map(vendor => (
<SelectItem key={vendor} value={vendor}>
{vendor}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
{/* Purchase Orders Table */} <FilterControls
<Card className="mb-6"> searchInput={searchInput}
<CardHeader> setSearchInput={setSearchInput}
<CardTitle>Recent Purchase Orders</CardTitle> filterValues={filterValues}
</CardHeader> handleStatusChange={handleStatusChange}
<CardContent> handleVendorChange={handleVendorChange}
<Table> handleRecordTypeChange={handleRecordTypeChange}
<TableHeader> clearFilters={clearFilters}
<TableRow> filterOptions={filterOptions}
<TableHead> loading={loading}
<Button variant="ghost" onClick={() => handleSort('id')}> />
ID <ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
</TableHead>
<TableHead>
<Button variant="ghost" onClick={() => handleSort('vendor_name')}>
Vendor <ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
</TableHead>
<TableHead>
<Button variant="ghost" onClick={() => handleSort('order_date')}>
Order Date <ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
</TableHead>
<TableHead>
<Button variant="ghost" onClick={() => handleSort('status')}>
Status <ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
</TableHead>
<TableHead>Total Items</TableHead>
<TableHead>Total Quantity</TableHead>
<TableHead>
<Button variant="ghost" onClick={() => handleSort('total_cost')}>
Total Cost <ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
</TableHead>
<TableHead>Received</TableHead>
<TableHead>
<Button variant="ghost" onClick={() => handleSort('fulfillment_rate')}>
Fulfillment <ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{purchaseOrders.map((po) => (
<TableRow key={po.id}>
<TableCell>{po.id}</TableCell>
<TableCell>{po.vendor_name}</TableCell>
<TableCell>{new Date(po.order_date).toLocaleDateString()}</TableCell>
<TableCell>{getStatusBadge(po.status, po.receiving_status)}</TableCell>
<TableCell>{po.total_items.toLocaleString()}</TableCell>
<TableCell>{po.total_quantity.toLocaleString()}</TableCell>
<TableCell>${formatNumber(po.total_cost)}</TableCell>
<TableCell>{po.total_received.toLocaleString()}</TableCell>
<TableCell>{formatPercent(po.fulfillment_rate)}</TableCell>
</TableRow>
))}
{!purchaseOrders.length && (
<TableRow>
<TableCell colSpan={9} className="text-center text-muted-foreground">
No purchase orders found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Pagination */} <PurchaseOrdersTable
{pagination.pages > 1 && ( purchaseOrders={purchaseOrders}
<div className="flex justify-center"> loading={loading}
<Pagination> summary={summary}
<PaginationContent> sortColumn={sortColumn}
<PaginationItem> sortDirection={sortDirection}
<Button handleSort={handleSort}
onClick={() => setPage(page - 1)} />
disabled={page === 1}
className="h-9 px-4" <PaginationControls
> pagination={pagination}
<PaginationPrevious className="h-4 w-4" /> currentPage={page}
</Button> onPageChange={setPage}
</PaginationItem> />
<PaginationItem>
<Button
onClick={() => setPage(page + 1)}
disabled={page === pagination.pages}
className="h-9 px-4"
>
<PaginationNext className="h-4 w-4" />
</Button>
</PaginationItem>
</PaginationContent>
</Pagination>
</div> </div>
)}
{/* Cost Analysis */}
<Card>
<CardHeader>
<CardTitle>Cost Analysis by Category</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Category</TableHead>
<TableHead>Total Spend</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{costAnalysis?.total_spend_by_category?.map((category) => (
<TableRow key={category.category}>
<TableCell>{category.category}</TableCell>
<TableCell>${formatNumber(category.total_spend)}</TableCell>
</TableRow>
)) || (
<TableRow>
<TableCell colSpan={2} className="text-center text-muted-foreground">
No cost analysis data available
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</motion.div>
); );
} }

View File

@@ -75,7 +75,7 @@ export function getPurchaseOrderStatusVariant(status: number): 'default' | 'seco
export function getReceivingStatusVariant(status: number): 'default' | 'secondary' | 'destructive' | 'outline' { export function getReceivingStatusVariant(status: number): 'default' | 'secondary' | 'destructive' | 'outline' {
if (isReceivingCanceled(status)) return 'destructive'; if (isReceivingCanceled(status)) return 'destructive';
if (status === ReceivingStatus.Paid) return 'default'; if (status === ReceivingStatus.Paid || status === ReceivingStatus.FullReceived) return 'default';
if (status >= ReceivingStatus.PartialReceived) return 'secondary'; if (status >= ReceivingStatus.PartialReceived) return 'secondary';
return 'outline'; return 'outline';
} }

View File

@@ -92,7 +92,7 @@ export default defineConfig(function (_a) {
}, },
server: { server: {
host: "0.0.0.0", host: "0.0.0.0",
port: 5173, port: 5175,
proxy: { proxy: {
"/api": { "/api": {
target: "https://inventory.kent.pw", target: "https://inventory.kent.pw",
@@ -139,7 +139,7 @@ export default defineConfig(function (_a) {
withCredentials: true, withCredentials: true,
onProxyReq: function (proxyReq, req) { onProxyReq: function (proxyReq, req) {
// Add origin header to match CORS policy // Add origin header to match CORS policy
proxyReq.setHeader('Origin', 'http://localhost:5173'); proxyReq.setHeader('Origin', 'http://localhost:5175');
}, },
rewrite: function (path) { return path.replace(/^\/auth-inv/, "/auth-inv"); }, rewrite: function (path) { return path.replace(/^\/auth-inv/, "/auth-inv"); },
configure: function (proxy, _options) { configure: function (proxy, _options) {

View File

@@ -42,7 +42,7 @@ export default defineConfig(({ mode }) => {
}, },
server: { server: {
host: "0.0.0.0", host: "0.0.0.0",
port: 5173, port: 5175,
proxy: { proxy: {
"/api": { "/api": {
target: "https://inventory.kent.pw", target: "https://inventory.kent.pw",
@@ -91,7 +91,7 @@ export default defineConfig(({ mode }) => {
withCredentials: true, withCredentials: true,
onProxyReq: (proxyReq, req) => { onProxyReq: (proxyReq, req) => {
// Add origin header to match CORS policy // Add origin header to match CORS policy
proxyReq.setHeader('Origin', 'http://localhost:5173'); proxyReq.setHeader('Origin', 'http://localhost:5175');
}, },
rewrite: (path) => path.replace(/^\/auth-inv/, "/auth-inv"), rewrite: (path) => path.replace(/^\/auth-inv/, "/auth-inv"),
configure: (proxy, _options) => { configure: (proxy, _options) => {