diff --git a/.husky/pre-commit b/.husky/pre-commit index 9bfb8f99e4..5be0087ca7 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -3,7 +3,7 @@ export NVM_DIR="$HOME/.nvm" [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # Use Node 20 if available (or install if needed) -if [ -f .nvmrc ]; then +if command -v nvm >/dev/null 2>&1 && [ -f .nvmrc ]; then nvm use 20 2>/dev/null || nvm install 20 && nvm use 20 fi diff --git a/src/actions/bmdashboard/materialsActions.js b/src/actions/bmdashboard/materialsActions.js index 245961370b..bd5c18c425 100644 --- a/src/actions/bmdashboard/materialsActions.js +++ b/src/actions/bmdashboard/materialsActions.js @@ -158,6 +158,17 @@ export const postMaterialUpdateBulk = payload => { }; }; +export const postMaterialsBulkAction = async payload => { + return axios + .post(ENDPOINTS.BM_MATERIALS_BULK_ACTIONS, payload) + .then(res => res) + .catch(err => { + if (err.response) return err.response; + if (err.request) return err.request; + return err.message; + }); +}; + export const purchaseMaterial = async body => { return axios .post(ENDPOINTS.BM_MATERIALS, body) diff --git a/src/components/BMDashboard/ItemList/ItemListView.module.css b/src/components/BMDashboard/ItemList/ItemListView.module.css index 47d65ea217..739cae4154 100644 --- a/src/components/BMDashboard/ItemList/ItemListView.module.css +++ b/src/components/BMDashboard/ItemList/ItemListView.module.css @@ -567,3 +567,131 @@ table td { min-width: 0; } } + +.bulkActionsContainer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + flex-wrap: wrap; + padding: 10px 15px; + background-color: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 4px; + margin-bottom: 15px; +} + +.bulkActionsButton { + background-color: #1565c0; + color: white; + border: none; + padding: 8px 12px; + border-radius: 4px; + cursor: pointer; +} + +.bulkActionsButton:hover { + background-color: #0d4ea8; +} + +.bulkActionsButton:disabled { + background-color: #9aa6b2; + cursor: not-allowed; +} + +.selectedCount { + font-weight: bold; + color: #495057; +} + +.selectedRow { + background-color: #e3f2fd !important; +} + +.selectedRow:hover { + background-color: #bbdefb !important; +} + +.bulkStatusCell { + display: flex; + gap: 6px; + justify-content: center; + flex-wrap: wrap; +} + +.bulkTagHold, +.bulkTagReviewed, +.bulkTagNote, +.bulkTagNone { + font-size: 0.72rem; + padding: 2px 8px; + border-radius: 999px; + font-weight: 600; + line-height: 1.35; +} + +.bulkTagHold { + background: #fff4e5; + color: #a44900; + border: 1px solid #ffcf99; +} + +.bulkTagReviewed { + background: #e8f7ef; + color: #106c3f; + border: 1px solid #b8e6cb; +} + +.bulkTagNote { + background: #eaf1ff; + color: #1f4ba8; + border: 1px solid #c5d6ff; +} + +.bulkTagNone { + background: #f1f5f9; + color: #475569; + border: 1px solid #d8e0e8; +} + +.darkBulkActions { + background-color: #2f4157; + border: 1px solid #3f5269; + color: #e8edf4; +} + +.darkBulkActions .selectedCount { + color: #e8edf4; +} + +.darkTable .selectedRow { + background-color: #3f5269 !important; +} + +.darkTable .selectedRow:hover { + background-color: #4a5f7a !important; +} + +.darkTable .bulkTagHold { + background: #5a3a12; + color: #ffd8a6; + border-color: #7a4b1a; +} + +.darkTable .bulkTagReviewed { + background: #0f4a34; + color: #b7f7d7; + border-color: #166848; +} + +.darkTable .bulkTagNote { + background: #203a69; + color: #d3e3ff; + border-color: #2d4f89; +} + +.darkTable .bulkTagNone { + background: #334155; + color: #d1dae7; + border-color: #41556f; +} diff --git a/src/components/BMDashboard/ItemList/ItemsTable.jsx b/src/components/BMDashboard/ItemList/ItemsTable.jsx index a880ebadbd..2bef556a47 100644 --- a/src/components/BMDashboard/ItemList/ItemsTable.jsx +++ b/src/components/BMDashboard/ItemList/ItemsTable.jsx @@ -1,9 +1,33 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import { useDispatch } from 'react-redux'; import PropTypes from 'prop-types'; -import { Table, Button, Badge } from 'reactstrap'; +import { + Table, + Button, + Badge, + Dropdown, + DropdownToggle, + DropdownMenu, + DropdownItem, + Modal, + ModalHeader, + ModalBody, + ModalFooter, + Input, +} from 'reactstrap'; import { BiPencil } from 'react-icons/bi'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faSortDown, faSort, faSortUp } from '@fortawesome/free-solid-svg-icons'; +import { + faSortDown, + faSort, + faSortUp, + faFileCsv, + faFilePdf, +} from '@fortawesome/free-solid-svg-icons'; +import { toast } from 'react-toastify'; +import { jsPDF } from 'jspdf'; +import autoTable from 'jspdf-autotable'; +import { fetchAllMaterials, postMaterialsBulkAction } from '~/actions/bmdashboard/materialsActions'; import RecordsModal from './RecordsModal'; import styles from './ItemListView.module.css'; @@ -35,11 +59,30 @@ export default function ItemsTable({ onPageChange, onRowsPerPageChange, }) { + const dispatch = useDispatch(); 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 [selectedItems, setSelectedItems] = useState(new Set()); + const [selectAll, setSelectAll] = useState(false); + const [bulkActionsDropdownOpen, setBulkActionsDropdownOpen] = useState(false); + const [notesModalOpen, setNotesModalOpen] = useState(false); + const [bulkNotesValue, setBulkNotesValue] = useState(''); + const [isBulkActionLoading, setIsBulkActionLoading] = useState(false); + const isMaterialsTable = itemType?.toLowerCase() === 'materials'; + const pageItems = filteredItems || []; + + // Reset selection whenever the visible rows change (filter, sort, or page change). + useEffect(() => { + setSelectedItems(new Set()); + setSelectAll(false); + }, [filteredItems]); + + useEffect(() => { + setSelectAll(pageItems.length > 0 && selectedItems.size === pageItems.length); + }, [selectedItems, pageItems]); const handleEditRecordsClick = (selectedEl, type) => { if (type === 'Update') { @@ -54,9 +97,172 @@ export default function ItemsTable({ setRecordType(type); }; + const handleSelectAll = () => { + if (!pageItems.length) { + setSelectedItems(new Set()); + setSelectAll(false); + return; + } + + if (selectAll) { + setSelectedItems(new Set()); + setSelectAll(false); + } else { + setSelectedItems(new Set(pageItems.map(item => item._id))); + setSelectAll(true); + } + }; + + const handleSelectItem = itemId => { + const newSelected = new Set(selectedItems); + if (newSelected.has(itemId)) { + newSelected.delete(itemId); + } else { + newSelected.add(itemId); + } + setSelectedItems(newSelected); + }; + + const formatValue = value => { + if (typeof value === 'boolean') return value ? 'Yes' : 'No'; + if (value === undefined || value === null || value === '') return '-'; + return value; + }; + + // Round floats to 2 decimals to avoid noise like 1.806000000000001; pass other values through. + const roundIfNumber = value => + typeof value === 'number' && !Number.isInteger(value) ? Number(value.toFixed(2)) : value; + const getNestedValue = (obj, path) => path ? path.split('.').reduce((acc, part) => (acc ? acc[part] : null), obj) : null; + const escapeCsv = value => { + const str = String(value ?? ''); + // Prefix values that could be interpreted as spreadsheet formulas to prevent CSV + // injection, but leave plain numbers (incl. negatives) and the empty placeholder intact. + const isNumeric = str !== '' && !Number.isNaN(Number(str)); + const isFormulaRisk = !isNumeric && str !== '-' && /^[=+\-@]/.test(str); + const sanitized = isFormulaRisk ? `'${str}` : str; + return `"${sanitized.replaceAll('"', '""')}"`; + }; + + const exportToCsv = data => { + if (data.length === 0) return; + + const headers = ['Project', 'Name', ...dynamicColumns.map(col => col.label), 'Stock Available']; + const csvContent = [ + headers.map(escapeCsv).join(','), + ...data.map(item => + [ + item.project?.name || '', + item.itemType?.name || '', + ...dynamicColumns.map(col => formatValue(getNestedValue(item, col.key))), + item.stockAvailable || '', + ] + .map(escapeCsv) + .join(','), + ), + ].join('\n'); + + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + link.setAttribute('href', url); + link.setAttribute('download', `${itemType}_selected_items.csv`); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + link.remove(); + }; + + const exportToPdf = data => { + if (data.length === 0) return; + + const doc = new jsPDF(); + + doc.setFontSize(16); + doc.text(`${itemType} Selected Items`, 14, 16); + + const headers = ['Project', 'Name', ...dynamicColumns.map(col => col.label), 'Stock Available']; + const body = data.map(item => [ + item.project?.name || '', + item.itemType?.name || '', + ...dynamicColumns.map(col => formatValue(roundIfNumber(getNestedValue(item, col.key)))), + roundIfNumber(item.stockAvailable ?? 0), + ]); + + autoTable(doc, { + startY: 24, + head: [headers], + body, + headStyles: { fillColor: [0, 123, 255] }, + alternateRowStyles: { fillColor: [245, 245, 245] }, + styles: { fontSize: 9 }, + }); + + doc.save(`${itemType}_selected_items.pdf`); + }; + + const applyServerBulkAction = async (action, payload = {}) => { + if (selectedItems.size === 0 || isBulkActionLoading) return; + + setIsBulkActionLoading(true); + const response = await postMaterialsBulkAction({ + materialIds: Array.from(selectedItems), + action, + ...payload, + }); + + if (response?.status >= 200 && response?.status < 300) { + toast.success(response.data?.result || 'Bulk action applied successfully.'); + dispatch(fetchAllMaterials()); + setSelectedItems(new Set()); + setSelectAll(false); + setBulkActionsDropdownOpen(false); + } else { + const message = response?.data || 'Failed to apply bulk action.'; + toast.error(typeof message === 'string' ? message : 'Failed to apply bulk action.'); + } + + setIsBulkActionLoading(false); + }; + + const handleBulkAction = async action => { + const selectedData = pageItems.filter(item => selectedItems.has(item._id)); + + switch (action) { + case 'exportCsv': + exportToCsv(selectedData); + break; + case 'exportPdf': + exportToPdf(selectedData); + break; + case 'markAsHold': + await applyServerBulkAction('hold'); + break; + case 'markAsReviewed': + await applyServerBulkAction('review'); + break; + case 'addUpdateNotes': + setBulkNotesValue(''); + setNotesModalOpen(true); + break; + default: + break; + } + }; + + const submitBulkNotes = () => { + const trimmedNotes = bulkNotesValue.trim(); + if (!trimmedNotes) { + setNotesModalOpen(false); + return; + } + + applyServerBulkAction('notes', { notes: trimmedNotes }); + setNotesModalOpen(false); + }; + const getIconFor = key => { if (!sortConfig?.key || sortConfig.key !== key) return faSort; return sortConfig.direction === 'asc' ? faSortUp : faSortDown; @@ -96,10 +302,85 @@ export default function ItemsTable({ )} + setNotesModalOpen(false)}> + setNotesModalOpen(false)}>Add / Update Notes + + setBulkNotesValue(e.target.value)} + placeholder="Enter notes to apply to selected materials" + rows={5} + /> + + + + + + + + {isMaterialsTable && ( +
+ + {selectedItems.size} item{selectedItems.size === 1 ? '' : 's'} selected + + { + if (selectedItems.size === 0 || isBulkActionLoading) return; + setBulkActionsDropdownOpen(!bulkActionsDropdownOpen); + }} + > + + {isBulkActionLoading ? 'Applying...' : 'Bulk Actions'} + + + handleBulkAction('markAsHold')}> + Mark as Hold + + handleBulkAction('markAsReviewed')}> + Mark as Reviewed + + handleBulkAction('addUpdateNotes')}> + Add/Update Notes + + + handleBulkAction('exportCsv')}> + + Export Selected (CSV) + + handleBulkAction('exportPdf')}> + + Export Selected (PDF) + + + +
+ )} +
+ {isMaterialsTable && ( + + )} ); })} + {isMaterialsTable && } @@ -146,91 +428,123 @@ export default function ItemsTable({ - {filteredItems && filteredItems.length > 0 ? ( - filteredItems.map(el => ( - - - - {(dynamicColumns || []).map(({ label, key }) => { - const value = getNestedValue(el, key); - if ( - key === 'stockAvailable' && - value !== null && - value !== undefined && - Number(value) < 10 - ) { + {pageItems.length > 0 ? ( + pageItems.map(el => { + const isSelected = selectedItems.has(el._id); + const hasHold = Boolean(el.stockHold); + const hasReview = Boolean(el.isReviewed); + const hasNote = Boolean(el.notes?.trim()); + + return ( + + {isMaterialsTable && ( + + )} + + + {(dynamicColumns || []).map(({ label, key }) => { + const value = getNestedValue(el, key); + if ( + key === 'stockAvailable' && + value !== null && + value !== undefined && + Number(value) < 10 + ) { + return ( + + ); + } return ( ); - } - return ( - - ); - })} - + - - - - )) + + + + + + ); + }) ) : ( - diff --git a/src/utils/URL.js b/src/utils/URL.js index 24bd7e16fd..03402ed16f 100644 --- a/src/utils/URL.js +++ b/src/utils/URL.js @@ -373,6 +373,7 @@ export const ENDPOINTS = { BM_PROJECT_MATERIALS_COST: `${APIEndpoint}/material-costs`, BM_UPDATE_MATERIAL: `${APIEndpoint}/bm/updateMaterialRecord`, BM_UPDATE_MATERIAL_BULK: `${APIEndpoint}/bm/updateMaterialRecordBulk`, + BM_MATERIALS_BULK_ACTIONS: `${APIEndpoint}/bm/materials/bulk-actions`, BM_UPDATE_MATERIAL_STATUS: `${APIEndpoint}/bm/updateMaterialStatus`, BM_MATERIAL_STOCK_OUT_RISK: `${APIEndpoint}/bm/materials/stock-out-risk`, BM_UPDATE_REUSABLE: `${APIEndpoint}/bm/updateReusableRecord`, diff --git a/vite.config.js b/vite.config.js index 81006f65db..64b946fe36 100644 --- a/vite.config.js +++ b/vite.config.js @@ -4,6 +4,16 @@ import react from '@vitejs/plugin-react'; export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ''); + const sanitizedEnvDefines = Object.keys(env).reduce((prev, key) => { + const normalizedKey = key.replaceAll(/\W/g, '_'); + const sanitizedKey = /^\d/.test(normalizedKey) ? `_${normalizedKey}` : normalizedKey; + + // eslint-disable-next-line no-param-reassign + prev[`process.env.${sanitizedKey}`] = JSON.stringify(env[key]); + + return prev; + }, {}); + return { base: '/', resolve: { @@ -23,12 +33,7 @@ export default defineConfig(({ mode }) => { }, }, define: { - ...Object.keys(env).reduce((prev, key) => { - const sanitizedKey = key.replace(/[^a-zA-Z0-9_]/g, '_'); - // eslint-disable-next-line no-param-reassign - prev[`process.env.${sanitizedKey}`] = JSON.stringify(env[key]); - return prev; - }, {}), + ...sanitizedEnvDefines, }, build: { outDir: 'build',
+ + onSort?.('project')} className={styles.sortableTh} @@ -128,6 +409,7 @@ export default function ItemsTable({ Bulk Status Usage Record
{el.project?.name}{el.itemType?.name}
+ handleSelectItem(el._id)} + aria-label={`Select ${el.itemType?.name || 'item'}`} + /> + {el.project?.name}{el.itemType?.name} + + Low + + {value} + - - Low - {value} - {value} + })} + {isMaterialsTable && ( + +
+ {hasHold && On Hold} + {hasReview && Reviewed} + {hasNote && Has Note} + {!hasHold && !hasReview && !hasNote && ( + - + )} +
- + + + - - - - - - - - -
+ +
+ No items data