Fix filtering/sorting/pagination for purchase orders

This commit is contained in:
2025-04-13 23:51:09 -04:00
parent eeff5817ea
commit 8dd852dd6a
2 changed files with 511 additions and 283 deletions

View File

@@ -50,60 +50,106 @@ function getStatusWhereClause(statusNum) {
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
try { try {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
// Parse query parameters with defaults
const { const {
search, search = '',
status, status = 'all',
vendor, vendor = 'all',
recordType, recordType = 'all',
startDate, startDate = null,
endDate, endDate = null,
page = 1, page = 1,
limit = 100, limit = 100,
sortColumn = 'id', sortColumn = 'id',
sortDirection = 'desc' sortDirection = 'desc'
} = req.query; } = req.query;
let whereClause = '1=1'; console.log("Received query parameters:", {
search, status, vendor, recordType, page, limit, sortColumn, sortDirection
});
// Base where clause for purchase orders
let poWhereClause = '1=1';
// Base where clause for receivings (used in the receiving_data CTE)
let receivingWhereClause = '1=1';
const params = []; const params = [];
let paramCounter = 1; let paramCounter = 1;
if (search) { if (search && search.trim() !== '') {
whereClause += ` AND (po.po_id ILIKE $${paramCounter} OR po.vendor ILIKE $${paramCounter})`; // Simplified search for purchase orders - improved performance
params.push(`%${search}%`); const searchTerm = `%${search.trim()}%`;
poWhereClause += ` AND (
po.po_id::text ILIKE $${paramCounter}
OR po.vendor ILIKE $${paramCounter}
OR po.notes ILIKE $${paramCounter}
)`;
params.push(searchTerm);
paramCounter++;
// Add search for receivings
receivingWhereClause += ` AND (
r.receiving_id::text ILIKE $${paramCounter}
OR r.vendor ILIKE $${paramCounter}
)`;
params.push(searchTerm);
paramCounter++; paramCounter++;
} }
if (status && status !== 'all') { if (status && status !== 'all') {
whereClause += ` AND ${getStatusWhereClause(status)}`; poWhereClause += ` AND ${getStatusWhereClause(status)}`;
// Handle status for receivings
const dbStatuses = Object.keys(STATUS_MAPPING).filter(key =>
STATUS_MAPPING[key] === parseInt(status));
if (dbStatuses.length > 0) {
receivingWhereClause += ` AND r.status = '${dbStatuses[0]}'`;
}
} }
if (vendor && vendor !== 'all') { if (vendor && vendor !== 'all') {
whereClause += ` AND po.vendor = $${paramCounter}`; poWhereClause += ` AND po.vendor = $${paramCounter}`;
params.push(vendor);
paramCounter++;
// Add vendor filter for receivings
receivingWhereClause += ` AND r.vendor = $${paramCounter}`;
params.push(vendor); params.push(vendor);
paramCounter++; paramCounter++;
} }
if (startDate) { if (startDate) {
whereClause += ` AND po.date >= $${paramCounter}`; poWhereClause += ` AND po.date >= $${paramCounter}::date`;
params.push(startDate);
paramCounter++;
// Add date filter for receivings
receivingWhereClause += ` AND r.received_date >= $${paramCounter}::date`;
params.push(startDate); params.push(startDate);
paramCounter++; paramCounter++;
} }
if (endDate) { if (endDate) {
whereClause += ` AND po.date <= $${paramCounter}`; poWhereClause += ` AND po.date <= $${paramCounter}::date`;
params.push(endDate);
paramCounter++;
// Add date filter for receivings
receivingWhereClause += ` AND r.received_date <= $${paramCounter}::date`;
params.push(endDate); params.push(endDate);
paramCounter++; paramCounter++;
} }
// Get filtered summary metrics // Get filtered summary metrics
const { rows: [summary] } = await pool.query(` const summaryQuery = `
WITH po_totals AS ( WITH po_totals AS (
SELECT SELECT
po_id, po_id,
SUM(ordered) as total_ordered, SUM(ordered) as total_ordered,
ROUND(SUM(ordered * po_cost_price)::numeric, 3) as total_cost ROUND(SUM(ordered * po_cost_price)::numeric, 3) as total_cost
FROM purchase_orders po FROM purchase_orders po
WHERE ${whereClause} WHERE ${poWhereClause}
GROUP BY po_id GROUP BY po_id
), ),
receiving_totals AS ( receiving_totals AS (
@@ -111,102 +157,143 @@ router.get('/', async (req, res) => {
r.receiving_id as po_id, r.receiving_id as po_id,
SUM(r.qty_each) as total_received SUM(r.qty_each) as total_received
FROM receivings r FROM receivings r
WHERE r.receiving_id IN (SELECT po_id FROM po_totals) WHERE (${receivingWhereClause})
AND r.receiving_id IN (SELECT po_id FROM po_totals)
GROUP BY r.receiving_id GROUP BY r.receiving_id
) )
SELECT SELECT
COUNT(DISTINCT po.po_id) as order_count, COUNT(DISTINCT po.po_id) as order_count,
SUM(po.total_ordered) as total_ordered, SUM(po.total_ordered) as total_ordered,
COALESCE(SUM(r.total_received), 0) as total_received, COALESCE(SUM(r.total_received), 0) as total_received,
ROUND( CASE
(COALESCE(SUM(r.total_received), 0)::numeric / NULLIF(SUM(po.total_ordered), 0)), 3 WHEN SUM(po.total_ordered) > 0
) as fulfillment_rate, THEN ROUND((COALESCE(SUM(r.total_received), 0)::numeric / SUM(po.total_ordered)), 3)
ELSE 0
END as fulfillment_rate,
ROUND(SUM(po.total_cost)::numeric, 3) as total_value, ROUND(SUM(po.total_cost)::numeric, 3) as total_value,
ROUND(AVG(po.total_cost)::numeric, 3) as avg_cost CASE
WHEN COUNT(DISTINCT po.po_id) > 0
THEN ROUND(AVG(po.total_cost)::numeric, 3)
ELSE 0
END as avg_cost
FROM po_totals po FROM po_totals po
LEFT JOIN receiving_totals r ON po.po_id = r.po_id LEFT JOIN receiving_totals r ON po.po_id = r.po_id
`, params);
// Get total count for pagination (including both POs and receivings without POs)
let countQuery = `
WITH po_count AS (
SELECT COUNT(DISTINCT po_id) as po_count
FROM purchase_orders po
WHERE ${whereClause}
),
receiving_count AS (
SELECT COUNT(DISTINCT receiving_id) as r_count
FROM receivings r
WHERE receiving_id NOT IN (
SELECT po_id FROM purchase_orders po WHERE ${whereClause}
)
)`;
// Adjust count query based on record type filter
if (recordType && recordType !== 'all') {
if (recordType === 'po_only') {
countQuery = `
WITH po_count AS (
SELECT COUNT(DISTINCT po.po_id) as po_count
FROM purchase_orders po
LEFT JOIN (
SELECT DISTINCT receiving_id
FROM receivings
) r ON po.po_id = r.receiving_id
WHERE ${whereClause} AND r.receiving_id IS NULL
),
receiving_count AS (
SELECT 0 as r_count
)`;
} else if (recordType === 'po_with_receiving') {
countQuery = `
WITH po_count AS (
SELECT COUNT(DISTINCT po.po_id) as po_count
FROM purchase_orders po
INNER JOIN (
SELECT DISTINCT receiving_id
FROM receivings
) r ON po.po_id = r.receiving_id
WHERE ${whereClause}
),
receiving_count AS (
SELECT 0 as r_count
)`;
} else if (recordType === 'receiving_only') {
countQuery = `
WITH po_count AS (
SELECT 0 as po_count
),
receiving_count AS (
SELECT COUNT(DISTINCT receiving_id) as r_count
FROM receivings r
WHERE receiving_id NOT IN (
SELECT po_id FROM purchase_orders po WHERE ${whereClause}
)
)`;
}
}
countQuery += `
SELECT (SELECT po_count FROM po_count) + (SELECT r_count FROM receiving_count) as total
`; `;
const { rows: [summary] } = await pool.query(summaryQuery, params);
// Prepare query based on record type filter to get correct counts
let countQuery = '';
if (recordType === 'po_only') {
countQuery = `
WITH po_data AS (
SELECT po_id
FROM purchase_orders po
WHERE ${poWhereClause}
GROUP BY po_id
),
receiving_data AS (
SELECT receiving_id
FROM receivings r
WHERE ${receivingWhereClause}
GROUP BY receiving_id
),
filtered_data AS (
SELECT DISTINCT po_id as id
FROM po_data
WHERE po_id NOT IN (SELECT receiving_id FROM receiving_data WHERE receiving_id IS NOT NULL)
)
SELECT COUNT(*) as total FROM filtered_data
`;
} else if (recordType === 'po_with_receiving') {
countQuery = `
WITH po_data AS (
SELECT po_id
FROM purchase_orders po
WHERE ${poWhereClause}
GROUP BY po_id
),
receiving_data AS (
SELECT receiving_id
FROM receivings r
WHERE ${receivingWhereClause}
GROUP BY receiving_id
),
filtered_data AS (
SELECT DISTINCT po_id as id
FROM po_data
WHERE po_id IN (SELECT receiving_id FROM receiving_data WHERE receiving_id IS NOT NULL)
)
SELECT COUNT(*) as total FROM filtered_data
`;
} else if (recordType === 'receiving_only') {
countQuery = `
WITH po_data AS (
SELECT po_id
FROM purchase_orders po
WHERE ${poWhereClause}
GROUP BY po_id
),
receiving_data AS (
SELECT receiving_id
FROM receivings r
WHERE ${receivingWhereClause}
GROUP BY receiving_id
),
filtered_data AS (
SELECT DISTINCT receiving_id as id
FROM receiving_data
WHERE receiving_id NOT IN (SELECT po_id FROM po_data WHERE po_id IS NOT NULL)
)
SELECT COUNT(*) as total FROM filtered_data
`;
} else {
// 'all' - count both purchase orders and receiving-only records
countQuery = `
WITH po_data AS (
SELECT po_id
FROM purchase_orders po
WHERE ${poWhereClause}
GROUP BY po_id
),
receiving_data AS (
SELECT receiving_id
FROM receivings r
WHERE ${receivingWhereClause}
GROUP BY receiving_id
),
filtered_data AS (
SELECT DISTINCT po_id as id FROM po_data
UNION
SELECT DISTINCT receiving_id as id FROM receiving_data
WHERE receiving_id NOT IN (SELECT po_id FROM po_data WHERE po_id IS NOT NULL)
)
SELECT COUNT(*) as total FROM filtered_data
`;
}
const { rows: [countResult] } = await pool.query(countQuery, params); const { rows: [countResult] } = await pool.query(countQuery, params);
const total = countResult.total; // Parse parameters safely
const offset = (page - 1) * limit; const parsedPage = parseInt(page) || 1;
const pages = Math.ceil(total / limit); const parsedLimit = parseInt(limit) || 100;
const total = parseInt(countResult?.total) || 0;
const offset = (parsedPage - 1) * parsedLimit;
const pages = Math.ceil(total / parsedLimit);
// Set default sorting for id to ensure consistent ordering // Validated sort parameters
const defaultSortColumn = sortColumn || 'id'; const validSortColumns = ['id', 'vendor_name', 'order_date', 'receiving_date',
const defaultSortDirection = sortDirection || 'desc'; 'status', 'total_cost', 'total_items', 'total_quantity', 'total_received', 'fulfillment_rate'];
// Get recent purchase orders - build the base query const finalSortColumn = validSortColumns.includes(sortColumn) ? sortColumn : 'id';
const finalSortDirection = sortDirection === 'asc' ? 'asc' : 'desc';
// Build the order by clause with improved null handling
let orderByClause = ''; let orderByClause = '';
// Special sorting that ensures receiving_only records are included with any date sorting // Special sorting that ensures receiving_only records are included with any date sorting
if (defaultSortColumn === 'order_date' || defaultSortColumn === 'date') { if (finalSortColumn === 'order_date' || finalSortColumn === 'date') {
// Make sure to include receivings (which have NULL order_date) by using a CASE statement
orderByClause = ` orderByClause = `
CASE CASE
WHEN order_date IS NULL THEN WHEN order_date IS NULL THEN
@@ -217,28 +304,43 @@ router.get('/', async (req, res) => {
END END
ELSE ELSE
to_date(order_date, 'YYYY-MM-DD') to_date(order_date, 'YYYY-MM-DD')
END ${defaultSortDirection === 'desc' ? 'DESC' : 'ASC'} END ${finalSortDirection === 'desc' ? 'DESC' : 'ASC'}
`;
} else if (finalSortColumn === 'receiving_date') {
orderByClause = `
CASE WHEN receiving_date IS NULL THEN
'1900-01-01'::date
ELSE
to_date(receiving_date, 'YYYY-MM-DD')
END ${finalSortDirection === 'desc' ? 'DESC' : 'ASC'}
`;
} else if (finalSortColumn === 'vendor_name') {
orderByClause = `vendor_name ${finalSortDirection === 'desc' ? 'DESC NULLS LAST' : 'ASC NULLS FIRST'}`;
} else if (finalSortColumn === 'total_cost' || finalSortColumn === 'total_received' ||
finalSortColumn === 'total_items' || finalSortColumn === 'total_quantity' || finalSortColumn === 'fulfillment_rate') {
orderByClause = `COALESCE(${finalSortColumn}, 0) ${finalSortDirection === 'desc' ? 'DESC' : 'ASC'}`;
} else if (finalSortColumn === 'status') {
// For status sorting, first convert to numeric values for consistent sorting
orderByClause = `
CASE
WHEN status = 'canceled' THEN 0
WHEN status = 'created' THEN 1
WHEN status = 'electronically_ready_send' THEN 10
WHEN status = 'ordered' THEN 11
WHEN status = 'receiving_started' THEN 15
WHEN status = 'done' THEN 50
WHEN status = 'partial_received' THEN 30
WHEN status = 'full_received' THEN 40
WHEN status = 'paid' THEN 50
ELSE 999
END ${finalSortDirection === 'desc' ? 'DESC' : 'ASC'}
`; `;
} else if (defaultSortColumn === 'vendor_name') {
orderByClause = `vendor_name ${defaultSortDirection === 'desc' ? 'DESC NULLS LAST' : 'ASC NULLS FIRST'}`;
} else if (defaultSortColumn === 'total_cost') {
orderByClause = `total_cost ${defaultSortDirection === 'desc' ? 'DESC NULLS LAST' : 'ASC NULLS FIRST'}`;
} else if (defaultSortColumn === 'total_received') {
orderByClause = `total_received ${defaultSortDirection === 'desc' ? 'DESC NULLS LAST' : 'ASC NULLS FIRST'}`;
} else if (defaultSortColumn === 'total_items') {
orderByClause = `total_items ${defaultSortDirection === 'desc' ? 'DESC NULLS LAST' : 'ASC NULLS FIRST'}`;
} else if (defaultSortColumn === 'total_quantity') {
orderByClause = `total_quantity ${defaultSortDirection === 'desc' ? 'DESC NULLS LAST' : 'ASC NULLS FIRST'}`;
} else if (defaultSortColumn === 'fulfillment_rate') {
orderByClause = `fulfillment_rate ${defaultSortDirection === 'desc' ? 'DESC NULLS LAST' : 'ASC NULLS FIRST'}`;
} else if (defaultSortColumn === 'status') {
orderByClause = `status ${defaultSortDirection === 'desc' ? 'DESC NULLS LAST' : 'ASC NULLS FIRST'}`;
} else { } else {
// Default to ID sorting if no valid column is specified // Default to ID sorting
orderByClause = `id::bigint ${defaultSortDirection === 'desc' ? 'DESC' : 'ASC'}`; orderByClause = `id::text::bigint ${finalSortDirection === 'desc' ? 'DESC' : 'ASC'}`;
} }
// Simplified combined query approach to ensure all record types are included // Main query to get purchase orders and receivings
let orderQuery = ` let orderQuery = `
WITH po_data AS ( WITH po_data AS (
SELECT SELECT
@@ -251,7 +353,7 @@ router.get('/', async (req, res) => {
ROUND(SUM(ordered * po_cost_price)::numeric, 3) as total_cost, ROUND(SUM(ordered * po_cost_price)::numeric, 3) as total_cost,
MAX(notes) as short_note MAX(notes) as short_note
FROM purchase_orders po FROM purchase_orders po
WHERE ${whereClause} WHERE ${poWhereClause}
GROUP BY po_id, vendor, date, status GROUP BY po_id, vendor, date, status
), ),
receiving_data AS ( receiving_data AS (
@@ -264,23 +366,46 @@ router.get('/', async (req, res) => {
ROUND(SUM(r.qty_each * r.cost_each)::numeric, 3) as total_cost, ROUND(SUM(r.qty_each * r.cost_each)::numeric, 3) as total_cost,
MAX(r.status) as receiving_status MAX(r.status) as receiving_status
FROM receivings r FROM receivings r
WHERE ${receivingWhereClause}
GROUP BY r.receiving_id, r.vendor GROUP BY r.receiving_id, r.vendor
), )`;
all_data AS (
-- Get all unique IDs from both tables // Add appropriate record type filtering based on the filter value
SELECT DISTINCT po_id as id FROM po_data if (recordType === 'po_only') {
UNION orderQuery += `,
SELECT DISTINCT receiving_id as id FROM receiving_data all_data AS (
${recordType === 'po_only' ? SELECT DISTINCT po_id as id
'EXCEPT SELECT DISTINCT receiving_id as id FROM receiving_data' : FROM po_data
recordType === 'po_with_receiving' ? WHERE po_id NOT IN (SELECT receiving_id FROM receiving_data WHERE receiving_id IS NOT NULL)
'INTERSECT SELECT DISTINCT receiving_id as id FROM receiving_data' : )`;
recordType === 'receiving_only' ? } else if (recordType === 'po_with_receiving') {
'EXCEPT SELECT DISTINCT po_id as id FROM po_data' : orderQuery += `,
'' // No additional clause for 'all' all_data AS (
} SELECT DISTINCT po_id as id
), FROM po_data
combined_data AS ( WHERE po_id IN (SELECT receiving_id FROM receiving_data WHERE receiving_id IS NOT NULL)
)`;
} else if (recordType === 'receiving_only') {
orderQuery += `,
all_data AS (
SELECT DISTINCT receiving_id as id
FROM receiving_data
WHERE receiving_id NOT IN (SELECT po_id FROM po_data WHERE po_id IS NOT NULL)
)`;
} else {
// 'all' - include all records
orderQuery += `,
all_data AS (
SELECT DISTINCT po_id as id FROM po_data
UNION
SELECT DISTINCT receiving_id as id FROM receiving_data
WHERE receiving_id NOT IN (SELECT po_id FROM po_data WHERE po_id IS NOT NULL)
)`;
}
// Complete the query with combined data and ordering
orderQuery += `
,combined_data AS (
SELECT SELECT
a.id, a.id,
COALESCE(po.vendor, r.receiving_vendor) as vendor_name, COALESCE(po.vendor, r.receiving_vendor) as vendor_name,
@@ -297,7 +422,8 @@ router.get('/', async (req, res) => {
CASE CASE
WHEN po.po_id IS NULL THEN 1 WHEN po.po_id IS NULL THEN 1
WHEN r.receiving_id IS NULL THEN 0 WHEN r.receiving_id IS NULL THEN 0
ELSE ROUND((r.total_received::numeric / NULLIF(po.total_quantity, 0)), 3) WHEN po.total_quantity = 0 THEN 0
ELSE ROUND((r.total_received::numeric / po.total_quantity), 3)
END as fulfillment_rate, END as fulfillment_rate,
po.short_note, po.short_note,
CASE CASE
@@ -308,25 +434,23 @@ router.get('/', async (req, res) => {
FROM all_data a FROM all_data a
LEFT JOIN po_data po ON a.id = po.po_id LEFT JOIN po_data po ON a.id = po.po_id
LEFT JOIN receiving_data r ON a.id = r.receiving_id LEFT JOIN receiving_data r ON a.id = r.receiving_id
${
recordType === 'po_only' ? 'WHERE po.po_id IS NOT NULL AND r.receiving_id IS NULL' :
recordType === 'po_with_receiving' ? 'WHERE po.po_id IS NOT NULL AND r.receiving_id IS NOT NULL' :
recordType === 'receiving_only' ? 'WHERE po.po_id IS NULL AND r.receiving_id IS NOT NULL' :
'' // No WHERE clause for 'all'
}
) )
SELECT * FROM combined_data SELECT * FROM combined_data
ORDER BY ${orderByClause}, id::bigint DESC ORDER BY ${orderByClause}, id::text::bigint DESC
LIMIT $${paramCounter} OFFSET $${paramCounter + 1} LIMIT $${paramCounter} OFFSET $${paramCounter + 1}
`; `;
const { rows: orders } = await pool.query(orderQuery, [...params, Number(limit), offset]); const { rows: orders } = await pool.query(orderQuery, [...params, parsedLimit, offset]);
// Get unique vendors for filter options // Get unique vendors for filter options
const { rows: vendors } = await pool.query(` const { rows: vendors } = await pool.query(`
SELECT DISTINCT vendor SELECT DISTINCT vendor
FROM purchase_orders FROM purchase_orders
WHERE vendor IS NOT NULL AND vendor != '' WHERE vendor IS NOT NULL AND vendor != ''
UNION
SELECT DISTINCT vendor
FROM receivings
WHERE vendor IS NOT NULL AND vendor != ''
ORDER BY vendor ORDER BY vendor
`); `);
@@ -335,6 +459,10 @@ router.get('/', async (req, res) => {
SELECT DISTINCT status SELECT DISTINCT status
FROM purchase_orders FROM purchase_orders
WHERE status IS NOT NULL WHERE status IS NOT NULL
UNION
SELECT DISTINCT status
FROM receivings
WHERE status IS NOT NULL
ORDER BY status ORDER BY status
`); `);
@@ -389,24 +517,26 @@ router.get('/', async (req, res) => {
}; };
}); });
// Parse summary metrics // Parse summary metrics with fallbacks
const parsedSummary = { const parsedSummary = {
order_count: Number(summary.order_count) || 0, order_count: Number(summary?.order_count) || 0,
total_ordered: Number(summary.total_ordered) || 0, total_ordered: Number(summary?.total_ordered) || 0,
total_received: Number(summary.total_received) || 0, total_received: Number(summary?.total_received) || 0,
fulfillment_rate: Number(summary.fulfillment_rate) || 0, fulfillment_rate: Number(summary?.fulfillment_rate) || 0,
total_value: Number(summary.total_value) || 0, total_value: Number(summary?.total_value) || 0,
avg_cost: Number(summary.avg_cost) || 0 avg_cost: Number(summary?.avg_cost) || 0
}; };
console.log(`Returning ${parsedOrders.length} orders, total=${total}, pages=${pages}, page=${parsedPage}`);
res.json({ res.json({
orders: parsedOrders, orders: parsedOrders,
summary: parsedSummary, summary: parsedSummary,
pagination: { pagination: {
total, total,
pages, pages,
page: Number(page), page: parsedPage,
limit: Number(limit) limit: parsedLimit
}, },
filters: { filters: {
vendors: vendors.map(v => v.vendor), vendors: vendors.map(v => v.vendor),
@@ -415,7 +545,7 @@ router.get('/', async (req, res) => {
}); });
} catch (error) { } catch (error) {
console.error('Error fetching purchase orders:', error); console.error('Error fetching purchase orders:', error);
res.status(500).json({ error: 'Failed to fetch purchase orders' }); res.status(500).json({ error: 'Failed to fetch purchase orders', details: error.message });
} }
}); });

View File

@@ -229,6 +229,7 @@ export default function PurchaseOrders() {
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 [searchInput, setSearchInput] = useState("");
const [filterValues, setFilterValues] = useState({ const [filterValues, setFilterValues] = useState({
search: "", search: "",
status: "all", status: "all",
@@ -315,63 +316,74 @@ export default function PurchaseOrders() {
{ value: "receiving_only", label: "Receiving Only" }, { value: "receiving_only", label: "Receiving Only" },
]; ];
// Use useMemo to compute filters only when filterValues change
const filters = useMemo(() => filterValues, [filterValues]); const filters = useMemo(() => filterValues, [filterValues]);
const fetchData = async () => { const fetchData = async () => {
if (
hasInitialFetchRef.current &&
import.meta.hot &&
purchaseOrders.length > 0
) {
return;
}
try { try {
setLoading(true); setLoading(true);
const searchParams = new URLSearchParams({
page: page.toString(),
limit: "100",
sortColumn,
sortDirection,
...(filters.search && { search: filters.search }),
...(filters.status !== "all" && { status: filters.status }),
...(filters.vendor !== "all" && { vendor: filters.vendor }),
...(filters.recordType !== "all" && { recordType: filters.recordType }),
});
const [purchaseOrdersRes, vendorMetricsRes, costAnalysisRes, deliveryMetricsRes] = // Build search params with proper encoding
await Promise.all([ const searchParams = new URLSearchParams();
fetch(`/api/purchase-orders?${searchParams}`), searchParams.append('page', page.toString());
fetch("/api/purchase-orders/vendor-metrics"), searchParams.append('limit', '100');
fetch("/api/purchase-orders/cost-analysis"), searchParams.append('sortColumn', sortColumn);
fetch("/api/purchase-orders/delivery-metrics"), searchParams.append('sortDirection', sortDirection);
]);
// Initialize default data if (filters.search) {
let purchaseOrdersData: PurchaseOrdersResponse = { searchParams.append('search', filters.search);
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[] = []; if (filters.status !== 'all') {
let costAnalysisData: CostAnalysis = { 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"),
]);
let vendorMetricsData = [];
let costAnalysisData = {
unique_products: 0, unique_products: 0,
avg_cost: 0, avg_cost: 0,
min_cost: 0, min_cost: 0,
@@ -385,72 +397,58 @@ export default function PurchaseOrders() {
max_delivery_days: 0 max_delivery_days: 0
}; };
// Only try to parse responses if they were successful
if (purchaseOrdersRes.ok) {
purchaseOrdersData = await purchaseOrdersRes.json();
} else {
console.error(
"Failed to fetch purchase orders:",
await purchaseOrdersRes.text()
);
}
if (vendorMetricsRes.ok) { if (vendorMetricsRes.ok) {
vendorMetricsData = await vendorMetricsRes.json(); vendorMetricsData = await vendorMetricsRes.json();
setVendorMetrics(vendorMetricsData);
} else { } else {
console.error( console.error(
"Failed to fetch vendor metrics:", "Failed to fetch vendor metrics:",
await vendorMetricsRes.text() await vendorMetricsRes.text()
); );
setVendorMetrics([]);
} }
if (costAnalysisRes.ok) { if (costAnalysisRes.ok) {
costAnalysisData = await costAnalysisRes.json(); costAnalysisData = await costAnalysisRes.json();
setCostAnalysis(costAnalysisData);
} else { } else {
console.error( console.error(
"Failed to fetch cost analysis:", "Failed to fetch cost analysis:",
await costAnalysisRes.text() await costAnalysisRes.text()
); );
setCostAnalysis({
unique_products: 0,
avg_cost: 0,
min_cost: 0,
max_cost: 0,
cost_variance: 0,
total_spend_by_category: [],
});
} }
if (deliveryMetricsRes.ok) { if (deliveryMetricsRes.ok) {
deliveryMetricsData = await deliveryMetricsRes.json(); deliveryMetricsData = await deliveryMetricsRes.json();
// Merge delivery metrics into summary
const summaryWithDelivery = {
...purchaseOrdersData.summary,
avg_delivery_days: deliveryMetricsData.avg_delivery_days,
max_delivery_days: deliveryMetricsData.max_delivery_days
};
setSummary(summaryWithDelivery);
} else { } else {
console.error( console.error(
"Failed to fetch delivery metrics:", "Failed to fetch delivery metrics:",
await deliveryMetricsRes.text() await deliveryMetricsRes.text()
); );
setSummary({
...purchaseOrdersData.summary,
avg_delivery_days: 0,
max_delivery_days: 0
});
} }
// Process orders data
const processedOrders = purchaseOrdersData.orders.map((order) => {
let processedOrder = {
...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,
};
return processedOrder;
});
// Merge delivery metrics into summary
const summaryWithDelivery = {
...purchaseOrdersData.summary,
avg_delivery_days: deliveryMetricsData.avg_delivery_days,
max_delivery_days: deliveryMetricsData.max_delivery_days
};
setPurchaseOrders(processedOrders);
setPagination(purchaseOrdersData.pagination);
setFilterOptions(purchaseOrdersData.filters);
setSummary(summaryWithDelivery);
setVendorMetrics(vendorMetricsData);
setCostAnalysis(costAnalysisData);
// Mark that we've completed an initial fetch // Mark that we've completed an initial fetch
hasInitialFetchRef.current = true; hasInitialFetchRef.current = true;
} catch (error) { } catch (error) {
@@ -481,20 +479,75 @@ export default function PurchaseOrders() {
} }
}; };
// Setup debounced search
useEffect(() => { useEffect(() => {
fetchData(); const timer = setTimeout(() => {
// eslint-disable-next-line react-hooks/exhaustive-deps if (searchInput !== filterValues.search) {
}, [page, sortColumn, sortDirection, filters]); 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");
}
} }
}; };
// Update filter handlers
const handleStatusChange = (value: string) => {
setFilterValues(prev => ({ ...prev, status: value }));
};
const handleVendorChange = (value: string) => {
setFilterValues(prev => ({ ...prev, vendor: value }));
};
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 getStatusBadge = (status: number, recordType: string) => { const getStatusBadge = (status: number, recordType: string) => {
if (recordType === "receiving_only") { if (recordType === "receiving_only") {
return ( return (
@@ -541,14 +594,15 @@ export default function PurchaseOrders() {
const getPaginationItems = () => { const getPaginationItems = () => {
const items = []; const items = [];
const totalPages = pagination.pages; const totalPages = pagination.pages;
const currentPage = page; // Use the local state to ensure sync
// Always show first page // Always show first page
if (totalPages > 0) { if (totalPages > 0) {
items.push( items.push(
<PaginationItem key="first"> <PaginationItem key="first">
<PaginationLink <PaginationLink
isActive={page === 1} isActive={currentPage === 1}
onClick={() => page !== 1 && setPage(1)} onClick={() => currentPage !== 1 && setPage(1)}
> >
1 1
</PaginationLink> </PaginationLink>
@@ -557,7 +611,7 @@ export default function PurchaseOrders() {
} }
// Add ellipsis if needed // Add ellipsis if needed
if (page > 3) { if (currentPage > 3) {
items.push( items.push(
<PaginationItem key="ellipsis-1"> <PaginationItem key="ellipsis-1">
<PaginationEllipsis /> <PaginationEllipsis />
@@ -566,16 +620,16 @@ export default function PurchaseOrders() {
} }
// Add pages around current page // Add pages around current page
const startPage = Math.max(2, page - 1); const startPage = Math.max(2, currentPage - 1);
const endPage = Math.min(totalPages - 1, page + 1); const endPage = Math.min(totalPages - 1, currentPage + 1);
for (let i = startPage; i <= endPage; i++) { for (let i = startPage; i <= endPage; i++) {
if (i <= 1 || i >= totalPages) continue; // Skip first and last page as they're handled separately if (i <= 1 || i >= totalPages) continue; // Skip first and last page as they're handled separately
items.push( items.push(
<PaginationItem key={i}> <PaginationItem key={i}>
<PaginationLink <PaginationLink
isActive={page === i} isActive={currentPage === i}
onClick={() => page !== i && setPage(i)} onClick={() => currentPage !== i && setPage(i)}
> >
{i} {i}
</PaginationLink> </PaginationLink>
@@ -584,7 +638,7 @@ export default function PurchaseOrders() {
} }
// Add ellipsis if needed // Add ellipsis if needed
if (page < totalPages - 2) { if (currentPage < totalPages - 2) {
items.push( items.push(
<PaginationItem key="ellipsis-2"> <PaginationItem key="ellipsis-2">
<PaginationEllipsis /> <PaginationEllipsis />
@@ -597,8 +651,8 @@ export default function PurchaseOrders() {
items.push( items.push(
<PaginationItem key="last"> <PaginationItem key="last">
<PaginationLink <PaginationLink
isActive={page === totalPages} isActive={currentPage === totalPages}
onClick={() => page !== totalPages && setPage(totalPages)} onClick={() => currentPage !== totalPages && setPage(totalPages)}
> >
{totalPages} {totalPages}
</PaginationLink> </PaginationLink>
@@ -609,6 +663,12 @@ export default function PurchaseOrders() {
return items; return items;
}; };
// Update sort indicators in table headers
const getSortIndicator = (column: string) => {
if (sortColumn !== column) return null;
return sortDirection === "asc" ? " ↑" : " ↓";
};
// Update this function to fetch yearly data // Update this function to fetch yearly data
const fetchYearlyData = async () => { const fetchYearlyData = async () => {
if ( if (
@@ -938,7 +998,7 @@ export default function PurchaseOrders() {
</div> </div>
) : ( ) : (
<div className="text-center p-4 text-muted-foreground"> <div className="text-center p-4 text-muted-foreground">
No vendor data available for the past 12 months No supplier data available for the past 12 months
</div> </div>
); );
} }
@@ -953,14 +1013,14 @@ export default function PurchaseOrders() {
<> <>
<div className="text-sm font-medium mb-2 flex justify-between items-center px-4"> <div className="text-sm font-medium mb-2 flex justify-between items-center px-4">
<span> <span>
Showing received inventory by vendor for the past 12 months Showing received inventory by supplier for the past 12 months
</span> </span>
<span>{vendorData.length} vendors found</span> <span>{vendorData.length} suppliers found</span>
</div> </div>
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Vendor</TableHead> <TableHead>Supplier</TableHead>
<TableHead>Orders</TableHead> <TableHead>Orders</TableHead>
<TableHead>Total Spend</TableHead> <TableHead>Total Spend</TableHead>
<TableHead>% of Total</TableHead> <TableHead>% of Total</TableHead>
@@ -1063,7 +1123,7 @@ export default function PurchaseOrders() {
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"> <CardTitle className="text-sm font-medium">
Received by Vendor Received by Supplier
</CardTitle> </CardTitle>
<Dialog <Dialog
open={vendorAnalysisOpen} open={vendorAnalysisOpen}
@@ -1078,7 +1138,7 @@ export default function PurchaseOrders() {
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5" /> <BarChart3 className="h-5 w-5" />
<span>Received Inventory by Vendor</span> <span>Received Inventory by Supplier</span>
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="overflow-auto max-h-[70vh]"> <div className="overflow-auto max-h-[70vh]">
@@ -1201,18 +1261,14 @@ export default function PurchaseOrders() {
<div className="mb-4 flex flex-wrap items-center gap-4"> <div className="mb-4 flex flex-wrap items-center gap-4">
<Input <Input
placeholder="Search orders..." placeholder="Search orders..."
value={filterValues.search} value={searchInput}
onChange={(e) => onChange={(e) => setSearchInput(e.target.value)}
setFilterValues((prev) => ({ ...prev, search: e.target.value }))
}
className="max-w-xs" className="max-w-xs"
disabled={loading} disabled={loading}
/> />
<Select <Select
value={filterValues.status} value={filterValues.status}
onValueChange={(value) => onValueChange={handleStatusChange}
setFilterValues((prev) => ({ ...prev, status: value }))
}
disabled={loading} disabled={loading}
> >
<SelectTrigger className="w-[180px]"> <SelectTrigger className="w-[180px]">
@@ -1228,16 +1284,14 @@ export default function PurchaseOrders() {
</Select> </Select>
<Select <Select
value={filterValues.vendor} value={filterValues.vendor}
onValueChange={(value) => onValueChange={handleVendorChange}
setFilterValues((prev) => ({ ...prev, vendor: value }))
}
disabled={loading} disabled={loading}
> >
<SelectTrigger className="w-[180px]"> <SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select vendor" /> <SelectValue placeholder="Select supplier" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">All Vendors</SelectItem> <SelectItem value="all">All Suppliers</SelectItem>
{filterOptions?.vendors?.map((vendor) => ( {filterOptions?.vendors?.map((vendor) => (
<SelectItem key={vendor} value={vendor}> <SelectItem key={vendor} value={vendor}>
{vendor} {vendor}
@@ -1247,9 +1301,7 @@ export default function PurchaseOrders() {
</Select> </Select>
<Select <Select
value={filterValues.recordType} value={filterValues.recordType}
onValueChange={(value) => onValueChange={handleRecordTypeChange}
setFilterValues((prev) => ({ ...prev, recordType: value }))
}
disabled={loading} disabled={loading}
> >
<SelectTrigger className="w-[180px]"> <SelectTrigger className="w-[180px]">
@@ -1263,6 +1315,18 @@ export default function PurchaseOrders() {
))} ))}
</SelectContent> </SelectContent>
</Select> </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> </div>
{/* Purchase Orders Table */} {/* Purchase Orders Table */}
@@ -1292,7 +1356,7 @@ export default function PurchaseOrders() {
onClick={() => !loading && handleSort("id")} onClick={() => !loading && handleSort("id")}
disabled={loading} disabled={loading}
> >
ID ID{getSortIndicator("id")}
</Button> </Button>
</TableHead> </TableHead>
<TableHead className="w-[140px] text-center"> <TableHead className="w-[140px] text-center">
@@ -1302,7 +1366,7 @@ export default function PurchaseOrders() {
onClick={() => !loading && handleSort("vendor_name")} onClick={() => !loading && handleSort("vendor_name")}
disabled={loading} disabled={loading}
> >
Supplier Supplier{getSortIndicator("vendor_name")}
</Button> </Button>
</TableHead> </TableHead>
<TableHead className="w-[115px] text-center"> <TableHead className="w-[115px] text-center">
@@ -1312,7 +1376,7 @@ export default function PurchaseOrders() {
onClick={() => !loading && handleSort("status")} onClick={() => !loading && handleSort("status")}
disabled={loading} disabled={loading}
> >
Status Status{getSortIndicator("status")}
</Button> </Button>
</TableHead> </TableHead>
<TableHead className="w-[150px] text-center">Note</TableHead> <TableHead className="w-[150px] text-center">Note</TableHead>
@@ -1323,10 +1387,19 @@ export default function PurchaseOrders() {
onClick={() => !loading && handleSort("total_cost")} onClick={() => !loading && handleSort("total_cost")}
disabled={loading} disabled={loading}
> >
Total Cost 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> </Button>
</TableHead> </TableHead>
<TableHead className="w-[70px] text-center">Products</TableHead>
<TableHead className="w-[90px] text-center"> <TableHead className="w-[90px] text-center">
<Button <Button
className="w-full" className="w-full"
@@ -1334,14 +1407,39 @@ export default function PurchaseOrders() {
onClick={() => !loading && handleSort("order_date")} onClick={() => !loading && handleSort("order_date")}
disabled={loading} disabled={loading}
> >
Order Date 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> </Button>
</TableHead> </TableHead>
<TableHead className="w-[90px] text-center">Rec'd Date</TableHead>
<TableHead className="w-[70px] text-center">Ordered</TableHead>
<TableHead className="w-[80px] text-center">Received</TableHead>
<TableHead className="w-[80px] text-center"> <TableHead className="w-[80px] text-center">
<Button <Button
className="w-full" className="w-full"
@@ -1349,7 +1447,7 @@ export default function PurchaseOrders() {
onClick={() => !loading && handleSort("fulfillment_rate")} onClick={() => !loading && handleSort("fulfillment_rate")}
disabled={loading} disabled={loading}
> >
% Fulfilled % Fulfilled{getSortIndicator("fulfillment_rate")}
</Button> </Button>
</TableHead> </TableHead>
</TableRow> </TableRow>