8 Commits

23 changed files with 3815 additions and 1174 deletions
+112
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.
+51 -14
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
+221 -375
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,10 @@ 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 receivingRecordsAdded = 0;
let receivingRecordsUpdated = 0;
let totalProcessed = 0; let totalProcessed = 0;
// Batch size constants // Batch size constants
@@ -68,8 +70,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 +96,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 +113,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 +123,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 +185,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",
@@ -389,10 +432,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 +467,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 +487,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 +539,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 +563,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,
@@ -506,15 +604,13 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
} }
} }
// 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 +648,107 @@ 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({
status: "running",
operation: "Purchase orders import",
message: `FIFO allocation step ${i+1}/${fifoSteps.length}: ${step.name}`,
current: i,
total: fifoSteps.length
});
await localConnection.query(step.query);
}
// 4. Generate final purchase order records with receiving data
outputProgress({ outputProgress({
status: "running", status: "running",
operation: "Purchase orders import", operation: "Purchase orders import",
message: "Generating final purchase order records" message: "Inserting final purchase order records"
}); });
const [finalResult] = await localConnection.query(` const [poResult] = await localConnection.query(`
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
)
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 ON CONFLICT (po_id, pid) DO UPDATE SET
WHEN rs.total_received IS NULL THEN 'created' vendor = EXCLUDED.vendor,
WHEN rs.total_received = 0 THEN 'created' date = EXCLUDED.date,
WHEN rs.total_received < po.ordered THEN 'partial_received' expected_date = EXCLUDED.expected_date,
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
vendor = EXCLUDED.vendor,
date = EXCLUDED.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"
});
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 +762,9 @@ 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;
`); `);
// Commit transaction // Commit transaction
@@ -930,8 +772,12 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
return { return {
status: "complete", status: "complete",
recordsAdded: recordsAdded || 0, recordsAdded: poRecordsAdded + receivingRecordsAdded,
recordsUpdated: recordsUpdated || 0, recordsUpdated: poRecordsUpdated + receivingRecordsUpdated,
poRecordsAdded,
poRecordsUpdated,
receivingRecordsAdded,
receivingRecordsUpdated,
totalRecords: totalProcessed totalRecords: totalProcessed
}; };
} catch (error) { } catch (error) {
@@ -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();
@@ -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
@@ -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;
@@ -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
@@ -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
@@ -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
@@ -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 (
+39 -34
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
po_id,
status,
date,
expected_date,
pid,
ordered,
po_cost_price
FROM purchase_orders po
WHERE po.status NOT IN ('canceled', 'done')
AND po.date >= CURRENT_DATE - INTERVAL '6 months'
)
SELECT SELECT
COALESCE(COUNT(DISTINCT CASE COUNT(DISTINCT po_id)::integer as active_pos,
WHEN po.receiving_status NOT IN ('partial_received', 'full_received', 'paid') COUNT(DISTINCT CASE WHEN expected_date < CURRENT_DATE THEN po_id END)::integer as overdue_pos,
THEN po.po_id SUM(ordered)::integer as total_units,
END), 0)::integer as active_pos, ROUND(SUM(ordered * po_cost_price)::numeric, 3) as total_cost,
COALESCE(COUNT(DISTINCT CASE ROUND(SUM(ordered * pm.current_price)::numeric, 3) as total_retail
WHEN po.receiving_status NOT IN ('partial_received', 'full_received', 'paid') FROM po_metrics po
AND po.expected_date < CURRENT_DATE
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
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
vendor,
po_id,
SUM(ordered) as total_ordered,
SUM(ordered * po_cost_price) as total_cost
FROM purchase_orders
WHERE status NOT IN ('canceled', 'done')
AND date >= CURRENT_DATE - INTERVAL '6 months'
GROUP BY vendor, po_id
)
SELECT SELECT
po.vendor, pv.vendor,
COUNT(DISTINCT po.po_id)::integer as orders, COUNT(DISTINCT pv.po_id)::integer as orders,
COALESCE(SUM(po.ordered), 0)::integer as units, SUM(pv.total_ordered)::integer as units,
ROUND(COALESCE(SUM(po.ordered * po.cost_price), 0)::numeric, 3) as cost, ROUND(SUM(pv.total_cost)::numeric, 3) as cost,
ROUND(COALESCE(SUM(po.ordered * pm.current_price), 0)::numeric, 3) as retail ROUND(SUM(pv.total_ordered * pm.current_price)::numeric, 3) as retail
FROM purchase_orders po 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
`); `);
@@ -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
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -0,0 +1,211 @@
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-[350px] 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]">SKU</TableHead>
<TableHead className="w-auto">Product</TableHead>
<TableHead className="w-[100px] text-right">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="font-mono text-xs">{item.sku}</TableCell>
<TableCell className="font-medium">{item.product_name}</TableCell>
<TableCell className="text-right font-mono text-xs">{item.upc}</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>
)}
</>
);
}
@@ -0,0 +1,425 @@
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 tracking-tight 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 tracking-tight 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 tracking-tight 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 tracking-tight w-[85px]"
>
{recordType || "Unknown"}
</Badge>
);
}
};
const getStatusBadge = (status: number, recordType: string) => {
if (recordType === "receiving_only") {
return (
<Badge
className="w-[100px] flex items-center justify-center px-0 tracking-tight"
variant={getReceivingStatusVariant(status)}
>
{getReceivingStatusLabel(status)}
</Badge>
);
}
return (
<Badge
className="w-[100px] flex items-center justify-center px-0 tracking-tight"
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">{po.id}</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.total_quantity.toLocaleString()}
</TableCell>
<TableCell className="text-center">
{po.total_received.toLocaleString()}
</TableCell>
<TableCell className="text-right" >
{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>
);
}
@@ -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>
);
}
@@ -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>
+347 -381
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', // Build search params with proper encoding
sortColumn, const searchParams = new URLSearchParams();
sortDirection, searchParams.append('page', page.toString());
...filters.search && { search: filters.search }, searchParams.append('limit', '100');
...filters.status && { status: filters.status }, searchParams.append('sortColumn', sortColumn);
...filters.vendor && { vendor: filters.vendor }, searchParams.append('sortDirection', sortDirection);
});
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);
}
const [ console.log("Fetching data with params:", searchParams.toString());
purchaseOrdersRes,
vendorMetricsRes, // Fetch orders first separately to handle errors better
costAnalysisRes const purchaseOrdersRes = await fetch(`/api/purchase-orders?${searchParams.toString()}`);
] = await Promise.all([
fetch(`/api/purchase-orders?${searchParams}`), if (!purchaseOrdersRes.ok) {
fetch('/api/purchase-orders/vendor-metrics'), const errorText = await purchaseOrdersRes.text();
fetch('/api/purchase-orders/cost-analysis') 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: [],
};
let deliveryMetricsData = {
avg_delivery_days: 0,
max_delivery_days: 0
}; };
// Only try to parse responses if they were successful
if (purchaseOrdersRes.ok) {
purchaseOrdersData = await purchaseOrdersRes.json();
} 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: [],
});
}
if (deliveryMetricsRes.ok) {
deliveryMetricsData = await deliveryMetricsRes.json();
// Merge delivery metrics into summary
const summaryWithDelivery = {
...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
});
} }
setPurchaseOrders(purchaseOrdersData.orders); // Mark that we've completed an initial fetch
setPagination(purchaseOrdersData.pagination); hasInitialFetchRef.current = true;
setFilterOptions(purchaseOrdersData.filters);
setSummary(purchaseOrdersData.summary);
setVendorMetrics(vendorMetricsData);
setCostAnalysis(costAnalysisData);
} 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>; const handleVendorChange = (value: string) => {
} setFilterValues(prev => ({ ...prev, vendor: value }));
};
// If receiving has started, show receiving status
if (status >= PurchaseOrderStatus.ReceivingStarted) { const handleRecordTypeChange = (value: string) => {
return <Badge variant={getReceivingStatusVariant(receivingStatus)}> setFilterValues(prev => ({ ...prev, recordType: value }));
{getReceivingStatusLabel(receivingStatus)}
</Badge>;
}
// Otherwise show PO status
return <Badge variant={getPurchaseOrderStatusVariant(status)}>
{getPurchaseOrderStatusLabel(status)}
</Badge>;
}; };
const formatNumber = (value: number) => { // Clear all filters handler
return value.toLocaleString('en-US', { const clearFilters = () => {
minimumFractionDigits: 2, setSearchInput("");
maximumFractionDigits: 2 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;
}
try {
setYearlyDataLoading(true);
// Create a date for 1 year ago
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);
}
}; };
if (loading) { // Fetch yearly data when component mounts, not just when dialogs open
return ( useEffect(() => {
<div className="flex h-full items-center justify-center"> fetchYearlyData();
<Loader2 className="h-8 w-8 animate-spin" /> // eslint-disable-next-line react-hooks/exhaustive-deps
</div> }, []);
);
}
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"
>
<PaginationPrevious className="h-4 w-4" />
</Button>
</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>
)}
{/* Cost Analysis */} <PaginationControls
<Card> pagination={pagination}
<CardHeader> currentPage={page}
<CardTitle>Cost Analysis by Category</CardTitle> onPageChange={setPage}
</CardHeader> />
<CardContent> </div>
<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>
); );
} }
+1 -1
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';
} }