From 6b7d31c081fdd375da5764e585e18fabc01bf7f1 Mon Sep 17 00:00:00 2001 From: Linh Huynh Date: Tue, 30 Dec 2025 00:22:44 -0800 Subject: [PATCH] Improve Materials table usability: search, sort, pagination, sticky header, layout --- .../BMDashboard/ItemList/ItemListView.jsx | 147 +++++++- .../ItemList/ItemListView.module.css | 210 +++++++++++- .../BMDashboard/ItemList/ItemsTable.jsx | 319 +++++++++++------- 3 files changed, 540 insertions(+), 136 deletions(-) diff --git a/src/components/BMDashboard/ItemList/ItemListView.jsx b/src/components/BMDashboard/ItemList/ItemListView.jsx index cf6451b90d..6d375ea099 100644 --- a/src/components/BMDashboard/ItemList/ItemListView.jsx +++ b/src/components/BMDashboard/ItemList/ItemListView.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { useSelector } from 'react-redux'; import PropTypes from 'prop-types'; import DatePicker from 'react-datepicker'; @@ -15,6 +15,12 @@ export function ItemListView({ itemType, items, errors, UpdateItemModal, dynamic const [selectedItem, setSelectedItem] = useState('all'); const [isError, setIsError] = useState(false); const [selectedTime, setSelectedTime] = useState(new Date()); + + const [searchQuery, setSearchQuery] = useState(''); + const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' }); + const [currentPage, setCurrentPage] = useState(1); + const [rowsPerPage, setRowsPerPage] = useState(25); + const darkMode = useSelector(state => state.theme.darkMode); useEffect(() => { @@ -24,6 +30,7 @@ export function ItemListView({ itemType, items, errors, UpdateItemModal, dynamic useEffect(() => { let filterItems; if (!items) return; + if (selectedProject === 'all' && selectedItem === 'all') { setFilteredItems([...items]); } else if (selectedProject !== 'all' && selectedItem === 'all') { @@ -44,6 +51,97 @@ export function ItemListView({ itemType, items, errors, UpdateItemModal, dynamic setIsError(Object.entries(errors).length > 0); }, [errors]); + useEffect(() => { + setCurrentPage(1); + }, [searchQuery, selectedProject, selectedItem, rowsPerPage]); + + const normalize = v => + String(v ?? '') + .toLowerCase() + .trim(); + + const searchFilteredItems = useMemo(() => { + const q = normalize(searchQuery); + if (!q) return filteredItems || []; + + return (filteredItems || []).filter(item => { + const project = normalize(item.project?.name); + const name = normalize(item.itemType?.name || item.name); + const pid = normalize(item['product id'] ?? item.productId ?? item.pid); + const measurement = normalize(item.itemType?.unit); + + return project.includes(q) || name.includes(q) || pid.includes(q) || measurement.includes(q); + }); + }, [filteredItems, searchQuery]); + + const getSortValue = (item, key) => { + switch (key) { + case 'project': + return item.project?.name || ''; + case 'name': + return item.itemType?.name || item.name || ''; + case 'bought': + return item.stockBought ?? 0; + case 'used': + return item.stockUsed ?? 0; + case 'available': + return item.stockAvailable ?? 0; + case 'wasted': + return item.stockWasted ?? 0; + case 'hold': + return item.stockHold ?? 0; + default: + return ''; + } + }; + + const compare = (a, b, direction) => { + const dir = direction === 'asc' ? 1 : -1; + + if (typeof a === 'number' && typeof b === 'number') return (a - b) * dir; + + const sa = String(a ?? '').toLowerCase(); + const sb = String(b ?? '').toLowerCase(); + return sa.localeCompare(sb) * dir; + }; + + const sortedItems = useMemo(() => { + const arr = (searchFilteredItems || []).map((item, idx) => ({ item, idx })); + + if (!sortConfig.key) return arr.map(x => x.item); + + arr.sort((x, y) => { + const va = getSortValue(x.item, sortConfig.key); + const vb = getSortValue(y.item, sortConfig.key); + const c = compare(va, vb, sortConfig.direction); + return c !== 0 ? c : x.idx - y.idx; + }); + + return arr.map(x => x.item); + }, [searchFilteredItems, sortConfig]); + + const handleSort = key => { + setSortConfig(prev => { + if (prev.key !== key) return { key, direction: 'asc' }; + return { key, direction: prev.direction === 'asc' ? 'desc' : 'asc' }; + }); + }; + + const totalItems = sortedItems.length; + const totalPages = Math.max(1, Math.ceil(totalItems / rowsPerPage)); + + useEffect(() => { + setCurrentPage(p => Math.min(Math.max(1, p), totalPages)); + }, [totalPages]); + + const paginatedItems = useMemo(() => { + const start = (currentPage - 1) * rowsPerPage; + return sortedItems.slice(start, start + rowsPerPage); + }, [sortedItems, currentPage, rowsPerPage]); + + const startRow = totalItems === 0 ? 0 : (currentPage - 1) * rowsPerPage + 1; + const endRow = Math.min(currentPage * rowsPerPage, totalItems); + if (isError) { return (
@@ -59,6 +157,7 @@ export function ItemListView({ itemType, items, errors, UpdateItemModal, dynamic return (

{itemType}

+
{items && ( @@ -72,18 +171,20 @@ export function ItemListView({ itemType, items, errors, UpdateItemModal, dynamic timeIntervals={15} dateFormat="yyyy-MM-dd HH:mm:ss" placeholderText="Select date and time" - inputId="itemListTime" // This is the key line + inputId="itemListTime" className={darkMode ? styles.darkDatePickerInput : styles.lightDatePickerInput} calendarClassName={darkMode ? styles.darkDatePicker : styles.lightDatePicker} popperClassName={ darkMode ? styles.darkDatePickerPopper : styles.lightDatePickerPopper } /> + + )} +
+ +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search by project, name, PID, or measurement..." + /> + {searchQuery && ( + + )} +
+ +
+ {totalItems} {totalItems === 1 ? 'material' : 'materials'} found +
+
+ {filteredItems && ( )}
@@ -135,6 +275,7 @@ ItemListView.propTypes = { stockBought: PropTypes.number, stockUsed: PropTypes.number, stockWasted: PropTypes.number, + stockHold: PropTypes.number, }), ).isRequired, errors: PropTypes.shape({ diff --git a/src/components/BMDashboard/ItemList/ItemListView.module.css b/src/components/BMDashboard/ItemList/ItemListView.module.css index 9614144539..8c285099d9 100644 --- a/src/components/BMDashboard/ItemList/ItemListView.module.css +++ b/src/components/BMDashboard/ItemList/ItemListView.module.css @@ -1,4 +1,5 @@ -table thead th, table td { +table thead th, +table td { vertical-align: middle !important; } @@ -10,7 +11,10 @@ table thead th, table td { } .itemsTableContainer { - overflow-x: scroll; + overflow: auto; + max-height: 600px; + border: 1px solid #e5e7eb; + border-radius: 6px; } .itemsListContainer section { @@ -22,13 +26,14 @@ table thead th, table td { min-width: 1024px; overflow: scroll; font-size: small; + margin-bottom: 0 !important; } .itemsListContainer th { height: 2rem; } -.itemsCell td{ +.itemsCell td { vertical-align: middle; } @@ -47,6 +52,74 @@ table thead th, table td { color: black; } +.searchRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 12px 0; + flex-wrap: wrap; +} + +.searchBox { + position: relative; + display: flex; + align-items: center; + gap: 10px; +} + +.searchBox label { + font-weight: bold; + white-space: nowrap; +} + +.searchBox input { + height: 38px; + width: 320px; + max-width: 80vw; + padding: 5px 34px 5px 10px; + border: 1px solid #ccc; + border-radius: 6px; +} + +.clearSearch { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + border: none; + background: transparent; + font-size: 18px; + cursor: pointer; + color: #6b7280; +} + +.clearSearch:hover { + color: #111827; +} + +.foundCount { + font-size: 0.9rem; + opacity: 0.85; +} + +.stickyThead th { + position: sticky; + top: 0; + z-index: 2; + background: #ffffff; + border-bottom: 1px solid #e5e7eb; +} + +.sortableTh { + cursor: pointer; + user-select: none; +} + +.sortableTh:hover { + text-decoration: underline; +} + .selectInput { display: grid; grid-template-columns: auto 1fr auto 1fr auto 1fr; @@ -81,6 +154,7 @@ table thead th, table td { padding: 5px; margin-bottom: 8px; } + .btnPrimary { background-color: #468ef9; color: white; @@ -89,16 +163,82 @@ table thead th, table td { border-radius: 4px; cursor: pointer; } + .buttonsRow { display: flex; justify-content: center; gap: 15px; margin-bottom: 15px; } + .btnPrimary:hover { background-color: #3b82f6; } +.paginationBar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border: 1px solid #e5e7eb; + border-top: none; + border-radius: 0 0 6px 6px; + flex-wrap: wrap; +} + +.rowsPerPage { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.9rem; +} + +.rowsPerPage select { + height: 32px; + padding: 0 8px; + border-radius: 6px; + border: 1px solid #d1d5db; + background: #fff; +} + +.rangeInfo { + font-size: 0.9rem; + opacity: 0.85; +} + +.pageButtons { + display: flex; + align-items: center; + gap: 6px; +} + +.pageButtons button { + height: 32px; + min-width: 32px; + padding: 0 8px; + border-radius: 6px; + border: 1px solid #d1d5db; + background: white; + cursor: pointer; +} + +.pageButtons button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.activePage { + background-color: #468ef9 !important; + color: white !important; + border-color: #468ef9 !important; +} + +.ellipsis { + padding: 0 6px; + opacity: 0.75; +} + .darkMode { color: #e8edf4; } @@ -144,6 +284,11 @@ table thead th, table td { border-color: #3f5269 !important; } +.darkTable .stickyThead th { + background-color: #2f4157; + border-bottom: 1px solid #3f5269; +} + .darkTable tbody td, .darkTable tbody th { border-color: #2f4157 !important; @@ -157,6 +302,36 @@ table thead th, table td { background-color: #23375b; } +.darkMode .searchBox input { + background-color: #2a3f5f; + color: #e8edf4; + border: 1px solid #3f5269; +} + +.darkMode .searchBox input::placeholder { + color: #c8d2e0; +} + +.darkMode .clearSearch:hover { + color: #ffffff; +} + +.darkMode .paginationBar { + border-color: #2f4157; +} + +.darkMode .rowsPerPage select { + background-color: #2a3f5f; + color: #e8edf4; + border: 1px solid #3f5269; +} + +.darkMode .pageButtons button { + background-color: #1b2a41; + color: #e8edf4; + border: 1px solid #3f5269; +} + .darkDatePickerInput { background-color: #2a3f5f !important; color: #e8edf4 !important; @@ -249,11 +424,13 @@ table thead th, table td { background-color: #2a3f5f !important; } -/* Explicitly target react-datepicker time list via :global to override inline white background */ .darkDatePicker :global(.react-datepicker__time-container), .darkDatePicker :global(.react-datepicker__time-container .react-datepicker__time), .darkDatePicker :global(.react-datepicker__time-container .react-datepicker__time-box), -.darkDatePicker :global(.react-datepicker__time-container .react-datepicker__time-box ul.react-datepicker__time-list) { +.darkDatePicker + :global(.react-datepicker__time-container + .react-datepicker__time-box + ul.react-datepicker__time-list) { background-color: #2a3f5f !important; border-color: #3f5269 !important; color: #e8edf4 !important; @@ -279,7 +456,6 @@ table thead th, table td { display: none; } -/* Light mode styles to improve time list contrast */ .lightDatePickerInput { background-color: #ffffff !important; color: #111827 !important; @@ -340,7 +516,10 @@ table thead th, table td { .lightDatePicker :global(.react-datepicker__time-container), .lightDatePicker :global(.react-datepicker__time-container .react-datepicker__time), .lightDatePicker :global(.react-datepicker__time-container .react-datepicker__time-box), -.lightDatePicker :global(.react-datepicker__time-container .react-datepicker__time-box ul.react-datepicker__time-list) { +.lightDatePicker + :global(.react-datepicker__time-container + .react-datepicker__time-box + ul.react-datepicker__time-list) { background-color: #ffffff !important; border-color: #d1d5db !important; color: #1f2937 !important; @@ -365,3 +544,20 @@ table thead th, table td { .lightDatePickerPopper .react-datepicker__triangle { display: none; } + +@media (max-width: 900px) { + .selectInput { + grid-template-columns: 1fr; + max-width: 100%; + } + + .selectInput label { + text-align: left; + } + + .selectInput input, + .selectInput select { + max-width: 100%; + min-width: 0; + } +} diff --git a/src/components/BMDashboard/ItemList/ItemsTable.jsx b/src/components/BMDashboard/ItemList/ItemsTable.jsx index 53ae6cc115..8858015e6c 100644 --- a/src/components/BMDashboard/ItemList/ItemsTable.jsx +++ b/src/components/BMDashboard/ItemList/ItemsTable.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { Table, Button } from 'reactstrap'; import { BiPencil } from 'react-icons/bi'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -6,6 +6,24 @@ import { faSortDown, faSort, faSortUp } from '@fortawesome/free-solid-svg-icons' import RecordsModal from './RecordsModal'; import styles from './ItemListView.module.css'; +const rowsPerPageOptions = [25, 50, 100]; + +function generatePageNumbers(current, total) { + if (total <= 7) { + return Array.from({ length: total }, (_, i) => i + 1); + } + + if (current <= 3) { + return [1, 2, 3, 4, 5, '...', total]; + } + + if (current >= total - 2) { + return [1, '...', total - 4, total - 3, total - 2, total - 1, total]; + } + + return [1, '...', current - 1, current, current + 1, '...', total]; +} + export default function ItemsTable({ selectedProject, selectedItem, @@ -13,30 +31,22 @@ export default function ItemsTable({ UpdateItemModal, dynamicColumns, darkMode = false, + sortConfig, + onSort, + totalItems, + currentPage, + totalPages, + rowsPerPage, + startRow, + endRow, + onPageChange, + onRowsPerPageChange, }) { - const [sortedData, setData] = useState(filteredItems); const [modal, setModal] = useState(false); const [record, setRecord] = useState(null); const [recordType, setRecordType] = useState(''); const [updateModal, setUpdateModal] = useState(false); const [updateRecord, setUpdateRecord] = useState(null); - const [projectNameCol, setProjectNameCol] = useState({ - iconsToDisplay: faSort, - sortOrder: 'default', - }); - const [inventoryItemTypeCol, setInventoryItemTypeCol] = useState({ - iconsToDisplay: faSort, - sortOrder: 'default', - }); - - useEffect(() => { - setData(filteredItems); - }, [filteredItems]); - - useEffect(() => { - setInventoryItemTypeCol({ iconsToDisplay: faSort, sortOrder: 'default' }); - setProjectNameCol({ iconsToDisplay: faSort, sortOrder: 'default' }); - }, [selectedProject, selectedItem]); const handleEditRecordsClick = (selectedEl, type) => { if (type === 'Update') { @@ -51,41 +61,21 @@ export default function ItemsTable({ setRecordType(type); }; - const sortData = columnName => { - const newSortedData = [...sortedData]; - - if (columnName === 'ProjectName') { - if (projectNameCol.sortOrder === 'default' || projectNameCol.sortOrder === 'desc') { - newSortedData.sort((a, b) => (a.project?.name || '').localeCompare(b.project?.name || '')); - setProjectNameCol({ iconsToDisplay: faSortUp, sortOrder: 'asc' }); - } else if (projectNameCol.sortOrder === 'asc') { - newSortedData.sort((a, b) => (b.project?.name || '').localeCompare(a.project?.name || '')); - setProjectNameCol({ iconsToDisplay: faSortDown, sortOrder: 'desc' }); - } - setInventoryItemTypeCol({ iconsToDisplay: faSort, sortOrder: 'default' }); - } else if (columnName === 'InventoryItemType') { - if ( - inventoryItemTypeCol.sortOrder === 'default' || - inventoryItemTypeCol.sortOrder === 'desc' - ) { - newSortedData.sort((a, b) => - (a.itemType?.name || '').localeCompare(b.itemType?.name || ''), - ); - setInventoryItemTypeCol({ iconsToDisplay: faSortUp, sortOrder: 'asc' }); - } else if (inventoryItemTypeCol.sortOrder === 'asc') { - newSortedData.sort((a, b) => - (b.itemType?.name || '').localeCompare(a.itemType?.name || ''), - ); - setInventoryItemTypeCol({ iconsToDisplay: faSortDown, sortOrder: 'desc' }); - } - setProjectNameCol({ iconsToDisplay: faSort, sortOrder: 'default' }); - } + const getNestedValue = (obj, path) => { + return path.split('.').reduce((acc, part) => (acc ? acc[part] : null), obj); + }; - setData(newSortedData); + const getIconFor = key => { + if (!sortConfig?.key || sortConfig.key !== key) return faSort; + return sortConfig.direction === 'asc' ? faSortUp : faSortDown; }; - const getNestedValue = (obj, path) => { - return path.split('.').reduce((acc, part) => (acc ? acc[part] : null), obj); + const dynamicSortKeyByLabel = { + Bought: 'bought', + Used: 'used', + Available: 'available', + Wasted: 'wasted', + Hold: 'hold', }; return ( @@ -98,27 +88,34 @@ export default function ItemsTable({ recordType={recordType} /> +
- + - {selectedProject === 'all' ? ( - - ) : ( - - )} - {selectedItem === 'all' ? ( - - ) : ( - - )} - {dynamicColumns.map(({ label }) => ( - - ))} + + + + + {dynamicColumns.map(({ label }) => { + const sortKey = dynamicSortKeyByLabel[label]; + const clickable = Boolean(sortKey); + + return ( + + ); + })} + @@ -126,65 +123,67 @@ export default function ItemsTable({ - {sortedData && sortedData.length > 0 ? ( - sortedData.map(el => { - return ( - - - - {dynamicColumns.map(({ label, key }) => ( - - ))} - - - - - ); - }) + {filteredItems && filteredItems.length > 0 ? ( + filteredItems.map(el => ( + + + + + {dynamicColumns.map(({ label, key }) => ( + + ))} + + + + + + + + )) ) : ( - @@ -192,6 +191,74 @@ export default function ItemsTable({
sortData('ProjectName')}> - Project - Project sortData('InventoryItemType')}> - Name - Name{label} onSort?.('project')} className={styles.sortableTh}> + Project + onSort?.('name')} className={styles.sortableTh}> + Name + onSort?.(sortKey) : undefined} + className={clickable ? styles.sortableTh : undefined} + > + {label} {clickable && } + Usage Record Updates Purchases
{el.project?.name}{el.itemType?.name}{getNestedValue(el, key)} - - - - - - - -
{el.project?.name}{el.itemType?.name}{getNestedValue(el, key)} + + + + + + + +
+ No items data
+ +
+
+ Rows per page: + +
+ +
+ {startRow}-{endRow} of {totalItems} +
+ +
+ + + + + {generatePageNumbers(currentPage, totalPages).map((p, idx) => + typeof p === 'number' ? ( + + ) : ( + + ... + + ), + )} + + + + +
+
); }