diff --git a/src/components/BMDashboard/Issues/IssuesList.jsx b/src/components/BMDashboard/Issues/IssuesList.jsx new file mode 100644 index 0000000000..dd38ec594a --- /dev/null +++ b/src/components/BMDashboard/Issues/IssuesList.jsx @@ -0,0 +1,448 @@ +/** + * IssuesList Component + * + * Displays a paginated, filterable list of open issues from the BM Dashboard. + * Supports filtering by date range, projects, and tags. + * Provides actions to rename, delete, and close issues. + * + * @component + */ +import { useState, useEffect, useMemo, useCallback } from 'react'; +import DatePicker from 'react-datepicker'; +import Select from 'react-select'; +import axios from 'axios'; +import { toast } from 'react-toastify'; +import 'react-datepicker/dist/react-datepicker.css'; +import { Table, Button, Dropdown, Form, Row, Col } from 'react-bootstrap'; +import styles from './IssuesList.module.css'; +import { useSelector } from 'react-redux'; +import { ENDPOINTS } from '../../../utils/URL'; + +/** + * Validates that an issue has all required fields for display. + * Filters out issues with empty or invalid issueTitle arrays. + * @param {Object} issue - The issue object from the API + * @returns {boolean} True if the issue is valid for display + */ +const isValidIssue = issue => + issue.issueTitle && + Array.isArray(issue.issueTitle) && + issue.issueTitle.length > 0 && + issue.issueTitle[0] && + typeof issue.issueTitle[0] === 'string' && + issue.issueTitle[0].trim() !== ''; + +/** + * Formats a Date object as YYYY-MM-DD string for API compatibility. + * @param {Date|null} date - The date to format + * @returns {string|null} Formatted date string or null if no date provided + */ +const formatDateForAPI = date => (date ? date.toISOString().split('T')[0] : null); + +/** + * Extracts a user-friendly error message from an API error response. + * @param {Error} err - The error object from axios + * @param {string} fallback - Default message if no specific error is found + * @returns {string} The error message to display + */ +const getErrorMessage = (err, fallback) => err.response?.data?.message || err.message || fallback; + +export default function IssuesList() { + const darkMode = useSelector(state => state.theme.darkMode); + + const [projects, setProjects] = useState([]); + const [openIssues, setOpenIssues] = useState([]); + const [tagFilter, setTagFilter] = useState(null); + const [selectedProjects, setSelectedProjects] = useState([]); + const [dateRange, setDateRange] = useState([null, null]); + const [editingId, setEditingId] = useState(null); + const [editedName, setEditedName] = useState(''); + const [dropdownOpenId, setDropdownOpenId] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const [pageGroupStart, setPageGroupStart] = useState(1); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(null); // Stores issue id to delete + + const [startDate, endDate] = dateRange; + const itemsPerPage = 5; + + // Fetch projects from the backend + const fetchProjects = useCallback(async () => { + try { + const response = await axios.get(ENDPOINTS.BM_GET_ISSUE_PROJECTS); + setProjects(response.data); + } catch (err) { + setError(`Error fetching projects: ${err.message || err}`); + } + }, []); + + // Fetch open issues with applied filters + const fetchIssuesWithFilters = useCallback(async () => { + try { + setLoading(true); + setError(''); + const projectIds = selectedProjects.length > 0 ? selectedProjects.join(',') : null; + const url = ENDPOINTS.BM_GET_OPEN_ISSUES( + projectIds, + formatDateForAPI(startDate), + formatDateForAPI(endDate), + tagFilter, + ); + const response = await axios.get(url); + setOpenIssues(response.data); + } catch (err) { + setError(`Error fetching open issues: ${err.message || err}`); + } finally { + setLoading(false); + } + }, [selectedProjects, startDate, endDate, tagFilter]); + + useEffect(() => { + fetchProjects(); + }, [fetchProjects]); + + useEffect(() => { + const fetchAndResetPagination = async () => { + await fetchIssuesWithFilters(); + setCurrentPage(1); + setPageGroupStart(1); + }; + fetchAndResetPagination(); + }, [fetchIssuesWithFilters]); + + // Memoize the mapped issues to avoid unnecessary recalculations + const mappedIssues = useMemo(() => { + return openIssues.filter(isValidIssue).map(issue => { + // Use issueDate (when issue occurred) instead of createdDate (when record was created) + const issueOpenDate = new Date(issue.issueDate); + const diffDays = Math.floor((Date.now() - issueOpenDate) / (1000 * 60 * 60 * 24)); + return { + id: issue._id, + name: issue.issueTitle[0], + tag: issue.tag || null, // Use null instead of empty string for cleaner logic + openSince: diffDays, + cost: issue.cost || 0, + person: issue.person, + }; + }); + }, [openIssues]); + + const projectOptions = useMemo( + () => projects.map(p => ({ value: p.projectId, label: p.projectName })), + [projects], + ); + + // Handle renaming an issue + const handleRename = id => { + const issue = mappedIssues.find(i => i.id === id); + if (issue) { + setEditingId(id); + setEditedName(issue.name); + } + setDropdownOpenId(null); + }; + + const handleNameSubmit = async id => { + const trimmedName = editedName.trim(); + + // Validate input - prevent empty names + if (!trimmedName) { + toast.error('Issue name cannot be empty'); + return; + } + + try { + // Backend expects issueTitle as array format, not dot notation + await axios.patch(ENDPOINTS.BM_ISSUE_UPDATE(id), { issueTitle: [trimmedName] }); + await fetchIssuesWithFilters(); + toast.success('Issue renamed successfully'); + setError(''); + } catch (err) { + const errorMsg = getErrorMessage(err, 'Failed to rename issue'); + toast.error(errorMsg); + setError(`Error updating issue name: ${errorMsg}`); + } + setEditingId(null); + setEditedName(''); + }; + + // Handle deleting an issue - show confirmation first + const handleDeleteClick = id => { + setConfirmDelete(id); + setDropdownOpenId(null); + }; + + const confirmDeleteAction = async () => { + if (!confirmDelete) return; + + try { + await axios.delete(ENDPOINTS.BM_ISSUE_UPDATE(confirmDelete)); + await fetchIssuesWithFilters(); + toast.success('Issue deleted successfully'); + } catch (err) { + const errorMsg = getErrorMessage(err, 'Failed to delete issue'); + toast.error(errorMsg); + setError(`Error deleting issue: ${errorMsg}`); + } + setConfirmDelete(null); + }; + + const cancelDelete = () => { + setConfirmDelete(null); + }; + + // Handle closing an issue + const handleCloseIssue = async id => { + try { + await axios.patch(ENDPOINTS.BM_ISSUE_UPDATE(id), { status: 'closed' }); + await fetchIssuesWithFilters(); + toast.success('Issue closed successfully'); + } catch (err) { + const errorMsg = getErrorMessage(err, 'Failed to close issue'); + toast.error(errorMsg); + setError(`Error closing issue: ${errorMsg}`); + } + setDropdownOpenId(null); + }; + + const currentItems = useMemo(() => { + const indexOfLast = currentPage * itemsPerPage; + return mappedIssues.slice(indexOfLast - itemsPerPage, indexOfLast); + }, [mappedIssues, currentPage]); + + // Format date for display + const formatDate = date => date?.toISOString().split('T')[0]; + const dateRangeLabel = + startDate && endDate ? `${formatDate(startDate)} - ${formatDate(endDate)}` : ''; + + return ( +
+

A List of Issues

+ + +
+ setDateRange(update)} + placeholderText={dateRangeLabel || 'Filter by Date Range'} + className={`${styles.datePickerInput} form-control ${darkMode ? 'dark-theme' : ''}`} + calendarClassName={darkMode ? styles.darkThemeCalendar : ''} + /> + +
+ + +