From d75e4212b47076c534bb5563a59589fe33a00110 Mon Sep 17 00:00:00 2001 From: sayali-2308 Date: Wed, 25 Feb 2026 19:19:14 -0500 Subject: [PATCH 1/2] feat: add user state indicator frontend - display, permissions, dark mode fix --- .../PermissionsManagement/Permissions.json | 5 + .../TeamMemberTasks/TeamMemberTask.jsx | 65 +- src/components/UserState/UserStateDisplay.jsx | 596 ++++++++++++++++++ .../WeeklySummariesReport/FormattedReport.jsx | 85 +-- .../WeeklySummariesReport.jsx | 85 +-- .../WeeklySummariesReport.module.css | 2 +- src/utils/URL.js | 4 + 7 files changed, 738 insertions(+), 104 deletions(-) create mode 100644 src/components/UserState/UserStateDisplay.jsx diff --git a/src/components/PermissionsManagement/Permissions.json b/src/components/PermissionsManagement/Permissions.json index e639cb465b..6505d78da8 100644 --- a/src/components/PermissionsManagement/Permissions.json +++ b/src/components/PermissionsManagement/Permissions.json @@ -525,6 +525,11 @@ "label": "Blue Square Email Management", "key": "resendBlueSquareAndSummaryEmails", "description": "Gives the user permission to access Blue Square Email Management and resend infringement emails and weekly summary emails." + }, + { + "label": "Manage user state indicator", + "key": "manage_user_state_indicator", + "description": "Gives the user permission to edit the state indicator for team members on the Dashboard Tasks and Weekly Summaries Reports pages." } ] }, diff --git a/src/components/TeamMemberTasks/TeamMemberTask.jsx b/src/components/TeamMemberTasks/TeamMemberTask.jsx index a52eaffd8c..3b9768d41e 100644 --- a/src/components/TeamMemberTasks/TeamMemberTask.jsx +++ b/src/components/TeamMemberTasks/TeamMemberTask.jsx @@ -1,34 +1,35 @@ -import React, { useState, useRef } from 'react'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faBell, - faCircle, faCheckCircle, - faTimesCircle, - faExpandArrowsAlt, + faCircle, faCompressArrowsAlt, + faExpandArrowsAlt, + faTimesCircle, } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import React, { useRef, useState } from 'react'; +import { Modal, ModalBody, ModalFooter, ModalHeader, Progress, Table } from 'reactstrap'; import CopyToClipboard from '~/components/common/Clipboard/CopyToClipboard'; -import { Table, Progress, Modal, ModalHeader, ModalFooter, ModalBody } from 'reactstrap'; +import UserStateDisplay from '../UserState/UserStateDisplay'; +import moment from 'moment-timezone'; +import { useDispatch, useSelector } from 'react-redux'; import { Link } from 'react-router-dom'; -import hasPermission from '~/utils/permissions'; -import styles from './style.module.css'; -import { getUserProfile } from '~/actions/userProfile.js'; import { toast } from 'react-toastify'; +import { getUserProfile } from '~/actions/userProfile.js'; import Warning from '~/components/Warnings/Warnings'; -import { useDispatch, useSelector } from 'react-redux'; -import moment from 'moment-timezone'; +import hasPermission from '~/utils/permissions'; +import styles from './style.module.css'; -import ReviewButton from './ReviewButton'; -import { getProgressColor, getProgressValue } from '../../utils/effortColors'; -import TeamMemberTaskIconsInfo from './TeamMemberTaskIconsInfo'; import { showTimeOffRequestModal } from '../../actions/timeOffRequestAction'; +import * as messages from '../../constants/followUpConstants'; +import { getProgressColor, getProgressValue } from '../../utils/effortColors'; import GoogleDocIcon from '../common/GoogleDocIcon'; +import TaskChangeLogModal from './components/TaskChangeLogModal'; import FollowupCheckButton from './FollowupCheckButton'; import FollowUpInfoModal from './FollowUpInfoModal'; -import TaskChangeLogModal from './components/TaskChangeLogModal'; -import * as messages from '../../constants/followUpConstants'; +import ReviewButton from './ReviewButton'; +import TeamMemberTaskIconsInfo from './TeamMemberTaskIconsInfo'; const NUM_TASKS_SHOW_TRUNCATE = 6; @@ -49,6 +50,7 @@ const TeamMemberTask = React.memo( displayUser, }) => { const darkMode = useSelector(state => state.theme.darkMode); + const auth = useSelector(state => state.auth); const taskCounts = useSelector(state => state.dashboard?.taskCounts ?? {}); const ref = useRef(null); const currentDate = moment.tz('America/Los_Angeles').startOf('day'); @@ -501,15 +503,28 @@ const TeamMemberTask = React.memo( data-label="Time" className={`${styles['team-clocks']} ${darkMode ? 'text-light' : ''}`} > - - {user.weeklycommittedHours ? user.weeklycommittedHours : 0} - {' '} - / - - {' '} - {thisWeekHours ? thisWeekHours.toFixed(1) : 0} - {' '} - / {totalHoursRemaining.toFixed(1)} +
+ + {user.weeklycommittedHours ? user.weeklycommittedHours : 0} + {' '} + / + + {' '} + {thisWeekHours ? thisWeekHours.toFixed(1) : 0} + {' '} + / {totalHoursRemaining.toFixed(1)} +
+
+ +
diff --git a/src/components/UserState/UserStateDisplay.jsx b/src/components/UserState/UserStateDisplay.jsx new file mode 100644 index 0000000000..b8f6d1f240 --- /dev/null +++ b/src/components/UserState/UserStateDisplay.jsx @@ -0,0 +1,596 @@ +import axios from 'axios'; +import PropTypes from 'prop-types'; +import { useCallback, useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { ENDPOINTS } from '~/utils/URL'; + +function formatDate(dateStr) { + if (!dateStr) return ''; + const d = new Date(dateStr); + return `(${d.getMonth() + 1}/${d.getDate()})`; +} + +const STATE_COLORS = { + 'closing-out': { bg: '#e74c3c', text: '#fff' }, + 'new-developer': { bg: '#3498db', text: '#fff' }, + 'pr-review-team': { bg: '#9b59b6', text: '#fff' }, + developer: { bg: '#27ae60', text: '#fff' }, + 'task-requested': { bg: '#e67e22', text: '#fff' }, +}; + +function getStateColor(key, darkMode) { + if (STATE_COLORS[key]) return STATE_COLORS[key]; + return { bg: darkMode ? '#2a3a5c' : '#607d8b', text: '#fff' }; +} + +function UserStateDisplay({ userId, canEdit }) { + const darkMode = useSelector(state => state.theme.darkMode); + const [catalog, setCatalog] = useState([]); + const [selected, setSelected] = useState([]); + const [isEditing, setIsEditing] = useState(false); + const [loading, setLoading] = useState(true); + const [newLabel, setNewLabel] = useState(''); + const [isAdding, setIsAdding] = useState(false); + const [isReordering, setIsReordering] = useState(false); + + const fetchData = useCallback(async () => { + try { + const [catalogRes, selectionRes] = await Promise.all([ + axios.get(ENDPOINTS.USER_STATE_CATALOG), + axios.get(ENDPOINTS.USER_STATE_SELECTION(userId)), + ]); + setCatalog(catalogRes.data.items || []); + setSelected(selectionRes.data.stateIndicators || []); + } catch (e) { + // silently fail + } finally { + setLoading(false); + } + }, [userId]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const handleToggle = async key => { + const isItemSelected = selected.some(s => s.key === key); + const updated = isItemSelected + ? selected.filter(s => s.key !== key) + : [...selected, { key, selectedAt: new Date().toISOString() }]; + + setSelected(updated); + try { + await axios.put(ENDPOINTS.USER_STATE_SELECTION(userId), { + selectedKeys: updated.map(s => s.key), + requestor: { role: 'Owner' }, + }); + } catch (e) { + setSelected(selected); + } + }; + + const handleAddNew = async () => { + const trimmed = newLabel.trim(); + if (!trimmed) return; + try { + const res = await axios.post(ENDPOINTS.USER_STATE_CATALOG, { + label: trimmed, + requestor: { role: 'Owner' }, + }); + setCatalog(prev => [...prev, res.data.item]); + setNewLabel(''); + setIsAdding(false); + } catch (e) { + alert(e?.response?.data?.error || 'Failed to add new state'); + } + }; + + const handleMoveUp = async idx => { + if (idx === 0) return; + const reordered = [...catalog]; + [reordered[idx - 1], reordered[idx]] = [reordered[idx], reordered[idx - 1]]; + setCatalog(reordered); + try { + await axios.put(`${ENDPOINTS.USER_STATE_CATALOG}/reorder`, { + orderedKeys: reordered.map(c => c.key), + requestor: { role: 'Owner' }, + }); + } catch (e) { + fetchData(); // revert on failure + } + }; + + const handleMoveDown = async idx => { + if (idx === catalog.length - 1) return; + const reordered = [...catalog]; + [reordered[idx], reordered[idx + 1]] = [reordered[idx + 1], reordered[idx]]; + setCatalog(reordered); + try { + await axios.put(ENDPOINTS.USER_STATE_CATALOG_REORDER, { + orderedKeys: reordered.map(c => c.key), + requestor: { role: 'Owner' }, + }); + } catch (e) { + fetchData(); + } + }; + + if (loading) return null; + + if (selected.length === 0) { + if (!canEdit) return null; + return ( +
+ setIsEditing(true)} + onKeyDown={e => e.key === 'Enter' && setIsEditing(true)} + role="button" + tabIndex={0} + style={{ cursor: 'pointer', fontSize: '11px', color: '#3498db' }} + > + ➕ Set State + + {isEditing && ( +
+
+ Select State: +
+
+ {catalog.map((item, idx) => { + const isItemSelected = selected.some(s => s.key === item.key); + const { bg, text } = getStateColor(item.key, darkMode); + return ( +
+ handleToggle(item.key)} + role="button" + tabIndex={0} + onKeyDown={e => e.key === 'Enter' && handleToggle(item.key)} + style={{ + display: 'inline-block', + padding: '4px 12px', + borderRadius: '20px', + fontSize: '12px', + fontWeight: '600', + cursor: 'pointer', + background: isItemSelected ? bg : darkMode ? '#2a3a5c' : '#e8f0fe', + color: isItemSelected ? text : darkMode ? '#cdd9f5' : '#1a3a6b', + border: `2px solid ${bg}`, + boxShadow: isItemSelected ? '0 2px 4px rgba(0,0,0,0.2)' : 'none', + opacity: isItemSelected ? 1 : 0.7, + }} + > + {item.label} + + {isReordering && ( +
+ + +
+ )} +
+ ); + })} +
+ {isAdding && ( +
+ setNewLabel(e.target.value)} + placeholder="e.g. 🌟 Star Developer" + maxLength={30} + style={{ + fontSize: '12px', + padding: '4px 8px', + borderRadius: '4px', + border: `1px solid ${darkMode ? '#4a6a9c' : '#b0c4de'}`, + background: darkMode ? '#1e2d4a' : '#fff', + color: darkMode ? '#cdd9f5' : '#1a3a6b', + width: '180px', + }} + onKeyDown={e => e.key === 'Enter' && handleAddNew()} + /> + + +
+ )} +
+ + + +
+
+ )} +
+ ); + } + const selectedItems = catalog + .filter(c => selected.some(s => s.key === c.key)) + .map(c => { + const sel = selected.find(s => s.key === c.key); + return { ...c, selectedAt: sel.selectedAt }; + }); + + return ( +
+ {/* Display selected state badges */} + {selectedItems.map(item => { + const { bg, text } = getStateColor(item.key, darkMode); + return ( + { + e.stopPropagation(); + setIsEditing(true); + } + : undefined + } + role={canEdit ? 'button' : undefined} + tabIndex={canEdit ? 0 : undefined} + onKeyDown={canEdit ? e => e.key === 'Enter' && setIsEditing(true) : undefined} + > + {formatDate(item.selectedAt)} {item.label} + + ); + })} + + {/* Edit panel */} + {canEdit && isEditing && ( +
+
+ Select State: +
+ + {/* State selection buttons */} +
+ {catalog.map((item, idx) => { + const isItemSelected = selected.some(s => s.key === item.key); + const { bg, text } = getStateColor(item.key, darkMode); + return ( +
+ handleToggle(item.key)} + role="button" + tabIndex={0} + onKeyDown={e => e.key === 'Enter' && handleToggle(item.key)} + style={{ + display: 'inline-block', + padding: '4px 12px', + borderRadius: '20px', + fontSize: '12px', + fontWeight: '600', + cursor: 'pointer', + background: isItemSelected ? bg : darkMode ? '#2a3a5c' : '#e8f0fe', + color: isItemSelected ? text : darkMode ? '#cdd9f5' : '#1a3a6b', + border: `2px solid ${bg}`, + boxShadow: isItemSelected ? '0 2px 4px rgba(0,0,0,0.2)' : 'none', + opacity: isItemSelected ? 1 : 0.7, + }} + > + {item.label} + + {/* Reorder buttons */} + {isReordering && ( +
+ + +
+ )} +
+ ); + })} +
+ + {/* Add new state input */} + {isAdding && ( +
+ setNewLabel(e.target.value)} + placeholder="e.g. 🌟 Star Developer" + maxLength={30} + style={{ + fontSize: '12px', + padding: '4px 8px', + borderRadius: '4px', + border: `1px solid ${darkMode ? '#4a6a9c' : '#b0c4de'}`, + background: darkMode ? '#1e2d4a' : '#fff', + color: darkMode ? '#cdd9f5' : '#1a3a6b', + width: '180px', + }} + onKeyDown={e => e.key === 'Enter' && handleAddNew()} + /> + + +
+ )} + + {/* Action buttons */} +
+ + + +
+
+ )} +
+ ); +} + +UserStateDisplay.propTypes = { + userId: PropTypes.string.isRequired, + canEdit: PropTypes.bool, +}; + +UserStateDisplay.defaultProps = { + canEdit: false, +}; + +export default UserStateDisplay; diff --git a/src/components/WeeklySummariesReport/FormattedReport.jsx b/src/components/WeeklySummariesReport/FormattedReport.jsx index 48c4f46313..94d100d86e 100644 --- a/src/components/WeeklySummariesReport/FormattedReport.jsx +++ b/src/components/WeeklySummariesReport/FormattedReport.jsx @@ -1,51 +1,52 @@ /* eslint-disable jsx-a11y/no-static-element-interactions */ /* eslint-disable jsx-a11y/click-events-have-key-events */ -import { useState, useRef, useEffect } from 'react'; -import { useDispatch } from 'react-redux'; import PropTypes from 'prop-types'; +import { useEffect, useRef, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import UserStateDisplay from '../UserState/UserStateDisplay'; // import moment from 'moment'; // import 'moment-timezone'; -import moment from 'moment-timezone'; +import { faCopy, faMailBulk } from '@fortawesome/free-solid-svg-icons'; +import axios from 'axios'; import parse from 'html-react-parser'; +import moment from 'moment-timezone'; import { Link } from 'react-router-dom'; import { toast } from 'react-toastify'; -import axios from 'axios'; -import { faCopy, faMailBulk } from '@fortawesome/free-solid-svg-icons'; import { - Modal, - ModalHeader, - ModalBody, - ModalFooter, + Alert, Button, - Input, - ListGroup, - ListGroupItem as LGI, Card, - Tooltip, - CardTitle, CardBody, CardImg, CardText, - UncontrolledPopover, - Row, + CardTitle, Col, - Alert, + Input, + ListGroupItem as LGI, + ListGroup, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + Row, + Tooltip, + UncontrolledPopover, } from 'reactstrap'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { assignStarDotColors, showStar } from '~/utils/leaderboardPermissions'; import { postLeaderboardData } from '~/actions/leaderBoardData'; -import { calculateDurationBetweenDates, showTrophyIcon } from '~/utils/anniversaryPermissions'; import { toggleUserBio } from '~/actions/weeklySummariesReport'; +import { calculateDurationBetweenDates, showTrophyIcon } from '~/utils/anniversaryPermissions'; import RoleInfoModal from '~/components/UserProfile/EditableModal/RoleInfoModal'; import CopyToClipboard from '~/components/common/Clipboard/CopyToClipboard'; -import styles from './WeeklySummariesReport.module.scss'; -import hasPermission, { cantUpdateDevAdminDetails } from '../../utils/permissions'; import { ENDPOINTS } from '~/utils/URL'; +import hasPermission, { cantUpdateDevAdminDetails } from '../../utils/permissions'; import ToggleSwitch from '../UserProfile/UserProfileEdit/ToggleSwitch'; import GoogleDocIcon from '../common/GoogleDocIcon'; +import styles from './WeeklySummariesReport.module.scss'; const textColors = { Default: '#000000', @@ -156,7 +157,7 @@ function FormattedReport({ return ( <> - + {summaries.map(summary => { // Add safety check for each summary if ( @@ -283,7 +284,7 @@ function EmailsList({ summaries, auth }) { return null; } -function getTextColorForHoursLogged(hoursLogged, promisedHours) { +function getTextColorForHoursLogged(hoursLogged, promisedHours, darkMode) { const percentage = (hoursLogged / promisedHours) * 100; if (percentage < 50) { @@ -292,7 +293,7 @@ function getTextColorForHoursLogged(hoursLogged, promisedHours) { if (percentage < 100) { return '#0B6623'; } - return 'black'; + return darkMode ? '#fff' : 'black'; } function ReportDetails({ @@ -337,7 +338,7 @@ function ReportDetails({ return (
  • - + {/* TWO-COLUMN CONTENT BELOW */} - - + + -

    - {/* Hours logged: {hoursLogged.toFixed(2)} / {summary.promisedHoursByWeek[weekIndex]} */} - Hours logged: {hoursLogged} / {promisedHours} -

    +
    +

    + Hours logged: {hoursLogged} / {promisedHours} +

    + +
    @@ -409,6 +419,7 @@ function ReportDetails({ {

    diff --git a/src/components/WeeklySummariesReport/WeeklySummariesReport.module.css b/src/components/WeeklySummariesReport/WeeklySummariesReport.module.css index 8651e78c60..adcdf67d0a 100644 --- a/src/components/WeeklySummariesReport/WeeklySummariesReport.module.css +++ b/src/components/WeeklySummariesReport/WeeklySummariesReport.module.css @@ -568,4 +568,4 @@ :global(.dark-mode) :global(.select-item.selected) { background-color: #4c566a !important; font-weight: bold; -} +} \ No newline at end of file diff --git a/src/utils/URL.js b/src/utils/URL.js index f0f482d2e7..f55f01207a 100644 --- a/src/utils/URL.js +++ b/src/utils/URL.js @@ -410,6 +410,10 @@ export const ENDPOINTS = { HGN_FORM_UPDATE_USER_SKILLS_FOLLOWUP_SUBMIT: `${APIEndpoint}/skills/profile/updateFollowUp/`, // HGN Skills Dashboard SKILLS_PROFILE: userId => `${APIEndpoint}/skills/profile/${userId}`, + // User State Indicator endpoints + USER_STATE_CATALOG: `${APIEndpoint}/userstate/catalog`, + USER_STATE_CATALOG_REORDER: `${APIEndpoint}/userstate/catalog/reorder`, + USER_STATE_SELECTION: userId => `${APIEndpoint}/userstate/selection/${userId}`, CREATE_JOB_FORM: `${APIEndpoint}/jobforms`, UPDATE_JOB_FORM: `${APIEndpoint}/jobforms`, From 31b582107c4bd6bdc8ef566987b1b4b17edd08f8 Mon Sep 17 00:00:00 2001 From: sayali-2308 Date: Wed, 25 Feb 2026 20:21:57 -0500 Subject: [PATCH 2/2] fix: correct reorder endpoint and set state UX in UserStateDisplay --- src/components/UserState/UserStateDisplay.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/UserState/UserStateDisplay.jsx b/src/components/UserState/UserStateDisplay.jsx index b8f6d1f240..5529b34542 100644 --- a/src/components/UserState/UserStateDisplay.jsx +++ b/src/components/UserState/UserStateDisplay.jsx @@ -91,7 +91,7 @@ function UserStateDisplay({ userId, canEdit }) { [reordered[idx - 1], reordered[idx]] = [reordered[idx], reordered[idx - 1]]; setCatalog(reordered); try { - await axios.put(`${ENDPOINTS.USER_STATE_CATALOG}/reorder`, { + await axios.put(ENDPOINTS.USER_STATE_CATALOG_REORDER, { orderedKeys: reordered.map(c => c.key), requestor: { role: 'Owner' }, });