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 + 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 }) => ( - - ))} + {!projectId && + sortedTools.map(({ _id, name }) => ( + + ))} + {!!projectId && !!projectFocusedTools.length && ( + + {projectFocusedTools.map(({ _id, name }) => ( + + ))} + + )} + {!!projectId && !!otherTools.length && ( + + {otherTools.map(({ _id, 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. +

+ )} +
+ )}
@@ -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 + @@ -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.'}

+
+ )}