diff --git a/src/components/BMDashboard/Equipment/Add/AddTypeForm.jsx b/src/components/BMDashboard/Equipment/Add/AddTypeForm.jsx
index 2e2381bc7e..1a3bb1aa08 100644
--- a/src/components/BMDashboard/Equipment/Add/AddTypeForm.jsx
+++ b/src/components/BMDashboard/Equipment/Add/AddTypeForm.jsx
@@ -6,6 +6,7 @@ import { toast } from 'react-toastify';
import { useHistory } from 'react-router-dom';
import { addEquipmentType, fetchAllEquipments } from '~/actions/bmdashboard/equipmentActions';
+import BMCharacterLimitHint from '../../shared/BMCharacterLimitHint';
const FuelTypes = {
dies: 'Diesel',
@@ -15,13 +16,15 @@ const FuelTypes = {
etha: 'Ethanol',
};
+const DESC_CHAR_LIMIT = 150;
+
// const [inputText, setInputText] = useState('');
const schema = Joi.object({
name: Joi.string().required(),
desc: Joi.string()
.required()
- .max(150),
+ .max(DESC_CHAR_LIMIT),
});
export default function AddTypeForm() {
@@ -46,7 +49,7 @@ export default function AddTypeForm() {
setName(event.target.value);
}
if (event.target.name === 'desc') {
- setDesc(event.target.value);
+ setDesc(event.target.value.slice(0, DESC_CHAR_LIMIT));
}
if (event.target.name === 'fuel') {
setFuel(event.target.value);
@@ -104,13 +107,16 @@ export default function AddTypeForm() {
name="desc"
type="textarea"
rows={2}
+ maxLength={DESC_CHAR_LIMIT}
value={desc}
invalid={errInput === 'desc'}
onChange={handleChange}
/>
-
150 ? '#dc3545' : 'black' }}>
- Character {desc.length}/150
-
+
{/* {!errInput && Max 150 characters } */}
{errType === 'string.max'
diff --git a/src/components/BMDashboard/EquipmentPurchaseRequest/PurchaseForm.jsx b/src/components/BMDashboard/EquipmentPurchaseRequest/PurchaseForm.jsx
index e98acccf74..e71190cde5 100644
--- a/src/components/BMDashboard/EquipmentPurchaseRequest/PurchaseForm.jsx
+++ b/src/components/BMDashboard/EquipmentPurchaseRequest/PurchaseForm.jsx
@@ -7,9 +7,12 @@ import Joi from 'joi-browser';
import { boxStyle } from '~/styles';
import { purchaseEquipment } from '~/actions/bmdashboard/equipmentActions';
+import BMCharacterLimitHint from '../shared/BMCharacterLimitHint';
import stylesPurchaseForm from './PurchaseForm.module.css';
+const DESC_CHAR_LIMIT = 150;
+
export default function PurchaseForm() {
const bmProjects = useSelector(state => state.bmProjects);
const equipments = useSelector(state => state.bmInvTypes.list);
@@ -38,7 +41,7 @@ export default function PurchaseForm() {
estTime: Joi.string().required(),
desc: Joi.string()
.required()
- .max(150),
+ .max(DESC_CHAR_LIMIT),
makeModel: Joi.string().allow(''),
});
@@ -204,13 +207,14 @@ export default function PurchaseForm() {
{
setValidationError('');
- setDesc(currentTarget.value);
+ setDesc(currentTarget.value.slice(0, DESC_CHAR_LIMIT));
}}
/>
- Max 150 characters
+
Preferred Make & Model (optional)
diff --git a/src/components/BMDashboard/ToolPurchaseRequest/PurchaseForm.jsx b/src/components/BMDashboard/ToolPurchaseRequest/PurchaseForm.jsx
index 753ec4756e..4465fe1c66 100644
--- a/src/components/BMDashboard/ToolPurchaseRequest/PurchaseForm.jsx
+++ b/src/components/BMDashboard/ToolPurchaseRequest/PurchaseForm.jsx
@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import { toast } from 'react-toastify';
@@ -7,18 +7,99 @@ import Joi from 'joi-browser';
import { boxStyle } from '~/styles';
import { purchaseTools } from '~/actions/bmdashboard/toolActions';
+import BMCharacterLimitHint from '../shared/BMCharacterLimitHint';
import styles from './PurchaseForm.module.css';
+const PRIORITY_ORDER = { Low: 1, Medium: 2, High: 3 };
+const RECENT_REQUEST_WINDOW_DAYS = 30;
+const DESC_CHAR_LIMIT = 150;
+
+function getObjectId(value) {
+ if (!value) return '';
+ if (typeof value === 'string') return value;
+ if (typeof value === 'object' && value._id) return value._id;
+ return '';
+}
+
+function formatDate(dateValue) {
+ if (!dateValue) return 'No prior requests';
+
+ const date = new Date(dateValue);
+
+ if (Number.isNaN(date.getTime())) {
+ return 'No prior requests';
+ }
+
+ return date.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ });
+}
+
+function getDaysSince(dateValue) {
+ if (!dateValue) return Number.POSITIVE_INFINITY;
+
+ const date = new Date(dateValue);
+
+ if (Number.isNaN(date.getTime())) {
+ return Number.POSITIVE_INFINITY;
+ }
+
+ return Math.floor((Date.now() - date.getTime()) / (1000 * 60 * 60 * 24));
+}
+
+function getSuggestedPriority(records, availabilitySummary) {
+ if (records.length) {
+ const counts = records.reduce((acc, record) => {
+ const recordPriority = record.priority || 'Low';
+ const current = acc[recordPriority] || { count: 0, latest: 0 };
+ const latest = new Date(record.date || 0).getTime();
+
+ acc[recordPriority] = {
+ count: current.count + 1,
+ latest: Math.max(current.latest, Number.isNaN(latest) ? 0 : latest),
+ };
+
+ return acc;
+ }, {});
+
+ return Object.entries(counts).sort((a, b) => {
+ if (b[1].count !== a[1].count) return b[1].count - a[1].count;
+ if (b[1].latest !== a[1].latest) return b[1].latest - a[1].latest;
+ return PRIORITY_ORDER[b[0]] - PRIORITY_ORDER[a[0]];
+ })[0][0];
+ }
+
+ if (
+ availabilitySummary.projectAvailableCount === 0 &&
+ availabilitySummary.projectUsingCount > 0
+ ) {
+ return 'High';
+ }
+
+ if (
+ availabilitySummary.projectAvailableCount === 0 &&
+ availabilitySummary.globalAvailableCount > 0
+ ) {
+ return 'Medium';
+ }
+
+ return 'Low';
+}
+
export default function PurchaseForm() {
const bmProjects = useSelector(state => state.bmProjects);
const tools = useSelector(state => state.bmInvTypes.list);
+ const toolInventory = useSelector(state => state.bmTools.toolslist || []);
const history = useHistory();
const [projectId, setProjectId] = useState('');
const [toolId, setToolId] = useState('');
const [quantity, setQty] = useState('');
const [priority, setPriority] = useState('Low');
+ const [priorityTouched, setPriorityTouched] = useState(false);
const [makeModel, setMakeModel] = useState('');
const [estTime, setEstTime] = useState('');
const [desc, setDesc] = useState('');
@@ -36,10 +117,109 @@ export default function PurchaseForm() {
estTime: Joi.string().required(),
desc: Joi.string()
.required()
- .max(150),
+ .max(DESC_CHAR_LIMIT),
makeModel: Joi.string().allow(''),
});
+ const sortedTools = [...tools].sort((a, b) => a.name.localeCompare(b.name));
+ const selectedTool = sortedTools.find(tool => tool._id === toolId);
+
+ const selectedProjectInventory = projectId
+ ? toolInventory.filter(tool => getObjectId(tool.project) === projectId)
+ : [];
+
+ const projectSpecificToolIds = new Set();
+
+ selectedProjectInventory.forEach(tool => {
+ const itemTypeId = getObjectId(tool.itemType);
+
+ if (itemTypeId) {
+ projectSpecificToolIds.add(itemTypeId);
+ }
+ });
+
+ sortedTools.forEach(tool => {
+ const availableOnProject = (tool.available || []).some(
+ item => getObjectId(item.project) === projectId,
+ );
+ const inUseOnProject = (tool.using || []).some(item => getObjectId(item.project) === projectId);
+
+ if (availableOnProject || inUseOnProject) {
+ projectSpecificToolIds.add(tool._id);
+ }
+ });
+
+ const projectFocusedTools = sortedTools.filter(tool => projectSpecificToolIds.has(tool._id));
+ const otherTools = sortedTools.filter(tool => !projectSpecificToolIds.has(tool._id));
+ const selectedProjectName = bmProjects.find(project => project._id === projectId)?.name || '';
+
+ const matchingToolRecords = toolId
+ ? toolInventory.filter(tool => getObjectId(tool.itemType) === toolId)
+ : [];
+ const matchingProjectToolRecords = matchingToolRecords.filter(
+ tool => getObjectId(tool.project) === projectId,
+ );
+
+ const projectPurchaseHistory = matchingProjectToolRecords
+ .flatMap(tool => tool.purchaseRecord || [])
+ .sort((a, b) => new Date(b.date || 0) - new Date(a.date || 0));
+
+ const crossProjectPurchaseHistory = matchingToolRecords
+ .flatMap(tool => tool.purchaseRecord || [])
+ .sort((a, b) => new Date(b.date || 0) - new Date(a.date || 0));
+
+ const projectAvailableCount = (selectedTool?.available || []).filter(
+ item => getObjectId(item.project) === projectId,
+ ).length;
+ const projectUsingCount = (selectedTool?.using || []).filter(
+ item => getObjectId(item.project) === projectId,
+ ).length;
+ const globalAvailableCount = selectedTool?.available?.length || 0;
+ const globalUsingCount = selectedTool?.using?.length || 0;
+
+ const availabilitySummary = {
+ projectAvailableCount,
+ projectUsingCount,
+ globalAvailableCount,
+ globalUsingCount,
+ };
+
+ const suggestedPriority = getSuggestedPriority(projectPurchaseHistory, availabilitySummary);
+ const lastRequestedRecord = projectPurchaseHistory[0] || crossProjectPurchaseHistory[0] || null;
+ const recentDuplicateRecord =
+ projectPurchaseHistory.find(
+ record => getDaysSince(record.date) <= RECENT_REQUEST_WINDOW_DAYS,
+ ) || null;
+
+ const availabilityStatus = (() => {
+ if (!selectedTool) return 'Select a tool to view availability.';
+ if (!projectId)
+ return `${globalAvailableCount} available and ${globalUsingCount} in use across all projects.`;
+ if (projectAvailableCount > 0) {
+ return `${projectAvailableCount} available on ${selectedProjectName}.`;
+ }
+ if (projectUsingCount > 0) {
+ return `${projectUsingCount} currently in use on ${selectedProjectName}.`;
+ }
+ if (globalAvailableCount > 0) {
+ return `${globalAvailableCount} available on other projects.`;
+ }
+ if (globalUsingCount > 0) {
+ return `${globalUsingCount} currently in use across other projects.`;
+ }
+ return 'No active inventory found for this tool yet.';
+ })();
+
+ useEffect(() => {
+ if (!priorityTouched) {
+ setPriority(suggestedPriority);
+ }
+ }, [suggestedPriority, priorityTouched]);
+
+ useEffect(() => {
+ setPriorityTouched(false);
+ }, [projectId, toolId]);
+
const handleSubmit = async e => {
e.preventDefault();
const { error } = schema.validate({
@@ -73,6 +253,7 @@ export default function PurchaseForm() {
setToolId('');
setQty('');
setPriority('Low');
+ setPriorityTouched(false);
setMakeModel('');
setEstTime('');
setDesc('');
@@ -128,13 +309,70 @@ export default function PurchaseForm() {
{' '}
- {tools.map(({ _id, name }) => (
-
- {name}
-
- ))}
+ {!projectId &&
+ sortedTools.map(({ _id, name }) => (
+
+ {name}
+
+ ))}
+ {!!projectId && !!projectFocusedTools.length && (
+
+ {projectFocusedTools.map(({ _id, name }) => (
+
+ {name}
+
+ ))}
+
+ )}
+ {!!projectId && !!otherTools.length && (
+
+ {otherTools.map(({ _id, name }) => (
+
+ {name}
+
+ ))}
+
+ )}
+ {projectId && (
+
+ {projectFocusedTools.length
+ ? 'Tools already used, available, or previously requested on this project are grouped first.'
+ : 'No prior tool history was found for this project, so the full catalog is shown.'}
+
+ )}
+ {toolId && (
+
+
+
Selection Insights
+
+ Suggested priority: {suggestedPriority}
+
+
+
+
+
Availability Status
+ {availabilityStatus}
+
+
+
Last Requested
+ {formatDate(lastRequestedRecord?.date)}
+
+
+
Common Use Case
+ {selectedTool?.description || 'No usage guidance added for this tool yet.'}
+
+
+ {recentDuplicateRecord && selectedProjectName && (
+
+ Warning: this tool was already requested for {selectedProjectName} on{' '}
+ {formatDate(recentDuplicateRecord.date)} with {recentDuplicateRecord.priority}{' '}
+ priority.
+
+ )}
+
+ )}
Quantity
@@ -159,6 +397,7 @@ export default function PurchaseForm() {
value={priority}
onChange={({ currentTarget }) => {
setValidationError('');
+ setPriorityTouched(true);
setPriority(currentTarget.value);
}}
>
@@ -188,13 +427,14 @@ export default function PurchaseForm() {
{
setValidationError('');
- setDesc(currentTarget.value);
+ setDesc(currentTarget.value.slice(0, DESC_CHAR_LIMIT));
}}
/>
- Max 150 characters
+
Preferred Make & Model (optional)
@@ -210,6 +450,20 @@ export default function PurchaseForm() {
{validationError &&
{validationError}
}
+ {!!projectId && !!toolId && (
+
+
Ready to Submit
+
+ You are requesting {quantity || '0'} {selectedTool?.name || 'tool'} for{' '}
+ {selectedProjectName || 'the selected project'} with {priority} priority.
+
+
+ Expected use: {estTime || 'not provided yet'}.
+ {makeModel ? ` Preferred make/model: ${makeModel}.` : ''}
+
+
{desc || 'Add a short usage description before submitting.'}
+
+ )}
{
dispatch(fetchBMProjects());
dispatch(fetchToolTypes());
+ dispatch(fetchTools());
}, []);
// trigger error state if an error object is added to props
diff --git a/src/components/BMDashboard/shared/BMCharacterLimitHint.jsx b/src/components/BMDashboard/shared/BMCharacterLimitHint.jsx
new file mode 100644
index 0000000000..838e99acf3
--- /dev/null
+++ b/src/components/BMDashboard/shared/BMCharacterLimitHint.jsx
@@ -0,0 +1,23 @@
+import PropTypes from 'prop-types';
+import { FormText } from 'reactstrap';
+
+export default function BMCharacterLimitHint({ limit, length, summary }) {
+ return (
+
+ {summary || `Max ${limit} characters`}
+ {limit - length} characters left
+
+ );
+}
+
+BMCharacterLimitHint.propTypes = {
+ limit: PropTypes.number.isRequired,
+ length: PropTypes.number.isRequired,
+ summary: PropTypes.string,
+};