Compare commits
10 Commits
00249f7c33
...
fix-number
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cb41a7e4c | |||
| d05d27494d | |||
| 4ed734e5c0 | |||
| 1e3be5d4cb | |||
| 8dd852dd6a | |||
| eeff5817ea | |||
| 1b19feb172 | |||
| 80ff8124ec | |||
| 8508bfac93 | |||
| ac14179bd2 |
112
docs/split-up-pos.md
Normal file
112
docs/split-up-pos.md
Normal 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.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function validateDate(mysqlDate) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Imports purchase orders and receivings from a production MySQL database to a local PostgreSQL database.
|
* Imports purchase orders and receivings from a production MySQL database to a local PostgreSQL database.
|
||||||
* Implements FIFO allocation of receivings to purchase orders.
|
* Handles these as separate data streams without complex FIFO allocation.
|
||||||
*
|
*
|
||||||
* @param {object} prodConnection - A MySQL connection to production DB
|
* @param {object} prodConnection - A MySQL connection to production DB
|
||||||
* @param {object} localConnection - A PostgreSQL connection to local DB
|
* @param {object} localConnection - A PostgreSQL connection to local DB
|
||||||
@@ -44,8 +44,12 @@ function validateDate(mysqlDate) {
|
|||||||
*/
|
*/
|
||||||
async function importPurchaseOrders(prodConnection, localConnection, incrementalUpdate = true) {
|
async function importPurchaseOrders(prodConnection, localConnection, incrementalUpdate = true) {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
let recordsAdded = 0;
|
let poRecordsAdded = 0;
|
||||||
let recordsUpdated = 0;
|
let poRecordsUpdated = 0;
|
||||||
|
let poRecordsDeleted = 0;
|
||||||
|
let receivingRecordsAdded = 0;
|
||||||
|
let receivingRecordsUpdated = 0;
|
||||||
|
let receivingRecordsDeleted = 0;
|
||||||
let totalProcessed = 0;
|
let totalProcessed = 0;
|
||||||
|
|
||||||
// Batch size constants
|
// Batch size constants
|
||||||
@@ -68,8 +72,8 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
await localConnection.query(`
|
await localConnection.query(`
|
||||||
DROP TABLE IF EXISTS temp_purchase_orders;
|
DROP TABLE IF EXISTS temp_purchase_orders;
|
||||||
DROP TABLE IF EXISTS temp_receivings;
|
DROP TABLE IF EXISTS temp_receivings;
|
||||||
DROP TABLE IF EXISTS temp_receiving_allocations;
|
|
||||||
DROP TABLE IF EXISTS employee_names;
|
DROP TABLE IF EXISTS employee_names;
|
||||||
|
DROP TABLE IF EXISTS temp_supplier_names;
|
||||||
|
|
||||||
-- Temporary table for purchase orders
|
-- Temporary table for purchase orders
|
||||||
CREATE TEMP TABLE temp_purchase_orders (
|
CREATE TEMP TABLE temp_purchase_orders (
|
||||||
@@ -94,11 +98,16 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
-- Temporary table for receivings
|
-- Temporary table for receivings
|
||||||
CREATE TEMP TABLE temp_receivings (
|
CREATE TEMP TABLE temp_receivings (
|
||||||
receiving_id TEXT NOT NULL,
|
receiving_id TEXT NOT NULL,
|
||||||
po_id TEXT,
|
|
||||||
pid BIGINT NOT NULL,
|
pid BIGINT NOT NULL,
|
||||||
|
sku TEXT,
|
||||||
|
name TEXT,
|
||||||
|
vendor TEXT,
|
||||||
qty_each INTEGER,
|
qty_each INTEGER,
|
||||||
cost_each NUMERIC(14, 4),
|
qty_each_orig INTEGER,
|
||||||
|
cost_each NUMERIC(14, 5),
|
||||||
|
cost_each_orig NUMERIC(14, 5),
|
||||||
received_by INTEGER,
|
received_by INTEGER,
|
||||||
|
received_by_name TEXT,
|
||||||
received_date TIMESTAMP WITH TIME ZONE,
|
received_date TIMESTAMP WITH TIME ZONE,
|
||||||
receiving_created_date TIMESTAMP WITH TIME ZONE,
|
receiving_created_date TIMESTAMP WITH TIME ZONE,
|
||||||
supplier_id INTEGER,
|
supplier_id INTEGER,
|
||||||
@@ -106,18 +115,6 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
PRIMARY KEY (receiving_id, pid)
|
PRIMARY KEY (receiving_id, pid)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Temporary table for tracking FIFO allocations
|
|
||||||
CREATE TEMP TABLE temp_receiving_allocations (
|
|
||||||
po_id TEXT NOT NULL,
|
|
||||||
pid BIGINT NOT NULL,
|
|
||||||
receiving_id TEXT NOT NULL,
|
|
||||||
allocated_qty INTEGER NOT NULL,
|
|
||||||
cost_each NUMERIC(14, 4) NOT NULL,
|
|
||||||
received_date TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
||||||
received_by INTEGER,
|
|
||||||
PRIMARY KEY (po_id, pid, receiving_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Temporary table for employee names
|
-- Temporary table for employee names
|
||||||
CREATE TEMP TABLE employee_names (
|
CREATE TEMP TABLE employee_names (
|
||||||
employeeid INTEGER PRIMARY KEY,
|
employeeid INTEGER PRIMARY KEY,
|
||||||
@@ -128,7 +125,6 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
-- Create indexes for efficient joins
|
-- Create indexes for efficient joins
|
||||||
CREATE INDEX idx_temp_po_pid ON temp_purchase_orders(pid);
|
CREATE INDEX idx_temp_po_pid ON temp_purchase_orders(pid);
|
||||||
CREATE INDEX idx_temp_receiving_pid ON temp_receivings(pid);
|
CREATE INDEX idx_temp_receiving_pid ON temp_receivings(pid);
|
||||||
CREATE INDEX idx_temp_receiving_po_id ON temp_receivings(po_id);
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Map status codes to text values
|
// Map status codes to text values
|
||||||
@@ -191,7 +187,56 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
`, employeeValues);
|
`, employeeValues);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. First, fetch all relevant POs
|
// Add this section before the PO import to create a supplier names mapping
|
||||||
|
outputProgress({
|
||||||
|
status: "running",
|
||||||
|
operation: "Purchase orders import",
|
||||||
|
message: "Fetching supplier data for vendor mapping"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch supplier data from production and store in a temp table
|
||||||
|
const [suppliers] = await prodConnection.query(`
|
||||||
|
SELECT
|
||||||
|
supplierid,
|
||||||
|
companyname
|
||||||
|
FROM suppliers
|
||||||
|
WHERE companyname IS NOT NULL AND companyname != ''
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (suppliers.length > 0) {
|
||||||
|
// Create temp table for supplier names
|
||||||
|
await localConnection.query(`
|
||||||
|
DROP TABLE IF EXISTS temp_supplier_names;
|
||||||
|
CREATE TEMP TABLE temp_supplier_names (
|
||||||
|
supplier_id INTEGER PRIMARY KEY,
|
||||||
|
company_name TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Insert supplier data in batches
|
||||||
|
for (let i = 0; i < suppliers.length; i += INSERT_BATCH_SIZE) {
|
||||||
|
const batch = suppliers.slice(i, i + INSERT_BATCH_SIZE);
|
||||||
|
|
||||||
|
const placeholders = batch.map((_, idx) => {
|
||||||
|
const base = idx * 2;
|
||||||
|
return `($${base + 1}, $${base + 2})`;
|
||||||
|
}).join(',');
|
||||||
|
|
||||||
|
const values = batch.flatMap(s => [
|
||||||
|
s.supplierid,
|
||||||
|
s.companyname || 'Unnamed Supplier'
|
||||||
|
]);
|
||||||
|
|
||||||
|
await localConnection.query(`
|
||||||
|
INSERT INTO temp_supplier_names (supplier_id, company_name)
|
||||||
|
VALUES ${placeholders}
|
||||||
|
ON CONFLICT (supplier_id) DO UPDATE SET
|
||||||
|
company_name = EXCLUDED.company_name
|
||||||
|
`, values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Fetch and process purchase orders
|
||||||
outputProgress({
|
outputProgress({
|
||||||
status: "running",
|
status: "running",
|
||||||
operation: "Purchase orders import",
|
operation: "Purchase orders import",
|
||||||
@@ -214,6 +259,10 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
const totalPOs = poCount[0].total;
|
const totalPOs = poCount[0].total;
|
||||||
console.log(`Found ${totalPOs} relevant purchase orders`);
|
console.log(`Found ${totalPOs} relevant purchase orders`);
|
||||||
|
|
||||||
|
// Skip processing if no POs to process
|
||||||
|
if (totalPOs === 0) {
|
||||||
|
console.log('No purchase orders to process, skipping PO import step');
|
||||||
|
} else {
|
||||||
// Fetch and process POs in batches
|
// Fetch and process POs in batches
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
let allPOsProcessed = false;
|
let allPOsProcessed = false;
|
||||||
@@ -358,6 +407,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
allPOsProcessed = true;
|
allPOsProcessed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Next, fetch all relevant receivings
|
// 2. Next, fetch all relevant receivings
|
||||||
outputProgress({
|
outputProgress({
|
||||||
@@ -381,6 +431,10 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
const totalReceivings = receivingCount[0].total;
|
const totalReceivings = receivingCount[0].total;
|
||||||
console.log(`Found ${totalReceivings} relevant receivings`);
|
console.log(`Found ${totalReceivings} relevant receivings`);
|
||||||
|
|
||||||
|
// Skip processing if no receivings to process
|
||||||
|
if (totalReceivings === 0) {
|
||||||
|
console.log('No receivings to process, skipping receivings import step');
|
||||||
|
} else {
|
||||||
// Fetch and process receivings in batches
|
// Fetch and process receivings in batches
|
||||||
offset = 0; // Reset offset for receivings
|
offset = 0; // Reset offset for receivings
|
||||||
let allReceivingsProcessed = false;
|
let allReceivingsProcessed = false;
|
||||||
@@ -389,10 +443,16 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
const [receivingList] = await prodConnection.query(`
|
const [receivingList] = await prodConnection.query(`
|
||||||
SELECT
|
SELECT
|
||||||
r.receiving_id,
|
r.receiving_id,
|
||||||
r.po_id,
|
|
||||||
r.supplier_id,
|
r.supplier_id,
|
||||||
r.status,
|
r.status,
|
||||||
r.date_created
|
r.notes,
|
||||||
|
r.shipping,
|
||||||
|
r.total_amount,
|
||||||
|
r.hold,
|
||||||
|
r.for_storefront,
|
||||||
|
r.date_created,
|
||||||
|
r.date_paid,
|
||||||
|
r.date_checked
|
||||||
FROM receivings r
|
FROM receivings r
|
||||||
WHERE r.date_created >= DATE_SUB(CURRENT_DATE, INTERVAL ${yearInterval} YEAR)
|
WHERE r.date_created >= DATE_SUB(CURRENT_DATE, INTERVAL ${yearInterval} YEAR)
|
||||||
${incrementalUpdate ? `
|
${incrementalUpdate ? `
|
||||||
@@ -418,12 +478,17 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
rp.receiving_id,
|
rp.receiving_id,
|
||||||
rp.pid,
|
rp.pid,
|
||||||
rp.qty_each,
|
rp.qty_each,
|
||||||
|
rp.qty_each_orig,
|
||||||
rp.cost_each,
|
rp.cost_each,
|
||||||
|
rp.cost_each_orig,
|
||||||
rp.received_by,
|
rp.received_by,
|
||||||
rp.received_date,
|
rp.received_date,
|
||||||
r.date_created as receiving_created_date
|
r.date_created as receiving_created_date,
|
||||||
|
COALESCE(p.itemnumber, 'NO-SKU') AS sku,
|
||||||
|
COALESCE(p.description, 'Unknown Product') AS name
|
||||||
FROM receivings_products rp
|
FROM receivings_products rp
|
||||||
JOIN receivings r ON rp.receiving_id = r.receiving_id
|
JOIN receivings r ON rp.receiving_id = r.receiving_id
|
||||||
|
LEFT JOIN products p ON rp.pid = p.pid
|
||||||
WHERE rp.receiving_id IN (?)
|
WHERE rp.receiving_id IN (?)
|
||||||
`, [receivingIds]);
|
`, [receivingIds]);
|
||||||
|
|
||||||
@@ -433,13 +498,46 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
const receiving = receivingList.find(r => r.receiving_id == product.receiving_id);
|
const receiving = receivingList.find(r => r.receiving_id == product.receiving_id);
|
||||||
if (!receiving) continue;
|
if (!receiving) continue;
|
||||||
|
|
||||||
|
// Get employee name if available
|
||||||
|
let receivedByName = null;
|
||||||
|
if (product.received_by) {
|
||||||
|
const [employeeResult] = await localConnection.query(`
|
||||||
|
SELECT CONCAT(firstname, ' ', lastname) as full_name
|
||||||
|
FROM employee_names
|
||||||
|
WHERE employeeid = $1
|
||||||
|
`, [product.received_by]);
|
||||||
|
|
||||||
|
if (employeeResult.rows.length > 0) {
|
||||||
|
receivedByName = employeeResult.rows[0].full_name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get vendor name if available
|
||||||
|
let vendorName = 'Unknown Vendor';
|
||||||
|
if (receiving.supplier_id) {
|
||||||
|
const [vendorResult] = await localConnection.query(`
|
||||||
|
SELECT company_name
|
||||||
|
FROM temp_supplier_names
|
||||||
|
WHERE supplier_id = $1
|
||||||
|
`, [receiving.supplier_id]);
|
||||||
|
|
||||||
|
if (vendorResult.rows.length > 0) {
|
||||||
|
vendorName = vendorResult.rows[0].company_name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
completeReceivings.push({
|
completeReceivings.push({
|
||||||
receiving_id: receiving.receiving_id.toString(),
|
receiving_id: receiving.receiving_id.toString(),
|
||||||
po_id: receiving.po_id ? receiving.po_id.toString() : null,
|
|
||||||
pid: product.pid,
|
pid: product.pid,
|
||||||
|
sku: product.sku,
|
||||||
|
name: product.name,
|
||||||
|
vendor: vendorName,
|
||||||
qty_each: product.qty_each,
|
qty_each: product.qty_each,
|
||||||
|
qty_each_orig: product.qty_each_orig,
|
||||||
cost_each: product.cost_each,
|
cost_each: product.cost_each,
|
||||||
|
cost_each_orig: product.cost_each_orig,
|
||||||
received_by: product.received_by,
|
received_by: product.received_by,
|
||||||
|
received_by_name: receivedByName,
|
||||||
received_date: validateDate(product.received_date) || validateDate(product.receiving_created_date),
|
received_date: validateDate(product.received_date) || validateDate(product.receiving_created_date),
|
||||||
receiving_created_date: validateDate(product.receiving_created_date),
|
receiving_created_date: validateDate(product.receiving_created_date),
|
||||||
supplier_id: receiving.supplier_id,
|
supplier_id: receiving.supplier_id,
|
||||||
@@ -452,17 +550,22 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
const batch = completeReceivings.slice(i, i + INSERT_BATCH_SIZE);
|
const batch = completeReceivings.slice(i, i + INSERT_BATCH_SIZE);
|
||||||
|
|
||||||
const placeholders = batch.map((_, idx) => {
|
const placeholders = batch.map((_, idx) => {
|
||||||
const base = idx * 10;
|
const base = idx * 15;
|
||||||
return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6}, $${base + 7}, $${base + 8}, $${base + 9}, $${base + 10})`;
|
return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6}, $${base + 7}, $${base + 8}, $${base + 9}, $${base + 10}, $${base + 11}, $${base + 12}, $${base + 13}, $${base + 14}, $${base + 15})`;
|
||||||
}).join(',');
|
}).join(',');
|
||||||
|
|
||||||
const values = batch.flatMap(r => [
|
const values = batch.flatMap(r => [
|
||||||
r.receiving_id,
|
r.receiving_id,
|
||||||
r.po_id,
|
|
||||||
r.pid,
|
r.pid,
|
||||||
|
r.sku,
|
||||||
|
r.name,
|
||||||
|
r.vendor,
|
||||||
r.qty_each,
|
r.qty_each,
|
||||||
|
r.qty_each_orig,
|
||||||
r.cost_each,
|
r.cost_each,
|
||||||
|
r.cost_each_orig,
|
||||||
r.received_by,
|
r.received_by,
|
||||||
|
r.received_by_name,
|
||||||
r.received_date,
|
r.received_date,
|
||||||
r.receiving_created_date,
|
r.receiving_created_date,
|
||||||
r.supplier_id,
|
r.supplier_id,
|
||||||
@@ -471,15 +574,21 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
|
|
||||||
await localConnection.query(`
|
await localConnection.query(`
|
||||||
INSERT INTO temp_receivings (
|
INSERT INTO temp_receivings (
|
||||||
receiving_id, po_id, pid, qty_each, cost_each, received_by,
|
receiving_id, pid, sku, name, vendor, qty_each, qty_each_orig,
|
||||||
|
cost_each, cost_each_orig, received_by, received_by_name,
|
||||||
received_date, receiving_created_date, supplier_id, status
|
received_date, receiving_created_date, supplier_id, status
|
||||||
)
|
)
|
||||||
VALUES ${placeholders}
|
VALUES ${placeholders}
|
||||||
ON CONFLICT (receiving_id, pid) DO UPDATE SET
|
ON CONFLICT (receiving_id, pid) DO UPDATE SET
|
||||||
po_id = EXCLUDED.po_id,
|
sku = EXCLUDED.sku,
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
vendor = EXCLUDED.vendor,
|
||||||
qty_each = EXCLUDED.qty_each,
|
qty_each = EXCLUDED.qty_each,
|
||||||
|
qty_each_orig = EXCLUDED.qty_each_orig,
|
||||||
cost_each = EXCLUDED.cost_each,
|
cost_each = EXCLUDED.cost_each,
|
||||||
|
cost_each_orig = EXCLUDED.cost_each_orig,
|
||||||
received_by = EXCLUDED.received_by,
|
received_by = EXCLUDED.received_by,
|
||||||
|
received_by_name = EXCLUDED.received_by_name,
|
||||||
received_date = EXCLUDED.received_date,
|
received_date = EXCLUDED.received_date,
|
||||||
receiving_created_date = EXCLUDED.receiving_created_date,
|
receiving_created_date = EXCLUDED.receiving_created_date,
|
||||||
supplier_id = EXCLUDED.supplier_id,
|
supplier_id = EXCLUDED.supplier_id,
|
||||||
@@ -505,16 +614,15 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
allReceivingsProcessed = true;
|
allReceivingsProcessed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Implement FIFO allocation of receivings to purchase orders
|
// Add this section to filter out invalid PIDs before final import
|
||||||
outputProgress({
|
outputProgress({
|
||||||
status: "running",
|
status: "running",
|
||||||
operation: "Purchase orders import",
|
operation: "Purchase orders import",
|
||||||
message: "Validating product IDs before allocation"
|
message: "Validating product IDs before final import"
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add this section to filter out invalid PIDs before allocation
|
|
||||||
// This will check all PIDs in our temp tables against the products table
|
|
||||||
await localConnection.query(`
|
await localConnection.query(`
|
||||||
-- Create temp table to store invalid PIDs
|
-- Create temp table to store invalid PIDs
|
||||||
DROP TABLE IF EXISTS temp_invalid_pids;
|
DROP TABLE IF EXISTS temp_invalid_pids;
|
||||||
@@ -552,362 +660,157 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
console.log(`Filtered out ${filteredCount} items with invalid product IDs`);
|
console.log(`Filtered out ${filteredCount} items with invalid product IDs`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Break FIFO allocation into steps with progress tracking
|
// 3. Insert final purchase order records to the actual table
|
||||||
const fifoSteps = [
|
|
||||||
{
|
|
||||||
name: "Direct allocations",
|
|
||||||
query: `
|
|
||||||
INSERT INTO temp_receiving_allocations (
|
|
||||||
po_id, pid, receiving_id, allocated_qty, cost_each, received_date, received_by
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
r.po_id,
|
|
||||||
r.pid,
|
|
||||||
r.receiving_id,
|
|
||||||
LEAST(r.qty_each, po.ordered) as allocated_qty,
|
|
||||||
r.cost_each,
|
|
||||||
COALESCE(r.received_date, NOW()) as received_date,
|
|
||||||
r.received_by
|
|
||||||
FROM temp_receivings r
|
|
||||||
JOIN temp_purchase_orders po ON r.po_id = po.po_id AND r.pid = po.pid
|
|
||||||
WHERE r.po_id IS NOT NULL
|
|
||||||
`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Handling standalone receivings",
|
|
||||||
query: `
|
|
||||||
INSERT INTO temp_purchase_orders (
|
|
||||||
po_id, pid, sku, name, vendor, date, status,
|
|
||||||
ordered, po_cost_price, supplier_id, date_created, date_ordered
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
r.receiving_id::text as po_id,
|
|
||||||
r.pid,
|
|
||||||
COALESCE(p.sku, 'NO-SKU') as sku,
|
|
||||||
COALESCE(p.name, 'Unknown Product') as name,
|
|
||||||
COALESCE(
|
|
||||||
(SELECT vendor FROM temp_purchase_orders
|
|
||||||
WHERE supplier_id = r.supplier_id LIMIT 1),
|
|
||||||
'Unknown Vendor'
|
|
||||||
) as vendor,
|
|
||||||
COALESCE(r.received_date, r.receiving_created_date) as date,
|
|
||||||
'created' as status,
|
|
||||||
NULL as ordered,
|
|
||||||
r.cost_each as po_cost_price,
|
|
||||||
r.supplier_id,
|
|
||||||
COALESCE(r.receiving_created_date, r.received_date) as date_created,
|
|
||||||
NULL as date_ordered
|
|
||||||
FROM temp_receivings r
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT DISTINCT pid, sku, name FROM temp_purchase_orders
|
|
||||||
) p ON r.pid = p.pid
|
|
||||||
WHERE r.po_id IS NULL
|
|
||||||
OR NOT EXISTS (
|
|
||||||
SELECT 1 FROM temp_purchase_orders po
|
|
||||||
WHERE po.po_id = r.po_id AND po.pid = r.pid
|
|
||||||
)
|
|
||||||
ON CONFLICT (po_id, pid) DO NOTHING
|
|
||||||
`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Allocating standalone receivings",
|
|
||||||
query: `
|
|
||||||
INSERT INTO temp_receiving_allocations (
|
|
||||||
po_id, pid, receiving_id, allocated_qty, cost_each, received_date, received_by
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
r.receiving_id::text as po_id,
|
|
||||||
r.pid,
|
|
||||||
r.receiving_id,
|
|
||||||
r.qty_each as allocated_qty,
|
|
||||||
r.cost_each,
|
|
||||||
COALESCE(r.received_date, NOW()) as received_date,
|
|
||||||
r.received_by
|
|
||||||
FROM temp_receivings r
|
|
||||||
WHERE r.po_id IS NULL
|
|
||||||
OR NOT EXISTS (
|
|
||||||
SELECT 1 FROM temp_purchase_orders po
|
|
||||||
WHERE po.po_id = r.po_id AND po.pid = r.pid
|
|
||||||
)
|
|
||||||
`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "FIFO allocation logic",
|
|
||||||
query: `
|
|
||||||
WITH
|
|
||||||
-- Calculate remaining quantities after direct allocations
|
|
||||||
remaining_po_quantities AS (
|
|
||||||
SELECT
|
|
||||||
po.po_id,
|
|
||||||
po.pid,
|
|
||||||
po.ordered,
|
|
||||||
COALESCE(SUM(ra.allocated_qty), 0) as already_allocated,
|
|
||||||
po.ordered - COALESCE(SUM(ra.allocated_qty), 0) as remaining_qty,
|
|
||||||
po.date_ordered,
|
|
||||||
po.date_created
|
|
||||||
FROM temp_purchase_orders po
|
|
||||||
LEFT JOIN temp_receiving_allocations ra ON po.po_id = ra.po_id AND po.pid = ra.pid
|
|
||||||
WHERE po.ordered IS NOT NULL
|
|
||||||
GROUP BY po.po_id, po.pid, po.ordered, po.date_ordered, po.date_created
|
|
||||||
HAVING po.ordered > COALESCE(SUM(ra.allocated_qty), 0)
|
|
||||||
),
|
|
||||||
remaining_receiving_quantities AS (
|
|
||||||
SELECT
|
|
||||||
r.receiving_id,
|
|
||||||
r.pid,
|
|
||||||
r.qty_each,
|
|
||||||
COALESCE(SUM(ra.allocated_qty), 0) as already_allocated,
|
|
||||||
r.qty_each - COALESCE(SUM(ra.allocated_qty), 0) as remaining_qty,
|
|
||||||
r.received_date,
|
|
||||||
r.cost_each,
|
|
||||||
r.received_by
|
|
||||||
FROM temp_receivings r
|
|
||||||
LEFT JOIN temp_receiving_allocations ra ON r.receiving_id = ra.receiving_id AND r.pid = ra.pid
|
|
||||||
GROUP BY r.receiving_id, r.pid, r.qty_each, r.received_date, r.cost_each, r.received_by
|
|
||||||
HAVING r.qty_each > COALESCE(SUM(ra.allocated_qty), 0)
|
|
||||||
),
|
|
||||||
-- Rank POs by age, with a cutoff for very old POs (1 year)
|
|
||||||
ranked_pos AS (
|
|
||||||
SELECT
|
|
||||||
po.po_id,
|
|
||||||
po.pid,
|
|
||||||
po.remaining_qty,
|
|
||||||
CASE
|
|
||||||
WHEN po.date_ordered IS NULL OR po.date_ordered < NOW() - INTERVAL '1 year' THEN 2
|
|
||||||
ELSE 1
|
|
||||||
END as age_group,
|
|
||||||
ROW_NUMBER() OVER (
|
|
||||||
PARTITION BY po.pid, (CASE WHEN po.date_ordered IS NULL OR po.date_ordered < NOW() - INTERVAL '1 year' THEN 2 ELSE 1 END)
|
|
||||||
ORDER BY COALESCE(po.date_ordered, po.date_created, NOW())
|
|
||||||
) as rank_in_group
|
|
||||||
FROM remaining_po_quantities po
|
|
||||||
),
|
|
||||||
-- Rank receivings by date
|
|
||||||
ranked_receivings AS (
|
|
||||||
SELECT
|
|
||||||
r.receiving_id,
|
|
||||||
r.pid,
|
|
||||||
r.remaining_qty,
|
|
||||||
r.received_date,
|
|
||||||
r.cost_each,
|
|
||||||
r.received_by,
|
|
||||||
ROW_NUMBER() OVER (PARTITION BY r.pid ORDER BY COALESCE(r.received_date, NOW())) as rank
|
|
||||||
FROM remaining_receiving_quantities r
|
|
||||||
),
|
|
||||||
-- First allocate to recent POs
|
|
||||||
allocations_recent AS (
|
|
||||||
SELECT
|
|
||||||
po.po_id,
|
|
||||||
po.pid,
|
|
||||||
r.receiving_id,
|
|
||||||
LEAST(po.remaining_qty, r.remaining_qty) as allocated_qty,
|
|
||||||
r.cost_each,
|
|
||||||
COALESCE(r.received_date, NOW()) as received_date,
|
|
||||||
r.received_by,
|
|
||||||
po.age_group,
|
|
||||||
po.rank_in_group,
|
|
||||||
r.rank,
|
|
||||||
'recent' as allocation_type
|
|
||||||
FROM ranked_pos po
|
|
||||||
JOIN ranked_receivings r ON po.pid = r.pid
|
|
||||||
WHERE po.age_group = 1
|
|
||||||
ORDER BY po.pid, po.rank_in_group, r.rank
|
|
||||||
),
|
|
||||||
-- Then allocate to older POs
|
|
||||||
remaining_after_recent AS (
|
|
||||||
SELECT
|
|
||||||
r.receiving_id,
|
|
||||||
r.pid,
|
|
||||||
r.remaining_qty - COALESCE(SUM(a.allocated_qty), 0) as remaining_qty,
|
|
||||||
r.received_date,
|
|
||||||
r.cost_each,
|
|
||||||
r.received_by,
|
|
||||||
r.rank
|
|
||||||
FROM ranked_receivings r
|
|
||||||
LEFT JOIN allocations_recent a ON r.receiving_id = a.receiving_id AND r.pid = a.pid
|
|
||||||
GROUP BY r.receiving_id, r.pid, r.remaining_qty, r.received_date, r.cost_each, r.received_by, r.rank
|
|
||||||
HAVING r.remaining_qty > COALESCE(SUM(a.allocated_qty), 0)
|
|
||||||
),
|
|
||||||
allocations_old AS (
|
|
||||||
SELECT
|
|
||||||
po.po_id,
|
|
||||||
po.pid,
|
|
||||||
r.receiving_id,
|
|
||||||
LEAST(po.remaining_qty, r.remaining_qty) as allocated_qty,
|
|
||||||
r.cost_each,
|
|
||||||
COALESCE(r.received_date, NOW()) as received_date,
|
|
||||||
r.received_by,
|
|
||||||
po.age_group,
|
|
||||||
po.rank_in_group,
|
|
||||||
r.rank,
|
|
||||||
'old' as allocation_type
|
|
||||||
FROM ranked_pos po
|
|
||||||
JOIN remaining_after_recent r ON po.pid = r.pid
|
|
||||||
WHERE po.age_group = 2
|
|
||||||
ORDER BY po.pid, po.rank_in_group, r.rank
|
|
||||||
),
|
|
||||||
-- Combine allocations
|
|
||||||
combined_allocations AS (
|
|
||||||
SELECT * FROM allocations_recent
|
|
||||||
UNION ALL
|
|
||||||
SELECT * FROM allocations_old
|
|
||||||
)
|
|
||||||
-- Insert into allocations table
|
|
||||||
INSERT INTO temp_receiving_allocations (
|
|
||||||
po_id, pid, receiving_id, allocated_qty, cost_each, received_date, received_by
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
po_id, pid, receiving_id, allocated_qty, cost_each,
|
|
||||||
COALESCE(received_date, NOW()) as received_date,
|
|
||||||
received_by
|
|
||||||
FROM combined_allocations
|
|
||||||
WHERE allocated_qty > 0
|
|
||||||
`
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// Execute FIFO steps with progress tracking
|
|
||||||
for (let i = 0; i < fifoSteps.length; i++) {
|
|
||||||
const step = fifoSteps[i];
|
|
||||||
outputProgress({
|
outputProgress({
|
||||||
status: "running",
|
status: "running",
|
||||||
operation: "Purchase orders import",
|
operation: "Purchase orders import",
|
||||||
message: `FIFO allocation step ${i+1}/${fifoSteps.length}: ${step.name}`,
|
message: "Inserting final purchase order records"
|
||||||
current: i,
|
|
||||||
total: fifoSteps.length
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await localConnection.query(step.query);
|
// Create a temp table to track PO IDs being processed
|
||||||
}
|
await localConnection.query(`
|
||||||
|
DROP TABLE IF EXISTS processed_po_ids;
|
||||||
|
CREATE TEMP TABLE processed_po_ids AS (
|
||||||
|
SELECT DISTINCT po_id FROM temp_purchase_orders
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
// 4. Generate final purchase order records with receiving data
|
// Delete products that were removed from POs and count them
|
||||||
outputProgress({
|
const [poDeletedResult] = await localConnection.query(`
|
||||||
status: "running",
|
WITH deleted AS (
|
||||||
operation: "Purchase orders import",
|
DELETE FROM purchase_orders
|
||||||
message: "Generating final purchase order records"
|
WHERE po_id IN (SELECT po_id FROM processed_po_ids)
|
||||||
});
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM temp_purchase_orders tp
|
||||||
const [finalResult] = await localConnection.query(`
|
WHERE purchase_orders.po_id = tp.po_id AND purchase_orders.pid = tp.pid
|
||||||
WITH
|
|
||||||
receiving_summaries AS (
|
|
||||||
SELECT
|
|
||||||
po_id,
|
|
||||||
pid,
|
|
||||||
SUM(allocated_qty) as total_received,
|
|
||||||
JSONB_AGG(
|
|
||||||
JSONB_BUILD_OBJECT(
|
|
||||||
'receiving_id', receiving_id,
|
|
||||||
'qty', allocated_qty,
|
|
||||||
'date', COALESCE(received_date, NOW()),
|
|
||||||
'cost', cost_each,
|
|
||||||
'received_by', received_by,
|
|
||||||
'received_by_name', CASE
|
|
||||||
WHEN received_by IS NOT NULL AND received_by > 0 THEN
|
|
||||||
(SELECT CONCAT(firstname, ' ', lastname)
|
|
||||||
FROM employee_names
|
|
||||||
WHERE employeeid = received_by)
|
|
||||||
ELSE NULL
|
|
||||||
END
|
|
||||||
) ORDER BY COALESCE(received_date, NOW())
|
|
||||||
) as receiving_history,
|
|
||||||
MIN(COALESCE(received_date, NOW())) as first_received_date,
|
|
||||||
MAX(COALESCE(received_date, NOW())) as last_received_date,
|
|
||||||
STRING_AGG(
|
|
||||||
DISTINCT CASE WHEN received_by IS NOT NULL AND received_by > 0
|
|
||||||
THEN CAST(received_by AS TEXT)
|
|
||||||
ELSE NULL
|
|
||||||
END,
|
|
||||||
','
|
|
||||||
) as received_by_list,
|
|
||||||
STRING_AGG(
|
|
||||||
DISTINCT CASE
|
|
||||||
WHEN ra.received_by IS NOT NULL AND ra.received_by > 0 THEN
|
|
||||||
(SELECT CONCAT(firstname, ' ', lastname)
|
|
||||||
FROM employee_names
|
|
||||||
WHERE employeeid = ra.received_by)
|
|
||||||
ELSE NULL
|
|
||||||
END,
|
|
||||||
', '
|
|
||||||
) as received_by_names
|
|
||||||
FROM temp_receiving_allocations ra
|
|
||||||
GROUP BY po_id, pid
|
|
||||||
),
|
|
||||||
cost_averaging AS (
|
|
||||||
SELECT
|
|
||||||
ra.po_id,
|
|
||||||
ra.pid,
|
|
||||||
SUM(ra.allocated_qty * ra.cost_each) / NULLIF(SUM(ra.allocated_qty), 0) as avg_cost
|
|
||||||
FROM temp_receiving_allocations ra
|
|
||||||
GROUP BY ra.po_id, ra.pid
|
|
||||||
)
|
)
|
||||||
|
RETURNING po_id, pid
|
||||||
|
)
|
||||||
|
SELECT COUNT(*) as count FROM deleted
|
||||||
|
`);
|
||||||
|
|
||||||
|
poRecordsDeleted = poDeletedResult.rows[0]?.count || 0;
|
||||||
|
console.log(`Deleted ${poRecordsDeleted} products that were removed from purchase orders`);
|
||||||
|
|
||||||
|
const [poResult] = await localConnection.query(`
|
||||||
INSERT INTO purchase_orders (
|
INSERT INTO purchase_orders (
|
||||||
po_id, vendor, date, expected_date, pid, sku, name,
|
po_id, vendor, date, expected_date, pid, sku, name,
|
||||||
cost_price, po_cost_price, status, receiving_status, notes, long_note,
|
po_cost_price, status, notes, long_note,
|
||||||
ordered, received, received_date, last_received_date, received_by,
|
ordered, supplier_id, date_created, date_ordered
|
||||||
receiving_history
|
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
po.po_id,
|
po_id,
|
||||||
po.vendor,
|
vendor,
|
||||||
CASE
|
COALESCE(date, date_created, now()) as date,
|
||||||
WHEN po.date IS NOT NULL THEN po.date
|
expected_date,
|
||||||
-- For standalone receivings, try to use the receiving date from history
|
pid,
|
||||||
WHEN po.po_id LIKE 'R%' AND rs.first_received_date IS NOT NULL THEN rs.first_received_date
|
sku,
|
||||||
-- As a last resort for data integrity, use Unix epoch (Jan 1, 1970)
|
name,
|
||||||
ELSE to_timestamp(0)
|
po_cost_price,
|
||||||
END as date,
|
status,
|
||||||
NULLIF(po.expected_date::text, '0000-00-00')::date as expected_date,
|
notes,
|
||||||
po.pid,
|
long_note,
|
||||||
po.sku,
|
ordered,
|
||||||
po.name,
|
supplier_id,
|
||||||
COALESCE(ca.avg_cost, po.po_cost_price) as cost_price,
|
date_created,
|
||||||
po.po_cost_price,
|
date_ordered
|
||||||
COALESCE(po.status, 'created'),
|
FROM temp_purchase_orders
|
||||||
CASE
|
|
||||||
WHEN rs.total_received IS NULL THEN 'created'
|
|
||||||
WHEN rs.total_received = 0 THEN 'created'
|
|
||||||
WHEN rs.total_received < po.ordered THEN 'partial_received'
|
|
||||||
WHEN rs.total_received >= po.ordered THEN 'full_received'
|
|
||||||
ELSE 'created'
|
|
||||||
END as receiving_status,
|
|
||||||
po.notes,
|
|
||||||
po.long_note,
|
|
||||||
COALESCE(po.ordered, 0),
|
|
||||||
COALESCE(rs.total_received, 0),
|
|
||||||
NULLIF(rs.first_received_date::text, '0000-00-00 00:00:00')::timestamp with time zone as received_date,
|
|
||||||
NULLIF(rs.last_received_date::text, '0000-00-00 00:00:00')::timestamp with time zone as last_received_date,
|
|
||||||
CASE
|
|
||||||
WHEN rs.received_by_list IS NULL THEN NULL
|
|
||||||
ELSE rs.received_by_names
|
|
||||||
END as received_by,
|
|
||||||
rs.receiving_history
|
|
||||||
FROM temp_purchase_orders po
|
|
||||||
LEFT JOIN receiving_summaries rs ON po.po_id = rs.po_id AND po.pid = rs.pid
|
|
||||||
LEFT JOIN cost_averaging ca ON po.po_id = ca.po_id AND po.pid = ca.pid
|
|
||||||
ON CONFLICT (po_id, pid) DO UPDATE SET
|
ON CONFLICT (po_id, pid) DO UPDATE SET
|
||||||
vendor = EXCLUDED.vendor,
|
vendor = EXCLUDED.vendor,
|
||||||
date = EXCLUDED.date,
|
date = EXCLUDED.date,
|
||||||
expected_date = EXCLUDED.expected_date,
|
expected_date = EXCLUDED.expected_date,
|
||||||
sku = EXCLUDED.sku,
|
sku = EXCLUDED.sku,
|
||||||
name = EXCLUDED.name,
|
name = EXCLUDED.name,
|
||||||
cost_price = EXCLUDED.cost_price,
|
|
||||||
po_cost_price = EXCLUDED.po_cost_price,
|
po_cost_price = EXCLUDED.po_cost_price,
|
||||||
status = EXCLUDED.status,
|
status = EXCLUDED.status,
|
||||||
receiving_status = EXCLUDED.receiving_status,
|
|
||||||
notes = EXCLUDED.notes,
|
notes = EXCLUDED.notes,
|
||||||
long_note = EXCLUDED.long_note,
|
long_note = EXCLUDED.long_note,
|
||||||
ordered = EXCLUDED.ordered,
|
ordered = EXCLUDED.ordered,
|
||||||
received = EXCLUDED.received,
|
supplier_id = EXCLUDED.supplier_id,
|
||||||
received_date = EXCLUDED.received_date,
|
date_created = EXCLUDED.date_created,
|
||||||
last_received_date = EXCLUDED.last_received_date,
|
date_ordered = EXCLUDED.date_ordered,
|
||||||
received_by = EXCLUDED.received_by,
|
|
||||||
receiving_history = EXCLUDED.receiving_history,
|
|
||||||
updated = CURRENT_TIMESTAMP
|
updated = CURRENT_TIMESTAMP
|
||||||
RETURNING (xmax = 0) as inserted
|
RETURNING (xmax = 0) as inserted
|
||||||
`);
|
`);
|
||||||
|
|
||||||
recordsAdded = finalResult.rows.filter(r => r.inserted).length;
|
poRecordsAdded = poResult.rows.filter(r => r.inserted).length;
|
||||||
recordsUpdated = finalResult.rows.filter(r => !r.inserted).length;
|
poRecordsUpdated = poResult.rows.filter(r => !r.inserted).length;
|
||||||
|
|
||||||
|
// 4. Insert final receiving records to the actual table
|
||||||
|
outputProgress({
|
||||||
|
status: "running",
|
||||||
|
operation: "Purchase orders import",
|
||||||
|
message: "Inserting final receiving records"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a temp table to track receiving IDs being processed
|
||||||
|
await localConnection.query(`
|
||||||
|
DROP TABLE IF EXISTS processed_receiving_ids;
|
||||||
|
CREATE TEMP TABLE processed_receiving_ids AS (
|
||||||
|
SELECT DISTINCT receiving_id FROM temp_receivings
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Delete products that were removed from receivings and count them
|
||||||
|
const [receivingDeletedResult] = await localConnection.query(`
|
||||||
|
WITH deleted AS (
|
||||||
|
DELETE FROM receivings
|
||||||
|
WHERE receiving_id IN (SELECT receiving_id FROM processed_receiving_ids)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM temp_receivings tr
|
||||||
|
WHERE receivings.receiving_id = tr.receiving_id AND receivings.pid = tr.pid
|
||||||
|
)
|
||||||
|
RETURNING receiving_id, pid
|
||||||
|
)
|
||||||
|
SELECT COUNT(*) as count FROM deleted
|
||||||
|
`);
|
||||||
|
|
||||||
|
receivingRecordsDeleted = receivingDeletedResult.rows[0]?.count || 0;
|
||||||
|
console.log(`Deleted ${receivingRecordsDeleted} products that were removed from receivings`);
|
||||||
|
|
||||||
|
const [receivingsResult] = await localConnection.query(`
|
||||||
|
INSERT INTO receivings (
|
||||||
|
receiving_id, pid, sku, name, vendor, qty_each, qty_each_orig,
|
||||||
|
cost_each, cost_each_orig, received_by, received_by_name,
|
||||||
|
received_date, receiving_created_date, supplier_id, status
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
receiving_id,
|
||||||
|
pid,
|
||||||
|
sku,
|
||||||
|
name,
|
||||||
|
vendor,
|
||||||
|
qty_each,
|
||||||
|
qty_each_orig,
|
||||||
|
cost_each,
|
||||||
|
cost_each_orig,
|
||||||
|
received_by,
|
||||||
|
received_by_name,
|
||||||
|
COALESCE(received_date, receiving_created_date, now()) as received_date,
|
||||||
|
receiving_created_date,
|
||||||
|
supplier_id,
|
||||||
|
status
|
||||||
|
FROM temp_receivings
|
||||||
|
ON CONFLICT (receiving_id, pid) DO UPDATE SET
|
||||||
|
sku = EXCLUDED.sku,
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
vendor = EXCLUDED.vendor,
|
||||||
|
qty_each = EXCLUDED.qty_each,
|
||||||
|
qty_each_orig = EXCLUDED.qty_each_orig,
|
||||||
|
cost_each = EXCLUDED.cost_each,
|
||||||
|
cost_each_orig = EXCLUDED.cost_each_orig,
|
||||||
|
received_by = EXCLUDED.received_by,
|
||||||
|
received_by_name = EXCLUDED.received_by_name,
|
||||||
|
received_date = EXCLUDED.received_date,
|
||||||
|
receiving_created_date = EXCLUDED.receiving_created_date,
|
||||||
|
supplier_id = EXCLUDED.supplier_id,
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
updated = CURRENT_TIMESTAMP
|
||||||
|
RETURNING (xmax = 0) as inserted
|
||||||
|
`);
|
||||||
|
|
||||||
|
receivingRecordsAdded = receivingsResult.rows.filter(r => r.inserted).length;
|
||||||
|
receivingRecordsUpdated = receivingsResult.rows.filter(r => !r.inserted).length;
|
||||||
|
|
||||||
// Update sync status
|
// Update sync status
|
||||||
await localConnection.query(`
|
await localConnection.query(`
|
||||||
@@ -921,8 +824,11 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
await localConnection.query(`
|
await localConnection.query(`
|
||||||
DROP TABLE IF EXISTS temp_purchase_orders;
|
DROP TABLE IF EXISTS temp_purchase_orders;
|
||||||
DROP TABLE IF EXISTS temp_receivings;
|
DROP TABLE IF EXISTS temp_receivings;
|
||||||
DROP TABLE IF EXISTS temp_receiving_allocations;
|
|
||||||
DROP TABLE IF EXISTS employee_names;
|
DROP TABLE IF EXISTS employee_names;
|
||||||
|
DROP TABLE IF EXISTS temp_supplier_names;
|
||||||
|
DROP TABLE IF EXISTS temp_invalid_pids;
|
||||||
|
DROP TABLE IF EXISTS processed_po_ids;
|
||||||
|
DROP TABLE IF EXISTS processed_receiving_ids;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Commit transaction
|
// Commit transaction
|
||||||
@@ -930,8 +836,15 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
status: "complete",
|
status: "complete",
|
||||||
recordsAdded: recordsAdded || 0,
|
recordsAdded: poRecordsAdded + receivingRecordsAdded,
|
||||||
recordsUpdated: recordsUpdated || 0,
|
recordsUpdated: poRecordsUpdated + receivingRecordsUpdated,
|
||||||
|
recordsDeleted: poRecordsDeleted + receivingRecordsDeleted,
|
||||||
|
poRecordsAdded,
|
||||||
|
poRecordsUpdated,
|
||||||
|
poRecordsDeleted,
|
||||||
|
receivingRecordsAdded,
|
||||||
|
receivingRecordsUpdated,
|
||||||
|
receivingRecordsDeleted,
|
||||||
totalRecords: totalProcessed
|
totalRecords: totalProcessed
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -949,6 +862,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
error: error.message,
|
error: error.message,
|
||||||
recordsAdded: 0,
|
recordsAdded: 0,
|
||||||
recordsUpdated: 0,
|
recordsUpdated: 0,
|
||||||
|
recordsDeleted: 0,
|
||||||
totalRecords: 0
|
totalRecords: 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -108,47 +108,52 @@ router.get('/purchase/metrics', async (req, res) => {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
const { rows: [poMetrics] } = await executeQuery(`
|
const { rows: [poMetrics] } = await executeQuery(`
|
||||||
|
WITH po_metrics AS (
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(COUNT(DISTINCT CASE
|
po_id,
|
||||||
WHEN po.receiving_status NOT IN ('partial_received', 'full_received', 'paid')
|
status,
|
||||||
THEN po.po_id
|
date,
|
||||||
END), 0)::integer as active_pos,
|
expected_date,
|
||||||
COALESCE(COUNT(DISTINCT CASE
|
pid,
|
||||||
WHEN po.receiving_status NOT IN ('partial_received', 'full_received', 'paid')
|
ordered,
|
||||||
AND po.expected_date < CURRENT_DATE
|
po_cost_price
|
||||||
THEN po.po_id
|
|
||||||
END), 0)::integer as overdue_pos,
|
|
||||||
COALESCE(SUM(CASE
|
|
||||||
WHEN po.receiving_status NOT IN ('partial_received', 'full_received', 'paid')
|
|
||||||
THEN po.ordered
|
|
||||||
ELSE 0
|
|
||||||
END), 0)::integer as total_units,
|
|
||||||
ROUND(COALESCE(SUM(CASE
|
|
||||||
WHEN po.receiving_status NOT IN ('partial_received', 'full_received', 'paid')
|
|
||||||
THEN po.ordered * po.cost_price
|
|
||||||
ELSE 0
|
|
||||||
END), 0)::numeric, 3) as total_cost,
|
|
||||||
ROUND(COALESCE(SUM(CASE
|
|
||||||
WHEN po.receiving_status NOT IN ('partial_received', 'full_received', 'paid')
|
|
||||||
THEN po.ordered * pm.current_price
|
|
||||||
ELSE 0
|
|
||||||
END), 0)::numeric, 3) as total_retail
|
|
||||||
FROM purchase_orders po
|
FROM purchase_orders po
|
||||||
|
WHERE po.status NOT IN ('canceled', 'done')
|
||||||
|
AND po.date >= CURRENT_DATE - INTERVAL '6 months'
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT po_id)::integer as active_pos,
|
||||||
|
COUNT(DISTINCT CASE WHEN expected_date < CURRENT_DATE THEN po_id END)::integer as overdue_pos,
|
||||||
|
SUM(ordered)::integer as total_units,
|
||||||
|
ROUND(SUM(ordered * po_cost_price)::numeric, 3) as total_cost,
|
||||||
|
ROUND(SUM(ordered * pm.current_price)::numeric, 3) as total_retail
|
||||||
|
FROM po_metrics po
|
||||||
JOIN product_metrics pm ON po.pid = pm.pid
|
JOIN product_metrics pm ON po.pid = pm.pid
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const { rows: vendorOrders } = await executeQuery(`
|
const { rows: vendorOrders } = await executeQuery(`
|
||||||
|
WITH po_by_vendor AS (
|
||||||
SELECT
|
SELECT
|
||||||
po.vendor,
|
vendor,
|
||||||
COUNT(DISTINCT po.po_id)::integer as orders,
|
po_id,
|
||||||
COALESCE(SUM(po.ordered), 0)::integer as units,
|
SUM(ordered) as total_ordered,
|
||||||
ROUND(COALESCE(SUM(po.ordered * po.cost_price), 0)::numeric, 3) as cost,
|
SUM(ordered * po_cost_price) as total_cost
|
||||||
ROUND(COALESCE(SUM(po.ordered * pm.current_price), 0)::numeric, 3) as retail
|
FROM purchase_orders
|
||||||
FROM purchase_orders po
|
WHERE status NOT IN ('canceled', 'done')
|
||||||
|
AND date >= CURRENT_DATE - INTERVAL '6 months'
|
||||||
|
GROUP BY vendor, po_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
pv.vendor,
|
||||||
|
COUNT(DISTINCT pv.po_id)::integer as orders,
|
||||||
|
SUM(pv.total_ordered)::integer as units,
|
||||||
|
ROUND(SUM(pv.total_cost)::numeric, 3) as cost,
|
||||||
|
ROUND(SUM(pv.total_ordered * pm.current_price)::numeric, 3) as retail
|
||||||
|
FROM po_by_vendor pv
|
||||||
|
JOIN purchase_orders po ON pv.po_id = po.po_id
|
||||||
JOIN product_metrics pm ON po.pid = pm.pid
|
JOIN product_metrics pm ON po.pid = pm.pid
|
||||||
WHERE po.receiving_status NOT IN ('partial_received', 'full_received', 'paid')
|
GROUP BY pv.vendor
|
||||||
GROUP BY po.vendor
|
HAVING ROUND(SUM(pv.total_cost)::numeric, 3) > 0
|
||||||
HAVING ROUND(COALESCE(SUM(po.ordered * po.cost_price), 0)::numeric, 3) > 0
|
|
||||||
ORDER BY cost DESC
|
ORDER BY cost DESC
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|||||||
@@ -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
383
inventory/src/components/purchase-orders/CategoryMetricsCard.tsx
Normal file
383
inventory/src/components/purchase-orders/CategoryMetricsCard.tsx
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../components/ui/card";
|
||||||
|
import { Skeleton } from "../../components/ui/skeleton";
|
||||||
|
import { BarChart3, Loader2 } from "lucide-react";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "../../components/ui/dialog";
|
||||||
|
import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../../components/ui/table";
|
||||||
|
|
||||||
|
// Add this constant for pie chart colors
|
||||||
|
const COLORS = [
|
||||||
|
"#0088FE",
|
||||||
|
"#00C49F",
|
||||||
|
"#FFBB28",
|
||||||
|
"#FF8042",
|
||||||
|
"#8884D8",
|
||||||
|
"#82CA9D",
|
||||||
|
"#FFC658",
|
||||||
|
"#FF7C43",
|
||||||
|
];
|
||||||
|
|
||||||
|
// The renderActiveShape function for pie charts
|
||||||
|
const renderActiveShape = (props: any) => {
|
||||||
|
const {
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
innerRadius,
|
||||||
|
outerRadius,
|
||||||
|
startAngle,
|
||||||
|
endAngle,
|
||||||
|
fill,
|
||||||
|
category,
|
||||||
|
total_spend,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
// Split category name into words and create lines of max 12 chars
|
||||||
|
const words = category.split(" ");
|
||||||
|
const lines: string[] = [];
|
||||||
|
let currentLine = "";
|
||||||
|
|
||||||
|
words.forEach((word: string) => {
|
||||||
|
if ((currentLine + " " + word).length <= 12) {
|
||||||
|
currentLine = currentLine ? `${currentLine} ${word}` : word;
|
||||||
|
} else {
|
||||||
|
if (currentLine) lines.push(currentLine);
|
||||||
|
currentLine = word;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (currentLine) lines.push(currentLine);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<Sector
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
innerRadius={innerRadius}
|
||||||
|
outerRadius={outerRadius}
|
||||||
|
startAngle={startAngle}
|
||||||
|
endAngle={endAngle}
|
||||||
|
fill={fill}
|
||||||
|
/>
|
||||||
|
<Sector
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
startAngle={startAngle}
|
||||||
|
endAngle={endAngle}
|
||||||
|
innerRadius={outerRadius - 1}
|
||||||
|
outerRadius={outerRadius + 4}
|
||||||
|
fill={fill}
|
||||||
|
/>
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<text
|
||||||
|
key={i}
|
||||||
|
x={cx}
|
||||||
|
y={cy}
|
||||||
|
dy={-20 + i * 16}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="#888888"
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{line}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
<text
|
||||||
|
x={cx}
|
||||||
|
y={cy}
|
||||||
|
dy={lines.length * 16 - 10}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="#000000"
|
||||||
|
className="text-base font-medium"
|
||||||
|
>
|
||||||
|
{`$${Number(total_spend).toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}`}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CategoryMetricsCardProps {
|
||||||
|
loading: boolean;
|
||||||
|
yearlyCategoryData: {
|
||||||
|
category: string;
|
||||||
|
unique_products?: number;
|
||||||
|
total_spend: number;
|
||||||
|
percentage?: number;
|
||||||
|
avg_cost?: number;
|
||||||
|
cost_variance?: number;
|
||||||
|
}[];
|
||||||
|
yearlyDataLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CategoryMetricsCard({
|
||||||
|
loading,
|
||||||
|
yearlyCategoryData,
|
||||||
|
yearlyDataLoading,
|
||||||
|
}: CategoryMetricsCardProps) {
|
||||||
|
const [costAnalysisOpen, setCostAnalysisOpen] = useState(false);
|
||||||
|
const [activeSpendingIndex, setActiveSpendingIndex] = useState<
|
||||||
|
number | undefined
|
||||||
|
>();
|
||||||
|
const [initialLoading, setInitialLoading] = useState(true);
|
||||||
|
|
||||||
|
// Only show loading state on initial load, not during table refreshes
|
||||||
|
useEffect(() => {
|
||||||
|
if (yearlyCategoryData.length > 0 && !yearlyDataLoading) {
|
||||||
|
setInitialLoading(false);
|
||||||
|
}
|
||||||
|
}, [yearlyCategoryData, yearlyDataLoading]);
|
||||||
|
|
||||||
|
const formatNumber = (value: number) => {
|
||||||
|
return value.toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) => {
|
||||||
|
return `$${formatNumber(value)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPercent = (value: number) => {
|
||||||
|
return (
|
||||||
|
(value * 100).toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 1,
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
}) + "%"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prepare spending chart data
|
||||||
|
const prepareSpendingChartData = () => {
|
||||||
|
if (!yearlyCategoryData.length) return [];
|
||||||
|
|
||||||
|
// Make a copy to avoid modifying state directly
|
||||||
|
const categoryArray = [...yearlyCategoryData];
|
||||||
|
const totalSpend = categoryArray.reduce(
|
||||||
|
(sum, cat) => sum + cat.total_spend,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Split into significant categories (>=1%) and others
|
||||||
|
const significantCategories = categoryArray.filter(
|
||||||
|
(cat) => cat.total_spend / totalSpend >= 0.01
|
||||||
|
);
|
||||||
|
|
||||||
|
const otherCategories = categoryArray.filter(
|
||||||
|
(cat) => cat.total_spend / totalSpend < 0.01
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = [...significantCategories];
|
||||||
|
|
||||||
|
// Add "Other" category if needed
|
||||||
|
if (otherCategories.length > 0) {
|
||||||
|
const otherTotalSpend = otherCategories.reduce(
|
||||||
|
(sum, cat) => sum + cat.total_spend,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
category: "Other",
|
||||||
|
total_spend: otherTotalSpend,
|
||||||
|
percentage: otherTotalSpend / totalSpend,
|
||||||
|
unique_products: otherCategories.reduce(
|
||||||
|
(sum, cat) => sum + (cat.unique_products || 0),
|
||||||
|
0
|
||||||
|
),
|
||||||
|
avg_cost:
|
||||||
|
otherTotalSpend /
|
||||||
|
otherCategories.reduce(
|
||||||
|
(sum, cat) => sum + (cat.unique_products || 0),
|
||||||
|
1
|
||||||
|
),
|
||||||
|
cost_variance: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by spend amount descending
|
||||||
|
return result.sort((a, b) => b.total_spend - a.total_spend);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cost analysis table component
|
||||||
|
const CostAnalysisTable = () => {
|
||||||
|
if (!yearlyCategoryData.length) {
|
||||||
|
return yearlyDataLoading ? (
|
||||||
|
<div className="flex justify-center p-4">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center p-4 text-muted-foreground">
|
||||||
|
No category data available for the past 12 months
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{yearlyDataLoading ? (
|
||||||
|
<div className="flex justify-center p-4">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-sm font-medium mb-2 px-4 flex justify-between">
|
||||||
|
<span>
|
||||||
|
Showing received inventory by category for the past 12 months
|
||||||
|
</span>
|
||||||
|
<span>{yearlyCategoryData.length} categories found</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground px-4 mb-2">
|
||||||
|
Note: items can be in multiple categories, so the sum of the
|
||||||
|
categories will not equal the total spend.
|
||||||
|
</div>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Category</TableHead>
|
||||||
|
<TableHead>Products</TableHead>
|
||||||
|
<TableHead>Avg. Cost</TableHead>
|
||||||
|
<TableHead>Price Variance</TableHead>
|
||||||
|
<TableHead>Total Spend</TableHead>
|
||||||
|
<TableHead>% of Total</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{yearlyCategoryData.map((category) => {
|
||||||
|
// Calculate percentage of total spend
|
||||||
|
const totalSpendPercentage =
|
||||||
|
"percentage" in category &&
|
||||||
|
typeof category.percentage === "number"
|
||||||
|
? category.percentage
|
||||||
|
: yearlyCategoryData.reduce(
|
||||||
|
(sum, cat) => sum + cat.total_spend,
|
||||||
|
0
|
||||||
|
) > 0
|
||||||
|
? category.total_spend /
|
||||||
|
yearlyCategoryData.reduce(
|
||||||
|
(sum, cat) => sum + cat.total_spend,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={category.category}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{category.category || "Uncategorized"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{category.unique_products?.toLocaleString() || "N/A"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{category.avg_cost !== undefined
|
||||||
|
? formatCurrency(category.avg_cost)
|
||||||
|
: "N/A"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{category.cost_variance !== undefined
|
||||||
|
? parseFloat(
|
||||||
|
category.cost_variance.toFixed(2)
|
||||||
|
).toLocaleString()
|
||||||
|
: "N/A"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{formatCurrency(category.total_spend)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{formatPercent(totalSpendPercentage)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Received by Category
|
||||||
|
</CardTitle>
|
||||||
|
<Dialog open={costAnalysisOpen} onOpenChange={setCostAnalysisOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" disabled={initialLoading || loading}>
|
||||||
|
<BarChart3 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-[90%] w-fit">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<BarChart3 className="h-5 w-5" />
|
||||||
|
<span>Received Inventory by Category</span>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="overflow-auto max-h-[70vh]">
|
||||||
|
<CostAnalysisTable />
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{initialLoading || loading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-[170px]">
|
||||||
|
<Skeleton className="h-[170px] w-[170px] rounded-full" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="h-[170px] relative">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart margin={{ top: 30, right: 0, left: 0, bottom: 30 }}>
|
||||||
|
<Pie
|
||||||
|
data={prepareSpendingChartData()}
|
||||||
|
dataKey="total_spend"
|
||||||
|
nameKey="category"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={60}
|
||||||
|
outerRadius={80}
|
||||||
|
paddingAngle={1}
|
||||||
|
activeIndex={activeSpendingIndex}
|
||||||
|
activeShape={renderActiveShape}
|
||||||
|
onMouseEnter={(_, index) => setActiveSpendingIndex(index)}
|
||||||
|
onMouseLeave={() => setActiveSpendingIndex(undefined)}
|
||||||
|
>
|
||||||
|
{prepareSpendingChartData().map((entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={entry.category}
|
||||||
|
fill={COLORS[index % COLORS.length]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
inventory/src/components/purchase-orders/FilterControls.tsx
Normal file
155
inventory/src/components/purchase-orders/FilterControls.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { Input } from "../../components/ui/input";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "../../components/ui/select";
|
||||||
|
import {
|
||||||
|
PurchaseOrderStatus,
|
||||||
|
getPurchaseOrderStatusLabel
|
||||||
|
} from "../../types/status-codes";
|
||||||
|
|
||||||
|
interface FilterControlsProps {
|
||||||
|
searchInput: string;
|
||||||
|
setSearchInput: (value: string) => void;
|
||||||
|
filterValues: {
|
||||||
|
search: string;
|
||||||
|
status: string;
|
||||||
|
vendor: string;
|
||||||
|
recordType: string;
|
||||||
|
};
|
||||||
|
handleStatusChange: (value: string) => void;
|
||||||
|
handleVendorChange: (value: string) => void;
|
||||||
|
handleRecordTypeChange: (value: string) => void;
|
||||||
|
clearFilters: () => void;
|
||||||
|
filterOptions: {
|
||||||
|
vendors: string[];
|
||||||
|
statuses: number[];
|
||||||
|
};
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_FILTER_OPTIONS = [
|
||||||
|
{ value: "all", label: "All Statuses" },
|
||||||
|
{
|
||||||
|
value: String(PurchaseOrderStatus.Created),
|
||||||
|
label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.Created),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: String(PurchaseOrderStatus.ElectronicallyReadySend),
|
||||||
|
label: getPurchaseOrderStatusLabel(
|
||||||
|
PurchaseOrderStatus.ElectronicallyReadySend
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: String(PurchaseOrderStatus.Ordered),
|
||||||
|
label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.Ordered),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: String(PurchaseOrderStatus.ReceivingStarted),
|
||||||
|
label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.ReceivingStarted),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: String(PurchaseOrderStatus.Done),
|
||||||
|
label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.Done),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: String(PurchaseOrderStatus.Canceled),
|
||||||
|
label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.Canceled),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const RECORD_TYPE_FILTER_OPTIONS = [
|
||||||
|
{ value: "all", label: "All Records" },
|
||||||
|
{ value: "po_only", label: "PO Only" },
|
||||||
|
{ value: "po_with_receiving", label: "PO with Receiving" },
|
||||||
|
{ value: "receiving_only", label: "Receiving Only" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function FilterControls({
|
||||||
|
searchInput,
|
||||||
|
setSearchInput,
|
||||||
|
filterValues,
|
||||||
|
handleStatusChange,
|
||||||
|
handleVendorChange,
|
||||||
|
handleRecordTypeChange,
|
||||||
|
clearFilters,
|
||||||
|
filterOptions,
|
||||||
|
loading,
|
||||||
|
}: FilterControlsProps) {
|
||||||
|
return (
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-4">
|
||||||
|
<Input
|
||||||
|
placeholder="Search orders..."
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
className="max-w-xs"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={filterValues.status}
|
||||||
|
onValueChange={handleStatusChange}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="Select status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{STATUS_FILTER_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
value={filterValues.vendor}
|
||||||
|
onValueChange={handleVendorChange}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="Select supplier" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Suppliers</SelectItem>
|
||||||
|
{filterOptions?.vendors?.map((vendor) => (
|
||||||
|
<SelectItem key={vendor} value={vendor}>
|
||||||
|
{vendor}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
value={filterValues.recordType}
|
||||||
|
onValueChange={handleRecordTypeChange}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="Record Type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{RECORD_TYPE_FILTER_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{(filterValues.search || filterValues.status !== "all" || filterValues.vendor !== "all" || filterValues.recordType !== "all") && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearFilters}
|
||||||
|
disabled={loading}
|
||||||
|
title="Clear filters"
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
<span>Clear</span> ✕
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
inventory/src/components/purchase-orders/OrderMetricsCard.tsx
Normal file
122
inventory/src/components/purchase-orders/OrderMetricsCard.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../components/ui/card";
|
||||||
|
import { Skeleton } from "../../components/ui/skeleton";
|
||||||
|
|
||||||
|
type ReceivingStatus = {
|
||||||
|
order_count: number;
|
||||||
|
total_ordered: number;
|
||||||
|
total_received: number;
|
||||||
|
fulfillment_rate: number;
|
||||||
|
total_value: number;
|
||||||
|
avg_cost: number;
|
||||||
|
avg_delivery_days?: number;
|
||||||
|
max_delivery_days?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface OrderMetricsCardProps {
|
||||||
|
summary: ReceivingStatus | null;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrderMetricsCard({
|
||||||
|
summary,
|
||||||
|
loading,
|
||||||
|
}: OrderMetricsCardProps) {
|
||||||
|
const [initialLoading, setInitialLoading] = useState(true);
|
||||||
|
|
||||||
|
// Only show loading state on initial load, not during table refreshes
|
||||||
|
useEffect(() => {
|
||||||
|
if (summary) {
|
||||||
|
setInitialLoading(false);
|
||||||
|
}
|
||||||
|
}, [summary]);
|
||||||
|
|
||||||
|
const formatNumber = (value: number) => {
|
||||||
|
return value.toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) => {
|
||||||
|
return `$${formatNumber(value)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPercent = (value: number) => {
|
||||||
|
return (
|
||||||
|
(value * 100).toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 1,
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
}) + "%"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Order Metrics</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{initialLoading || loading ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{/* 5 rows of skeleton metrics */}
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<div key={i} className="flex items-baseline justify-between">
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-6 w-16" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
Avg. Cost per PO
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold">
|
||||||
|
{formatCurrency(summary?.avg_cost || 0)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
Overall Fulfillment Rate
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold">
|
||||||
|
{formatPercent(summary?.fulfillment_rate || 0)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
Total Orders
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold">
|
||||||
|
{summary?.order_count.toLocaleString() || 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
Avg. Delivery Days
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold">
|
||||||
|
{summary?.avg_delivery_days ? summary.avg_delivery_days.toFixed(1) : "N/A"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
Longest Delivery Days
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold">
|
||||||
|
{summary?.max_delivery_days ? summary.max_delivery_days.toFixed(0) : "N/A"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
inventory/src/components/purchase-orders/PaginationControls.tsx
Normal file
140
inventory/src/components/purchase-orders/PaginationControls.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from "../../components/ui/pagination";
|
||||||
|
|
||||||
|
interface PaginationControlsProps {
|
||||||
|
pagination: {
|
||||||
|
total: number;
|
||||||
|
pages: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
};
|
||||||
|
currentPage: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PaginationControls({
|
||||||
|
pagination,
|
||||||
|
currentPage,
|
||||||
|
onPageChange,
|
||||||
|
}: PaginationControlsProps) {
|
||||||
|
// Generate pagination items
|
||||||
|
const getPaginationItems = () => {
|
||||||
|
const items = [];
|
||||||
|
const totalPages = pagination.pages;
|
||||||
|
|
||||||
|
// Always show first page
|
||||||
|
if (totalPages > 0) {
|
||||||
|
items.push(
|
||||||
|
<PaginationItem key="first">
|
||||||
|
<PaginationLink
|
||||||
|
isActive={currentPage === 1}
|
||||||
|
onClick={() => currentPage !== 1 && onPageChange(1)}
|
||||||
|
>
|
||||||
|
1
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add ellipsis if needed
|
||||||
|
if (currentPage > 3) {
|
||||||
|
items.push(
|
||||||
|
<PaginationItem key="ellipsis-1">
|
||||||
|
<PaginationEllipsis />
|
||||||
|
</PaginationItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add pages around current page
|
||||||
|
const startPage = Math.max(2, currentPage - 1);
|
||||||
|
const endPage = Math.min(totalPages - 1, currentPage + 1);
|
||||||
|
|
||||||
|
for (let i = startPage; i <= endPage; i++) {
|
||||||
|
if (i <= 1 || i >= totalPages) continue; // Skip first and last page as they're handled separately
|
||||||
|
items.push(
|
||||||
|
<PaginationItem key={i}>
|
||||||
|
<PaginationLink
|
||||||
|
isActive={currentPage === i}
|
||||||
|
onClick={() => currentPage !== i && onPageChange(i)}
|
||||||
|
>
|
||||||
|
{i}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add ellipsis if needed
|
||||||
|
if (currentPage < totalPages - 2) {
|
||||||
|
items.push(
|
||||||
|
<PaginationItem key="ellipsis-2">
|
||||||
|
<PaginationEllipsis />
|
||||||
|
</PaginationItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always show last page if there are multiple pages
|
||||||
|
if (totalPages > 1) {
|
||||||
|
items.push(
|
||||||
|
<PaginationItem key="last">
|
||||||
|
<PaginationLink
|
||||||
|
isActive={currentPage === totalPages}
|
||||||
|
onClick={() => currentPage !== totalPages && onPageChange(totalPages)}
|
||||||
|
>
|
||||||
|
{totalPages}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (pagination.pages <= 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center mb-6">
|
||||||
|
<Pagination>
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (currentPage > 1) onPageChange(currentPage - 1);
|
||||||
|
}}
|
||||||
|
aria-disabled={currentPage === 1}
|
||||||
|
className={currentPage === 1 ? "pointer-events-none opacity-50" : ""}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
|
||||||
|
{getPaginationItems()}
|
||||||
|
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (currentPage < pagination.pages) onPageChange(currentPage + 1);
|
||||||
|
}}
|
||||||
|
aria-disabled={currentPage === pagination.pages}
|
||||||
|
className={
|
||||||
|
currentPage === pagination.pages
|
||||||
|
? "pointer-events-none opacity-50"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../ui/table";
|
||||||
|
import { Skeleton } from "../ui/skeleton";
|
||||||
|
|
||||||
|
// Define the structure of purchase order items
|
||||||
|
interface PurchaseOrderItem {
|
||||||
|
id: string | number;
|
||||||
|
pid: string | number;
|
||||||
|
product_name: string;
|
||||||
|
sku: string;
|
||||||
|
upc: string;
|
||||||
|
ordered: number;
|
||||||
|
received: number;
|
||||||
|
po_cost_price: number;
|
||||||
|
cost_each?: number; // For receiving items
|
||||||
|
qty_each?: number; // For receiving items
|
||||||
|
total_cost: number;
|
||||||
|
receiving_status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PurchaseOrder {
|
||||||
|
id: number | string;
|
||||||
|
vendor_name: string;
|
||||||
|
order_date: string | null;
|
||||||
|
receiving_date: string | null;
|
||||||
|
status: number;
|
||||||
|
total_items: number;
|
||||||
|
total_quantity: number;
|
||||||
|
total_cost: number;
|
||||||
|
total_received: number;
|
||||||
|
fulfillment_rate: number;
|
||||||
|
short_note: string | null;
|
||||||
|
record_type: "po_only" | "po_with_receiving" | "receiving_only";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PurchaseOrderAccordionProps {
|
||||||
|
purchaseOrder: PurchaseOrder;
|
||||||
|
children: React.ReactNode;
|
||||||
|
rowClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PurchaseOrderAccordion({
|
||||||
|
purchaseOrder,
|
||||||
|
children,
|
||||||
|
rowClassName,
|
||||||
|
}: PurchaseOrderAccordionProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [items, setItems] = useState<PurchaseOrderItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Clone the TableRow (children) and add the onClick handler and className
|
||||||
|
const enhancedRow = React.cloneElement(children as React.ReactElement, {
|
||||||
|
onClick: () => setIsOpen(!isOpen),
|
||||||
|
className: `${(children as React.ReactElement).props.className || ""} cursor-pointer ${isOpen ? 'bg-gray-100' : ''} ${rowClassName || ""}`.trim(),
|
||||||
|
"data-state": isOpen ? "open" : "closed"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Format currency
|
||||||
|
const formatCurrency = (value: number) => {
|
||||||
|
return `$${value.toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Only fetch items when the accordion is open
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const fetchItems = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Endpoint path will depend on the type of record
|
||||||
|
const endpoint = purchaseOrder.record_type === "receiving_only"
|
||||||
|
? `/api/purchase-orders/receiving/${purchaseOrder.id}/items`
|
||||||
|
: `/api/purchase-orders/${purchaseOrder.id}/items`;
|
||||||
|
|
||||||
|
const response = await fetch(endpoint);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch items: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setItems(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching purchase order items:", err);
|
||||||
|
setError(err instanceof Error ? err.message : "Unknown error occurred");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchItems();
|
||||||
|
}, [purchaseOrder.id, purchaseOrder.record_type, isOpen]);
|
||||||
|
|
||||||
|
// Render purchase order items list
|
||||||
|
const renderItemsList = () => {
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 text-red-500">
|
||||||
|
Error loading items: {error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-h-[600px] overflow-y-auto bg-gray-50 rounded-md p-2">
|
||||||
|
<Table className="w-full">
|
||||||
|
<TableHeader className="bg-white sticky top-0 z-10">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[100px]">Item Number</TableHead>
|
||||||
|
<TableHead className="w-auto">Product</TableHead>
|
||||||
|
<TableHead className="w-[100px]">UPC</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-right">Ordered</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-right">Received</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-right">Unit Cost</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-right">Total Cost</TableHead>
|
||||||
|
{purchaseOrder.record_type !== "po_only" && (
|
||||||
|
<TableHead className="w-[120px] text-right">Status</TableHead>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
// Loading skeleton
|
||||||
|
Array(5).fill(0).map((_, i) => (
|
||||||
|
<TableRow key={`skeleton-${i}`}>
|
||||||
|
<TableCell><Skeleton className="h-5 w-full" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-5 w-full" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-5 w-full" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-5 w-full" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-5 w-full" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-5 w-full" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-5 w-full" /></TableCell>
|
||||||
|
{purchaseOrder.record_type !== "po_only" && (
|
||||||
|
<TableCell><Skeleton className="h-5 w-full" /></TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
items.map((item) => (
|
||||||
|
<TableRow key={item.id} className="hover:bg-gray-100">
|
||||||
|
<TableCell className="">
|
||||||
|
<a
|
||||||
|
href={`https://backend.acherryontop.com/product/${item.pid}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:underline"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{item.sku}
|
||||||
|
</a>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<a
|
||||||
|
href={`https://backend.acherryontop.com/product/${item.pid}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:underline"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{item.product_name}
|
||||||
|
</a>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="">
|
||||||
|
<a
|
||||||
|
href={`https://backend.acherryontop.com/product/${item.pid}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:underline"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{item.upc}
|
||||||
|
</a>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{item.ordered}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{item.received || 0}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatCurrency(item.po_cost_price || item.cost_each || 0)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatCurrency(item.total_cost)}
|
||||||
|
</TableCell>
|
||||||
|
{purchaseOrder.record_type !== "po_only" && (
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{item.receiving_status || "Unknown"}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && items.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={purchaseOrder.record_type === "po_only" ? 7 : 8} className="text-center py-4 text-muted-foreground">
|
||||||
|
No items found for this order
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* First render the row which will serve as the trigger */}
|
||||||
|
{enhancedRow}
|
||||||
|
|
||||||
|
{/* Then render the accordion content conditionally if open */}
|
||||||
|
{isOpen && (
|
||||||
|
<TableRow className="p-0 border-0">
|
||||||
|
<TableCell colSpan={12} className="p-0 border-0">
|
||||||
|
<div className="pt-2 pb-4 px-4 bg-gray-50 border-t border-b">
|
||||||
|
<div className="mb-2 text-sm text-muted-foreground">
|
||||||
|
{purchaseOrder.total_items} product{purchaseOrder.total_items !== 1 ? "s" : ""} in this {purchaseOrder.record_type === "receiving_only" ? "receiving" : "purchase order"}
|
||||||
|
</div>
|
||||||
|
{renderItemsList()}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
436
inventory/src/components/purchase-orders/PurchaseOrdersTable.tsx
Normal file
436
inventory/src/components/purchase-orders/PurchaseOrdersTable.tsx
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../ui/table";
|
||||||
|
import { Badge } from "../ui/badge";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Skeleton } from "../ui/skeleton";
|
||||||
|
import { FileText } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "../ui/tooltip";
|
||||||
|
import {
|
||||||
|
getPurchaseOrderStatusLabel,
|
||||||
|
getReceivingStatusLabel,
|
||||||
|
getPurchaseOrderStatusVariant,
|
||||||
|
getReceivingStatusVariant,
|
||||||
|
} from "../../types/status-codes";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../ui/card";
|
||||||
|
import PurchaseOrderAccordion from "./PurchaseOrderAccordion";
|
||||||
|
|
||||||
|
interface PurchaseOrder {
|
||||||
|
id: number | string;
|
||||||
|
vendor_name: string;
|
||||||
|
order_date: string | null;
|
||||||
|
receiving_date: string | null;
|
||||||
|
status: number;
|
||||||
|
total_items: number;
|
||||||
|
total_quantity: number;
|
||||||
|
total_cost: number;
|
||||||
|
total_received: number;
|
||||||
|
fulfillment_rate: number;
|
||||||
|
short_note: string | null;
|
||||||
|
record_type: "po_only" | "po_with_receiving" | "receiving_only";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PurchaseOrdersTableProps {
|
||||||
|
purchaseOrders: PurchaseOrder[];
|
||||||
|
loading: boolean;
|
||||||
|
summary: { order_count: number } | null;
|
||||||
|
sortColumn: string;
|
||||||
|
sortDirection: "asc" | "desc";
|
||||||
|
handleSort: (column: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PurchaseOrdersTable({
|
||||||
|
purchaseOrders,
|
||||||
|
loading,
|
||||||
|
summary,
|
||||||
|
sortColumn,
|
||||||
|
sortDirection,
|
||||||
|
handleSort
|
||||||
|
}: PurchaseOrdersTableProps) {
|
||||||
|
// Helper functions
|
||||||
|
const getRecordTypeIndicator = (recordType: string) => {
|
||||||
|
switch (recordType) {
|
||||||
|
case "po_with_receiving":
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="flex items-center justify-center border-green-500 text-green-700 bg-green-50 px-0 text-xs w-[85px]"
|
||||||
|
>
|
||||||
|
Received PO
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
case "po_only":
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="flex items-center justify-center border-blue-500 text-blue-700 bg-blue-50 px-0 text-xs w-[85px]"
|
||||||
|
>
|
||||||
|
PO
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
case "receiving_only":
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="flex items-center justify-center border-amber-500 text-amber-700 bg-amber-50 px-0 text-xs w-[85px]"
|
||||||
|
>
|
||||||
|
Receiving
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="flex items-center justify-center border-gray-500 text-gray-700 bg-gray-50 px-0 text-xs w-[85px]"
|
||||||
|
>
|
||||||
|
{recordType || "Unknown"}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: number, recordType: string) => {
|
||||||
|
if (recordType === "receiving_only") {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
className="w-[115px] flex items-center text-xs justify-center px-1"
|
||||||
|
variant={getReceivingStatusVariant(status)}
|
||||||
|
>
|
||||||
|
{getReceivingStatusLabel(status)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
className="w-[115px] flex items-center text-xs justify-center px-1"
|
||||||
|
variant={getPurchaseOrderStatusVariant(status)}
|
||||||
|
>
|
||||||
|
{getPurchaseOrderStatusLabel(status)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNumber = (value: number) => {
|
||||||
|
return value.toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) => {
|
||||||
|
return `$${formatNumber(value)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPercent = (value: number) => {
|
||||||
|
return (
|
||||||
|
(value * 100).toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 1,
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
}) + "%"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update sort indicators in table headers
|
||||||
|
const getSortIndicator = (column: string) => {
|
||||||
|
if (sortColumn !== column) return null;
|
||||||
|
return sortDirection === "asc" ? " ↑" : " ↓";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle>Purchase Orders & Receivings</CardTitle>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
) : (
|
||||||
|
`${summary?.order_count.toLocaleString()} orders`
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table
|
||||||
|
className="table-fixed"
|
||||||
|
style={{ tableLayout: "fixed", width: "100%"}}
|
||||||
|
>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[100px] text-center">Type</TableHead>
|
||||||
|
<TableHead className="w-[60px] text-center">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => !loading && handleSort("id")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
ID{getSortIndicator("id")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[140px] text-center">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => !loading && handleSort("vendor_name")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Supplier{getSortIndicator("vendor_name")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[115px] text-center">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => !loading && handleSort("status")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Status{getSortIndicator("status")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[150px] text-center">Note</TableHead>
|
||||||
|
<TableHead className="w-[90px] text-center">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => !loading && handleSort("total_cost")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Total Cost{getSortIndicator("total_cost")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[70px] text-center">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => !loading && handleSort("total_items")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Products{getSortIndicator("total_items")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[90px] text-center">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => !loading && handleSort("order_date")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Order Date{getSortIndicator("order_date")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[90px] text-center">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => !loading && handleSort("receiving_date")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Rec'd Date{getSortIndicator("receiving_date")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[70px] text-center">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => !loading && handleSort("total_quantity")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Ordered{getSortIndicator("total_quantity")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-center">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => !loading && handleSort("total_received")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Received{getSortIndicator("total_received")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-center">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => !loading && handleSort("fulfillment_rate")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
% Fulfilled{getSortIndicator("fulfillment_rate")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
// Skeleton rows for loading state
|
||||||
|
Array(50)
|
||||||
|
.fill(0)
|
||||||
|
.map((_, index) => (
|
||||||
|
<TableRow key={`skeleton-${index}`}>
|
||||||
|
<TableCell className="w-[100px]">
|
||||||
|
<Skeleton className="h-6 w-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[60px]">
|
||||||
|
<Skeleton className="h-5 w-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[140px]">
|
||||||
|
<Skeleton className="h-5 w-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[115px]">
|
||||||
|
<Skeleton className="h-6 w-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[150px]">
|
||||||
|
<Skeleton className="h-5 w-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[90px]">
|
||||||
|
<Skeleton className="h-5 w-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[70px]">
|
||||||
|
<Skeleton className="h-5 w-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[90px]">
|
||||||
|
<Skeleton className="h-5 w-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[90px]">
|
||||||
|
<Skeleton className="h-5 w-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[70px]">
|
||||||
|
<Skeleton className="h-5 w-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[80px]">
|
||||||
|
<Skeleton className="h-5 w-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[80px]">
|
||||||
|
<Skeleton className="h-5 w-full" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : purchaseOrders.length > 0 ? (
|
||||||
|
purchaseOrders.map((po) => {
|
||||||
|
// Determine row styling based on record type
|
||||||
|
let rowClassName = "border-l-4 border-l-gray-300"; // Default
|
||||||
|
|
||||||
|
if (po.record_type === "po_with_receiving") {
|
||||||
|
rowClassName = "border-l-4 border-l-green-500";
|
||||||
|
} else if (po.record_type === "po_only") {
|
||||||
|
rowClassName = "border-l-4 border-l-blue-500";
|
||||||
|
} else if (po.record_type === "receiving_only") {
|
||||||
|
rowClassName = "border-l-4 border-l-amber-500";
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<PurchaseOrderAccordion
|
||||||
|
key={`${po.id}-${po.record_type}`}
|
||||||
|
purchaseOrder={po}
|
||||||
|
rowClassName={rowClassName}
|
||||||
|
>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{getRecordTypeIndicator(po.record_type)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-semibold text-center">
|
||||||
|
<a
|
||||||
|
href={po.record_type === "po_only"
|
||||||
|
? `https://backend.acherryontop.com/po/edit/${po.id}`
|
||||||
|
: `https://backend.acherryontop.com/receiving/edit/${po.id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
{po.id}
|
||||||
|
</a>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{po.vendor_name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{getStatusBadge(po.status, po.record_type)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="truncate text-center">
|
||||||
|
{po.short_note ? (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger className="text-left flex items-center gap-1">
|
||||||
|
<FileText className="h-3 w-3" />
|
||||||
|
<span className="truncate">
|
||||||
|
{po.short_note}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{po.short_note}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
) : (
|
||||||
|
""
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatCurrency(po.total_cost)}</TableCell>
|
||||||
|
<TableCell className="text-center">{po.total_items.toLocaleString()}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{po.order_date
|
||||||
|
? new Date(po.order_date).toLocaleDateString(
|
||||||
|
"en-US",
|
||||||
|
{
|
||||||
|
month: "numeric",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{po.receiving_date
|
||||||
|
? new Date(po.receiving_date).toLocaleDateString(
|
||||||
|
"en-US",
|
||||||
|
{
|
||||||
|
month: "numeric",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{po.record_type === "receiving_only" ? "-" : po.total_quantity.toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{po.record_type === "po_only" ? "-" : po.total_received.toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center" >
|
||||||
|
{po.record_type === "po_with_receiving"
|
||||||
|
? (po.fulfillment_rate === null ? "N/A" : formatPercent(po.fulfillment_rate))
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</PurchaseOrderAccordion>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={12}
|
||||||
|
className="text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
No purchase orders found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
354
inventory/src/components/purchase-orders/VendorMetricsCard.tsx
Normal file
354
inventory/src/components/purchase-orders/VendorMetricsCard.tsx
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../components/ui/card";
|
||||||
|
import { Skeleton } from "../../components/ui/skeleton";
|
||||||
|
import { BarChart3, Loader2 } from "lucide-react";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "../../components/ui/dialog";
|
||||||
|
import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../../components/ui/table";
|
||||||
|
|
||||||
|
// Add this constant for pie chart colors
|
||||||
|
const COLORS = [
|
||||||
|
"#0088FE",
|
||||||
|
"#00C49F",
|
||||||
|
"#FFBB28",
|
||||||
|
"#FF8042",
|
||||||
|
"#8884D8",
|
||||||
|
"#82CA9D",
|
||||||
|
"#FFC658",
|
||||||
|
"#FF7C43",
|
||||||
|
];
|
||||||
|
|
||||||
|
// The renderActiveShape function for pie charts
|
||||||
|
const renderActiveShape = (props: any) => {
|
||||||
|
const {
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
innerRadius,
|
||||||
|
outerRadius,
|
||||||
|
startAngle,
|
||||||
|
endAngle,
|
||||||
|
fill,
|
||||||
|
category,
|
||||||
|
total_spend,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
// Split category name into words and create lines of max 12 chars
|
||||||
|
const words = category.split(" ");
|
||||||
|
const lines: string[] = [];
|
||||||
|
let currentLine = "";
|
||||||
|
|
||||||
|
words.forEach((word: string) => {
|
||||||
|
if ((currentLine + " " + word).length <= 12) {
|
||||||
|
currentLine = currentLine ? `${currentLine} ${word}` : word;
|
||||||
|
} else {
|
||||||
|
if (currentLine) lines.push(currentLine);
|
||||||
|
currentLine = word;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (currentLine) lines.push(currentLine);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<Sector
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
innerRadius={innerRadius}
|
||||||
|
outerRadius={outerRadius}
|
||||||
|
startAngle={startAngle}
|
||||||
|
endAngle={endAngle}
|
||||||
|
fill={fill}
|
||||||
|
/>
|
||||||
|
<Sector
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
startAngle={startAngle}
|
||||||
|
endAngle={endAngle}
|
||||||
|
innerRadius={outerRadius - 1}
|
||||||
|
outerRadius={outerRadius + 4}
|
||||||
|
fill={fill}
|
||||||
|
/>
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<text
|
||||||
|
key={i}
|
||||||
|
x={cx}
|
||||||
|
y={cy}
|
||||||
|
dy={-20 + i * 16}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="#888888"
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{line}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
<text
|
||||||
|
x={cx}
|
||||||
|
y={cy}
|
||||||
|
dy={lines.length * 16 - 10}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="#000000"
|
||||||
|
className="text-base font-medium"
|
||||||
|
>
|
||||||
|
{`$${Number(total_spend).toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}`}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface VendorMetricsCardProps {
|
||||||
|
loading: boolean;
|
||||||
|
yearlyVendorData: {
|
||||||
|
vendor: string;
|
||||||
|
orders: number;
|
||||||
|
total_spend: number;
|
||||||
|
percentage?: number;
|
||||||
|
}[];
|
||||||
|
yearlyDataLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VendorMetricsCard({
|
||||||
|
loading,
|
||||||
|
yearlyVendorData,
|
||||||
|
yearlyDataLoading,
|
||||||
|
}: VendorMetricsCardProps) {
|
||||||
|
const [vendorAnalysisOpen, setVendorAnalysisOpen] = useState(false);
|
||||||
|
const [activeVendorIndex, setActiveVendorIndex] = useState<
|
||||||
|
number | undefined
|
||||||
|
>();
|
||||||
|
const [initialLoading, setInitialLoading] = useState(true);
|
||||||
|
|
||||||
|
// Only show loading state on initial load, not during table refreshes
|
||||||
|
useEffect(() => {
|
||||||
|
if (yearlyVendorData.length > 0 && !yearlyDataLoading) {
|
||||||
|
setInitialLoading(false);
|
||||||
|
}
|
||||||
|
}, [yearlyVendorData, yearlyDataLoading]);
|
||||||
|
|
||||||
|
const formatNumber = (value: number) => {
|
||||||
|
return value.toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) => {
|
||||||
|
return `$${formatNumber(value)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPercent = (value: number) => {
|
||||||
|
return (
|
||||||
|
(value * 100).toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 1,
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
}) + "%"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prepare vendor chart data
|
||||||
|
const prepareVendorChartData = () => {
|
||||||
|
if (!yearlyVendorData.length) return [];
|
||||||
|
|
||||||
|
// Make a copy to avoid modifying state directly
|
||||||
|
const vendorArray = [...yearlyVendorData];
|
||||||
|
const totalSpend = vendorArray.reduce(
|
||||||
|
(sum, vendor) => sum + vendor.total_spend,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Split into significant vendors (>=1%) and others
|
||||||
|
const significantVendors = vendorArray.filter(
|
||||||
|
(vendor) => vendor.total_spend / totalSpend >= 0.01
|
||||||
|
);
|
||||||
|
|
||||||
|
const otherVendors = vendorArray.filter(
|
||||||
|
(vendor) => vendor.total_spend / totalSpend < 0.01
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = [...significantVendors];
|
||||||
|
|
||||||
|
// Add "Other" category if needed
|
||||||
|
if (otherVendors.length > 0) {
|
||||||
|
const otherTotalSpend = otherVendors.reduce(
|
||||||
|
(sum, vendor) => sum + vendor.total_spend,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
vendor: "Other Vendors",
|
||||||
|
total_spend: otherTotalSpend,
|
||||||
|
percentage: otherTotalSpend / totalSpend,
|
||||||
|
orders: otherVendors.reduce((sum, vendor) => sum + vendor.orders, 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by spend amount descending
|
||||||
|
return result.sort((a, b) => b.total_spend - a.total_spend);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get all vendors for table
|
||||||
|
const getAllVendorsForTable = () => {
|
||||||
|
if (!yearlyVendorData.length) return [];
|
||||||
|
return [...yearlyVendorData].sort((a, b) => b.total_spend - a.total_spend);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Vendor analysis table component
|
||||||
|
const VendorAnalysisTable = () => {
|
||||||
|
const vendorData = getAllVendorsForTable();
|
||||||
|
|
||||||
|
if (!vendorData.length) {
|
||||||
|
return yearlyDataLoading ? (
|
||||||
|
<div className="flex justify-center p-4">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center p-4 text-muted-foreground">
|
||||||
|
No supplier data available for the past 12 months
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{yearlyDataLoading ? (
|
||||||
|
<div className="flex justify-center p-4">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-sm font-medium mb-2 flex justify-between items-center px-4">
|
||||||
|
<span>
|
||||||
|
Showing received inventory by supplier for the past 12 months
|
||||||
|
</span>
|
||||||
|
<span>{vendorData.length} suppliers found</span>
|
||||||
|
</div>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Supplier</TableHead>
|
||||||
|
<TableHead>Orders</TableHead>
|
||||||
|
<TableHead>Total Spend</TableHead>
|
||||||
|
<TableHead>% of Total</TableHead>
|
||||||
|
<TableHead>Avg. Order Value</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{vendorData.map((vendor) => {
|
||||||
|
return (
|
||||||
|
<TableRow key={vendor.vendor}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{vendor.vendor}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{vendor.orders.toLocaleString()}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{formatCurrency(vendor.total_spend)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{formatPercent(vendor.percentage || 0)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{formatCurrency(
|
||||||
|
vendor.orders ? vendor.total_spend / vendor.orders : 0
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Received by Supplier
|
||||||
|
</CardTitle>
|
||||||
|
<Dialog
|
||||||
|
open={vendorAnalysisOpen}
|
||||||
|
onOpenChange={setVendorAnalysisOpen}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" disabled={initialLoading || loading}>
|
||||||
|
<BarChart3 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-[90%] w-fit">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<BarChart3 className="h-5 w-5" />
|
||||||
|
<span>Received Inventory by Supplier</span>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="overflow-auto max-h-[70vh]">
|
||||||
|
<VendorAnalysisTable />
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{initialLoading || loading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-[170px]">
|
||||||
|
<Skeleton className="h-[170px] w-[170px] rounded-full" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="h-[170px] relative">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={prepareVendorChartData()}
|
||||||
|
dataKey="total_spend"
|
||||||
|
nameKey="vendor"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={60}
|
||||||
|
outerRadius={80}
|
||||||
|
paddingAngle={1}
|
||||||
|
activeIndex={activeVendorIndex}
|
||||||
|
activeShape={(props: any) =>
|
||||||
|
renderActiveShape({ ...props, category: props.vendor })
|
||||||
|
}
|
||||||
|
onMouseEnter={(_, index) => setActiveVendorIndex(index)}
|
||||||
|
onMouseLeave={() => setActiveVendorIndex(undefined)}
|
||||||
|
>
|
||||||
|
{prepareVendorChartData().map((entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={entry.vendor}
|
||||||
|
fill={COLORS[index % COLORS.length]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,50 +1,31 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useRef, useMemo } from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
|
import OrderMetricsCard from "../components/purchase-orders/OrderMetricsCard";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../components/ui/table';
|
import VendorMetricsCard from "../components/purchase-orders/VendorMetricsCard";
|
||||||
import { Loader2, ArrowUpDown } from 'lucide-react';
|
import CategoryMetricsCard from "../components/purchase-orders/CategoryMetricsCard";
|
||||||
import { Button } from '../components/ui/button';
|
import PaginationControls from "../components/purchase-orders/PaginationControls";
|
||||||
import { Input } from '../components/ui/input';
|
import PurchaseOrdersTable from "../components/purchase-orders/PurchaseOrdersTable";
|
||||||
import { Badge } from '../components/ui/badge';
|
import FilterControls from "../components/purchase-orders/FilterControls";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '../components/ui/select';
|
|
||||||
import {
|
|
||||||
Pagination,
|
|
||||||
PaginationContent,
|
|
||||||
PaginationItem,
|
|
||||||
PaginationNext,
|
|
||||||
PaginationPrevious,
|
|
||||||
} from '../components/ui/pagination';
|
|
||||||
import { motion } from 'motion/react';
|
|
||||||
import {
|
|
||||||
PurchaseOrderStatus,
|
|
||||||
getPurchaseOrderStatusLabel,
|
|
||||||
getReceivingStatusLabel,
|
|
||||||
getPurchaseOrderStatusVariant,
|
|
||||||
getReceivingStatusVariant
|
|
||||||
} from '../types/status-codes';
|
|
||||||
|
|
||||||
interface PurchaseOrder {
|
interface PurchaseOrder {
|
||||||
id: number;
|
id: number | string;
|
||||||
vendor_name: string;
|
vendor_name: string;
|
||||||
order_date: string;
|
order_date: string | null;
|
||||||
|
receiving_date: string | null;
|
||||||
status: number;
|
status: number;
|
||||||
receiving_status: number;
|
|
||||||
total_items: number;
|
total_items: number;
|
||||||
total_quantity: number;
|
total_quantity: number;
|
||||||
total_cost: number;
|
total_cost: number;
|
||||||
total_received: number;
|
total_received: number;
|
||||||
fulfillment_rate: number;
|
fulfillment_rate: number;
|
||||||
|
short_note: string | null;
|
||||||
|
record_type: "po_only" | "po_with_receiving" | "receiving_only";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VendorMetrics {
|
interface VendorMetrics {
|
||||||
vendor_name: string;
|
vendor_name: string;
|
||||||
total_orders: number;
|
total_orders: number;
|
||||||
avg_delivery_days: number;
|
avg_delivery_days: number;
|
||||||
|
max_delivery_days: number;
|
||||||
fulfillment_rate: number;
|
fulfillment_rate: number;
|
||||||
avg_unit_cost: number;
|
avg_unit_cost: number;
|
||||||
total_spend: number;
|
total_spend: number;
|
||||||
@@ -59,6 +40,9 @@ interface CostAnalysis {
|
|||||||
total_spend_by_category: {
|
total_spend_by_category: {
|
||||||
category: string;
|
category: string;
|
||||||
total_spend: number;
|
total_spend: number;
|
||||||
|
unique_products?: number;
|
||||||
|
avg_cost?: number;
|
||||||
|
cost_variance?: number;
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,50 +53,33 @@ interface ReceivingStatus {
|
|||||||
fulfillment_rate: number;
|
fulfillment_rate: number;
|
||||||
total_value: number;
|
total_value: number;
|
||||||
avg_cost: number;
|
avg_cost: number;
|
||||||
|
avg_delivery_days?: number;
|
||||||
|
max_delivery_days?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PurchaseOrdersResponse {
|
|
||||||
orders: PurchaseOrder[];
|
|
||||||
summary: {
|
|
||||||
order_count: number;
|
|
||||||
total_ordered: number;
|
|
||||||
total_received: number;
|
|
||||||
fulfillment_rate: number;
|
|
||||||
total_value: number;
|
|
||||||
avg_cost: number;
|
|
||||||
};
|
|
||||||
pagination: {
|
|
||||||
total: number;
|
|
||||||
pages: number;
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
};
|
|
||||||
filters: {
|
|
||||||
vendors: string[];
|
|
||||||
statuses: string[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PurchaseOrders() {
|
export default function PurchaseOrders() {
|
||||||
const [purchaseOrders, setPurchaseOrders] = useState<PurchaseOrder[]>([]);
|
const [purchaseOrders, setPurchaseOrders] = useState<PurchaseOrder[]>([]);
|
||||||
const [, setVendorMetrics] = useState<VendorMetrics[]>([]);
|
const [, setVendorMetrics] = useState<VendorMetrics[]>([]);
|
||||||
const [costAnalysis, setCostAnalysis] = useState<CostAnalysis | null>(null);
|
const [, setCostAnalysis] = useState<CostAnalysis | null>(null);
|
||||||
const [summary, setSummary] = useState<ReceivingStatus | null>(null);
|
const [summary, setSummary] = useState<ReceivingStatus | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [sortColumn, setSortColumn] = useState<string>('order_date');
|
const [sortColumn, setSortColumn] = useState<string>("order_date");
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
|
||||||
const [filters, setFilters] = useState({
|
const [searchInput, setSearchInput] = useState("");
|
||||||
search: '',
|
const [filterValues, setFilterValues] = useState({
|
||||||
status: 'all',
|
search: "",
|
||||||
vendor: 'all',
|
status: "all",
|
||||||
|
vendor: "all",
|
||||||
|
recordType: "all",
|
||||||
});
|
});
|
||||||
const [filterOptions, setFilterOptions] = useState<{
|
const [filterOptions, setFilterOptions] = useState<{
|
||||||
vendors: string[];
|
vendors: string[];
|
||||||
statuses: string[];
|
statuses: number[];
|
||||||
}>({
|
}>({
|
||||||
vendors: [],
|
vendors: [],
|
||||||
statuses: []
|
statuses: [],
|
||||||
});
|
});
|
||||||
const [pagination, setPagination] = useState({
|
const [pagination, setPagination] = useState({
|
||||||
total: 0,
|
total: 0,
|
||||||
@@ -120,99 +87,173 @@ export default function PurchaseOrders() {
|
|||||||
page: 1,
|
page: 1,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
});
|
});
|
||||||
|
const [] = useState(false);
|
||||||
|
const [] = useState<
|
||||||
|
number | undefined
|
||||||
|
>();
|
||||||
|
const [] = useState<
|
||||||
|
number | undefined
|
||||||
|
>();
|
||||||
|
const [] = useState(false);
|
||||||
|
const [yearlyVendorData, setYearlyVendorData] = useState<
|
||||||
|
{
|
||||||
|
vendor: string;
|
||||||
|
orders: number;
|
||||||
|
total_spend: number;
|
||||||
|
percentage?: number;
|
||||||
|
}[]
|
||||||
|
>([]);
|
||||||
|
const [yearlyCategoryData, setYearlyCategoryData] = useState<
|
||||||
|
{
|
||||||
|
category: string;
|
||||||
|
unique_products?: number;
|
||||||
|
total_spend: number;
|
||||||
|
percentage?: number;
|
||||||
|
avg_cost?: number;
|
||||||
|
cost_variance?: number;
|
||||||
|
}[]
|
||||||
|
>([]);
|
||||||
|
const [yearlyDataLoading, setYearlyDataLoading] = useState(false);
|
||||||
|
const hasInitialFetchRef = useRef(false);
|
||||||
|
const hasInitialYearlyFetchRef = useRef(false);
|
||||||
|
|
||||||
const STATUS_FILTER_OPTIONS = [
|
// Use useMemo to compute filters only when filterValues change
|
||||||
{ value: 'all', label: 'All Statuses' },
|
const filters = useMemo(() => filterValues, [filterValues]);
|
||||||
{ value: String(PurchaseOrderStatus.Created), label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.Created) },
|
|
||||||
{ value: String(PurchaseOrderStatus.ElectronicallyReadySend), label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.ElectronicallyReadySend) },
|
|
||||||
{ value: String(PurchaseOrderStatus.Ordered), label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.Ordered) },
|
|
||||||
{ value: String(PurchaseOrderStatus.ReceivingStarted), label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.ReceivingStarted) },
|
|
||||||
{ value: String(PurchaseOrderStatus.Done), label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.Done) },
|
|
||||||
{ value: String(PurchaseOrderStatus.Canceled), label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.Canceled) },
|
|
||||||
];
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const searchParams = new URLSearchParams({
|
setLoading(true);
|
||||||
page: page.toString(),
|
|
||||||
limit: '100',
|
|
||||||
sortColumn,
|
|
||||||
sortDirection,
|
|
||||||
...filters.search && { search: filters.search },
|
|
||||||
...filters.status && { status: filters.status },
|
|
||||||
...filters.vendor && { vendor: filters.vendor },
|
|
||||||
});
|
|
||||||
|
|
||||||
const [
|
// Build search params with proper encoding
|
||||||
purchaseOrdersRes,
|
const searchParams = new URLSearchParams();
|
||||||
vendorMetricsRes,
|
searchParams.append('page', page.toString());
|
||||||
costAnalysisRes
|
searchParams.append('limit', '100');
|
||||||
] = await Promise.all([
|
searchParams.append('sortColumn', sortColumn);
|
||||||
fetch(`/api/purchase-orders?${searchParams}`),
|
searchParams.append('sortDirection', sortDirection);
|
||||||
fetch('/api/purchase-orders/vendor-metrics'),
|
|
||||||
fetch('/api/purchase-orders/cost-analysis')
|
if (filters.search) {
|
||||||
|
searchParams.append('search', filters.search);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.status !== 'all') {
|
||||||
|
searchParams.append('status', filters.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.vendor !== 'all') {
|
||||||
|
searchParams.append('vendor', filters.vendor);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.recordType !== 'all') {
|
||||||
|
searchParams.append('recordType', filters.recordType);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Fetching data with params:", searchParams.toString());
|
||||||
|
|
||||||
|
// Fetch orders first separately to handle errors better
|
||||||
|
const purchaseOrdersRes = await fetch(`/api/purchase-orders?${searchParams.toString()}`);
|
||||||
|
|
||||||
|
if (!purchaseOrdersRes.ok) {
|
||||||
|
const errorText = await purchaseOrdersRes.text();
|
||||||
|
console.error("Failed to fetch purchase orders:", errorText);
|
||||||
|
throw new Error(`Failed to fetch purchase orders: ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const purchaseOrdersData = await purchaseOrdersRes.json();
|
||||||
|
|
||||||
|
// Process orders data immediately
|
||||||
|
const processedOrders = purchaseOrdersData.orders.map((order: any) => ({
|
||||||
|
...order,
|
||||||
|
status: Number(order.status),
|
||||||
|
total_items: Number(order.total_items) || 0,
|
||||||
|
total_quantity: Number(order.total_quantity) || 0,
|
||||||
|
total_cost: Number(order.total_cost) || 0,
|
||||||
|
total_received: Number(order.total_received) || 0,
|
||||||
|
fulfillment_rate: Number(order.fulfillment_rate) || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Update the main data state
|
||||||
|
setPurchaseOrders(processedOrders);
|
||||||
|
setPagination(purchaseOrdersData.pagination);
|
||||||
|
setFilterOptions(purchaseOrdersData.filters);
|
||||||
|
|
||||||
|
// Now fetch the additional data in parallel
|
||||||
|
const [vendorMetricsRes, costAnalysisRes, deliveryMetricsRes] = await Promise.all([
|
||||||
|
fetch("/api/purchase-orders/vendor-metrics"),
|
||||||
|
fetch("/api/purchase-orders/cost-analysis"),
|
||||||
|
fetch("/api/purchase-orders/delivery-metrics"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Initialize default data
|
let vendorMetricsData = [];
|
||||||
let purchaseOrdersData: PurchaseOrdersResponse = {
|
let costAnalysisData = {
|
||||||
orders: [],
|
|
||||||
summary: {
|
|
||||||
order_count: 0,
|
|
||||||
total_ordered: 0,
|
|
||||||
total_received: 0,
|
|
||||||
fulfillment_rate: 0,
|
|
||||||
total_value: 0,
|
|
||||||
avg_cost: 0
|
|
||||||
},
|
|
||||||
pagination: {
|
|
||||||
total: 0,
|
|
||||||
pages: 0,
|
|
||||||
page: 1,
|
|
||||||
limit: 100
|
|
||||||
},
|
|
||||||
filters: {
|
|
||||||
vendors: [],
|
|
||||||
statuses: []
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let vendorMetricsData: VendorMetrics[] = [];
|
|
||||||
let costAnalysisData: CostAnalysis = {
|
|
||||||
unique_products: 0,
|
unique_products: 0,
|
||||||
avg_cost: 0,
|
avg_cost: 0,
|
||||||
min_cost: 0,
|
min_cost: 0,
|
||||||
max_cost: 0,
|
max_cost: 0,
|
||||||
cost_variance: 0,
|
cost_variance: 0,
|
||||||
total_spend_by_category: []
|
total_spend_by_category: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only try to parse responses if they were successful
|
let deliveryMetricsData = {
|
||||||
if (purchaseOrdersRes.ok) {
|
avg_delivery_days: 0,
|
||||||
purchaseOrdersData = await purchaseOrdersRes.json();
|
max_delivery_days: 0
|
||||||
} else {
|
};
|
||||||
console.error('Failed to fetch purchase orders:', await purchaseOrdersRes.text());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (vendorMetricsRes.ok) {
|
if (vendorMetricsRes.ok) {
|
||||||
vendorMetricsData = await vendorMetricsRes.json();
|
vendorMetricsData = await vendorMetricsRes.json();
|
||||||
|
setVendorMetrics(vendorMetricsData);
|
||||||
} else {
|
} else {
|
||||||
console.error('Failed to fetch vendor metrics:', await vendorMetricsRes.text());
|
console.error(
|
||||||
|
"Failed to fetch vendor metrics:",
|
||||||
|
await vendorMetricsRes.text()
|
||||||
|
);
|
||||||
|
setVendorMetrics([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (costAnalysisRes.ok) {
|
if (costAnalysisRes.ok) {
|
||||||
costAnalysisData = await costAnalysisRes.json();
|
costAnalysisData = await costAnalysisRes.json();
|
||||||
|
setCostAnalysis(costAnalysisData);
|
||||||
} else {
|
} else {
|
||||||
console.error('Failed to fetch cost analysis:', await costAnalysisRes.text());
|
console.error(
|
||||||
|
"Failed to fetch cost analysis:",
|
||||||
|
await costAnalysisRes.text()
|
||||||
|
);
|
||||||
|
setCostAnalysis({
|
||||||
|
unique_products: 0,
|
||||||
|
avg_cost: 0,
|
||||||
|
min_cost: 0,
|
||||||
|
max_cost: 0,
|
||||||
|
cost_variance: 0,
|
||||||
|
total_spend_by_category: [],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setPurchaseOrders(purchaseOrdersData.orders);
|
if (deliveryMetricsRes.ok) {
|
||||||
setPagination(purchaseOrdersData.pagination);
|
deliveryMetricsData = await deliveryMetricsRes.json();
|
||||||
setFilterOptions(purchaseOrdersData.filters);
|
|
||||||
setSummary(purchaseOrdersData.summary);
|
// Merge delivery metrics into summary
|
||||||
setVendorMetrics(vendorMetricsData);
|
const summaryWithDelivery = {
|
||||||
setCostAnalysis(costAnalysisData);
|
...purchaseOrdersData.summary,
|
||||||
|
avg_delivery_days: deliveryMetricsData.avg_delivery_days,
|
||||||
|
max_delivery_days: deliveryMetricsData.max_delivery_days
|
||||||
|
};
|
||||||
|
|
||||||
|
setSummary(summaryWithDelivery);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
"Failed to fetch delivery metrics:",
|
||||||
|
await deliveryMetricsRes.text()
|
||||||
|
);
|
||||||
|
setSummary({
|
||||||
|
...purchaseOrdersData.summary,
|
||||||
|
avg_delivery_days: 0,
|
||||||
|
max_delivery_days: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark that we've completed an initial fetch
|
||||||
|
hasInitialFetchRef.current = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching data:', error);
|
console.error("Error fetching data:", error);
|
||||||
// Set default values in case of error
|
// Set default values in case of error
|
||||||
setPurchaseOrders([]);
|
setPurchaseOrders([]);
|
||||||
setPagination({ total: 0, pages: 0, page: 1, limit: 100 });
|
setPagination({ total: 0, pages: 0, page: 1, limit: 100 });
|
||||||
@@ -223,7 +264,7 @@ export default function PurchaseOrders() {
|
|||||||
total_received: 0,
|
total_received: 0,
|
||||||
fulfillment_rate: 0,
|
fulfillment_rate: 0,
|
||||||
total_value: 0,
|
total_value: 0,
|
||||||
avg_cost: 0
|
avg_cost: 0,
|
||||||
});
|
});
|
||||||
setVendorMetrics([]);
|
setVendorMetrics([]);
|
||||||
setCostAnalysis({
|
setCostAnalysis({
|
||||||
@@ -232,284 +273,209 @@ export default function PurchaseOrders() {
|
|||||||
min_cost: 0,
|
min_cost: 0,
|
||||||
max_cost: 0,
|
max_cost: 0,
|
||||||
cost_variance: 0,
|
cost_variance: 0,
|
||||||
total_spend_by_category: []
|
total_spend_by_category: [],
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Setup debounced search
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
const timer = setTimeout(() => {
|
||||||
}, [page, sortColumn, sortDirection, filters]);
|
if (searchInput !== filterValues.search) {
|
||||||
|
setFilterValues(prev => ({ ...prev, search: searchInput }));
|
||||||
|
}
|
||||||
|
}, 300); // Use 300ms for better response time
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchInput, filterValues.search]);
|
||||||
|
|
||||||
|
// Reset page to 1 when filters change
|
||||||
|
useEffect(() => {
|
||||||
|
// Reset to page 1 when filters change to ensure proper pagination
|
||||||
|
setPage(1);
|
||||||
|
}, [filterValues]); // Use filterValues directly to avoid unnecessary renders
|
||||||
|
|
||||||
|
// Fetch data when page, sort or filters change
|
||||||
|
useEffect(() => {
|
||||||
|
// Log the current filter state for debugging
|
||||||
|
console.log("Fetching with filters:", filterValues);
|
||||||
|
console.log("Page:", page, "Sort:", sortColumn, sortDirection);
|
||||||
|
|
||||||
|
// Always fetch data - don't use conditional checks that might prevent it
|
||||||
|
fetchData();
|
||||||
|
}, [page, sortColumn, sortDirection, filterValues]);
|
||||||
|
|
||||||
|
// Handle column sorting more consistently
|
||||||
const handleSort = (column: string) => {
|
const handleSort = (column: string) => {
|
||||||
|
// Reset to page 1 when changing sort to ensure we see the first page of results
|
||||||
|
setPage(1);
|
||||||
|
|
||||||
if (sortColumn === column) {
|
if (sortColumn === column) {
|
||||||
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
setSortDirection((prev) => (prev === "asc" ? "desc" : "asc"));
|
||||||
} else {
|
} else {
|
||||||
setSortColumn(column);
|
setSortColumn(column);
|
||||||
setSortDirection('asc');
|
// For most columns, start with descending to show highest values first
|
||||||
|
if (column === 'id' || column === 'vendor_name') {
|
||||||
|
setSortDirection("asc");
|
||||||
|
} else {
|
||||||
|
setSortDirection("desc");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusBadge = (status: number, receivingStatus: number) => {
|
// Update filter handlers
|
||||||
// If the PO is canceled, show that status
|
const handleStatusChange = (value: string) => {
|
||||||
if (status === PurchaseOrderStatus.Canceled) {
|
setFilterValues(prev => ({ ...prev, status: value }));
|
||||||
return <Badge variant={getPurchaseOrderStatusVariant(status)}>
|
|
||||||
{getPurchaseOrderStatusLabel(status)}
|
|
||||||
</Badge>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If receiving has started, show receiving status
|
|
||||||
if (status >= PurchaseOrderStatus.ReceivingStarted) {
|
|
||||||
return <Badge variant={getReceivingStatusVariant(receivingStatus)}>
|
|
||||||
{getReceivingStatusLabel(receivingStatus)}
|
|
||||||
</Badge>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise show PO status
|
|
||||||
return <Badge variant={getPurchaseOrderStatusVariant(status)}>
|
|
||||||
{getPurchaseOrderStatusLabel(status)}
|
|
||||||
</Badge>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatNumber = (value: number) => {
|
const handleVendorChange = (value: string) => {
|
||||||
return value.toLocaleString('en-US', {
|
setFilterValues(prev => ({ ...prev, vendor: value }));
|
||||||
minimumFractionDigits: 2,
|
};
|
||||||
maximumFractionDigits: 2
|
|
||||||
|
const handleRecordTypeChange = (value: string) => {
|
||||||
|
setFilterValues(prev => ({ ...prev, recordType: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear all filters handler
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearchInput("");
|
||||||
|
setFilterValues({
|
||||||
|
search: "",
|
||||||
|
status: "all",
|
||||||
|
vendor: "all",
|
||||||
|
recordType: "all",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatPercent = (value: number) => {
|
// Update this function to fetch yearly data
|
||||||
return (value * 100).toLocaleString('en-US', {
|
const fetchYearlyData = async () => {
|
||||||
minimumFractionDigits: 1,
|
if (
|
||||||
maximumFractionDigits: 1
|
hasInitialYearlyFetchRef.current &&
|
||||||
}) + '%';
|
import.meta.hot &&
|
||||||
|
(yearlyVendorData.length > 0 || yearlyCategoryData.length > 0)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
<PaginationControls
|
||||||
>
|
pagination={pagination}
|
||||||
<PaginationPrevious className="h-4 w-4" />
|
currentPage={page}
|
||||||
</Button>
|
onPageChange={setPage}
|
||||||
</PaginationItem>
|
/>
|
||||||
<PaginationItem>
|
|
||||||
<Button
|
|
||||||
onClick={() => setPage(page + 1)}
|
|
||||||
disabled={page === pagination.pages}
|
|
||||||
className="h-9 px-4"
|
|
||||||
>
|
|
||||||
<PaginationNext className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</PaginationItem>
|
|
||||||
</PaginationContent>
|
|
||||||
</Pagination>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Cost Analysis */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Cost Analysis by Category</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Category</TableHead>
|
|
||||||
<TableHead>Total Spend</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{costAnalysis?.total_spend_by_category?.map((category) => (
|
|
||||||
<TableRow key={category.category}>
|
|
||||||
<TableCell>{category.category}</TableCell>
|
|
||||||
<TableCell>${formatNumber(category.total_spend)}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)) || (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={2} className="text-center text-muted-foreground">
|
|
||||||
No cost analysis data available
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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';
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user