diff --git a/src/components/VoteAnalytics.css b/src/components/VoteAnalytics.css new file mode 100644 index 0000000..ed953b0 --- /dev/null +++ b/src/components/VoteAnalytics.css @@ -0,0 +1,78 @@ +.vote-analytics-container { + margin-bottom: 2rem; +} + +.vote-analytics-container h3 { + margin-bottom: 1.5rem; + color: var(--color-gray100, #1d1127); + font-size: 1.75rem; + font-weight: 600; +} + +.analytics-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1.5rem; + margin-bottom: 1rem; +} + +.analytics-card { + background: white; + border: 1px solid var(--color-gray400); + border-radius: 12px; + padding: 1.5rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + transition: box-shadow 0.2s ease, transform 0.05s ease; +} + +.analytics-card:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + transform: translateY(-1px); +} + +.analytics-value { + font-size: 2.5rem; + font-weight: 700; + color: var(--color-blurple); + margin-bottom: 0.5rem; + line-height: 1; +} + +.analytics-label { + font-size: 1rem; + font-weight: 600; + color: var(--color-gray100); + line-height: 1.3; +} + +.analytics-subtext { + font-size: 0.85rem; + font-weight: 400; + color: var(--color-gray300); + margin-top: 0.25rem; + opacity: 0.8; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .analytics-grid { + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + } + + .analytics-card { + padding: 1.25rem; + } + + .analytics-value { + font-size: 2rem; + } + + .analytics-label { + font-size: 0.9rem; + } + + .analytics-subtext { + font-size: 0.8rem; + } +} diff --git a/src/components/VoteAnalytics.js b/src/components/VoteAnalytics.js new file mode 100644 index 0000000..844a1cd --- /dev/null +++ b/src/components/VoteAnalytics.js @@ -0,0 +1,71 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const VoteAnalytics = ({data, awardCategories, projects, userCount}) => { + // Calculate total votes cast across all categories + const totalVotesCast = Object.values(data).reduce((total, categoryVotes) => { + return total + Object.values(categoryVotes).reduce((sum, votes) => sum + votes, 0); + }, 0); + + // Calculate total possible votes (each user gets 5 votes) + const totalPossibleVotes = userCount * 5; + + // Calculate participation percentage + const participationPercentage = + totalPossibleVotes > 0 ? ((totalVotesCast / totalPossibleVotes) * 100).toFixed(1) : 0; + + // Calculate votes per category + const votesPerCategory = Object.keys(awardCategories) + .map((categoryKey) => { + const categoryVotes = data[categoryKey] || {}; + const total = Object.values(categoryVotes).reduce((sum, votes) => sum + votes, 0); + return { + name: awardCategories[categoryKey].name, + total, + key: categoryKey, + }; + }) + .sort((a, b) => b.total - a.total); + + // Find most active category + const mostActiveCategory = votesPerCategory[0]; + + return ( +
+

Vote Analytics Dashboard

+
+
+
{totalVotesCast}
+
Total Votes Cast
+
+ +
+
{participationPercentage}%
+
+ Participation Rate +
+ {totalVotesCast} / {totalPossibleVotes} possible votes +
+
+
+ +
+
{mostActiveCategory?.total || 0}
+
+ Most Active Category +
{mostActiveCategory?.name || 'N/A'}
+
+
+
+
+ ); +}; + +VoteAnalytics.propTypes = { + data: PropTypes.object.isRequired, + awardCategories: PropTypes.object.isRequired, + projects: PropTypes.object.isRequired, + userCount: PropTypes.number.isRequired, +}; + +export default VoteAnalytics; diff --git a/src/components/VoteTable.css b/src/components/VoteTable.css new file mode 100644 index 0000000..c36c406 --- /dev/null +++ b/src/components/VoteTable.css @@ -0,0 +1,239 @@ +.vote-table-container { + margin-bottom: 2rem; + margin-left: auto; + margin-right: auto; +} + +/* Admin pages - make table wider */ +.admin-page .vote-table-container { + max-width: none; +} + +.vote-table-container h3 { + margin-bottom: 1rem; + color: var(--color-gray100, #1d1127); + font-size: 1.75rem; + font-weight: 600; +} + +.vote-table-wrapper { + overflow-x: auto; + border-radius: 8px; + border: 1px solid var(--color-gray400); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + max-width: 100%; +} + +.vote-table { + width: 100%; + border-collapse: separate; + background: white; + font-size: 1.1rem; +} + +.vote-table th, +.vote-table td { + padding: 1rem 1.5rem; /* More comfortable padding on desktop */ + text-align: left; + border-bottom: 1px solid var(--color-gray400); +} + +.vote-table th { + background: var(--color-gray400); + color: var(--color-gray100); + font-weight: 600; + font-size: 1.2rem; + position: sticky; + top: 0; + z-index: 10; +} + +.vote-table tbody tr:hover { + background-color: #f8f9fa; +} + +/* Top 5 rows styling - rows 2-6 (category totals + top 5 projects) */ +.vote-table tbody tr:nth-child(n + 2):nth-child(-n + 6) { + background-color: #f0f8ff !important; /* Light blue background */ + font-weight: 500; /* Slightly bolder text */ +} + +/* Top 5 project names - make them bolder for additional distinction */ +.vote-table tbody tr:nth-child(n + 2):nth-child(-n + 6) .project-name { + font-weight: 700 !important; /* Bold project names for top 5 */ +} + +/* Top 5 project names - make them bolder for additional distinction */ +.vote-table tbody tr:nth-child(n + 2):nth-child(-n + 6) .project-name a { + font-weight: 700 !important; /* Bold project names for top 5 */ + color: #007bff !important; /* Blue color to match the theme */ +} + +/* Ensure hover effect still works on top 5 rows (rows 2-6) */ +.vote-table tbody tr:nth-child(n + 2):nth-child(-n + 6):hover { + background-color: #e3f2fd !important; /* Slightly darker blue on hover */ +} + +/* Thicker divider between 5th and 6th highest project rows */ +/* Category totals is row 1, then projects start at row 2, so divider is after row 6 (6th project) */ +.vote-table tbody tr:nth-child(7) { + border-top: 3px solid #dee2e6 !important; /* Thicker top border */ +} + +/* Remove any conflicting borders */ +.vote-table tbody tr:nth-child(6) { + border-top: none !important; +} + +/* Rank column styling */ +.rank-column { + width: 60px; + min-width: 60px; + text-align: center; + font-weight: bold; + color: #007bff; + background-color: #f8f9fa; + border-right: 2px solid #dee2e6; +} + +/* Top 5 rows - blue left border in rank column (rows 2-6: category totals + top 5 projects) */ +.vote-table tbody tr:nth-child(n + 2):nth-child(-n + 6) .rank-column { + border-left: 4px solid #007bff !important; + background-color: #f0f8ff !important; + color: #007bff !important; /* Blue numbers for top 5 */ +} + +/* Rows 6+ - gray numbers for lower rankings */ +.vote-table tbody tr:nth-child(n + 7) .rank-column { + color: #6c757d !important; /* Gray numbers for 6th place and below */ +} + +/* Category totals row - no left border */ +.category-totals-row .rank-column { + border-left: none !important; + background-color: #f8f9fa !important; +} + +/* Category totals row styling - remove blue borders */ +.category-totals-row { + background-color: #f8f9fa !important; + border-top: 2px solid #dee2e6 !important; + border-bottom: 2px solid #dee2e6 !important; +} + +.category-totals-row .rank-column { + border-left: none !important; + background-color: #f8f9fa !important; + color: #495057 !important; /* Dark gray for totals */ +} + +.category-total { + font-size: 1.2rem; + color: var(--color-blurple) !important; +} + +.grand-total { + background: var(--color-blurple) !important; + color: white !important; + font-size: 1.3rem; +} + +/* Column width adjustments for better desktop experience */ +.vote-table .project-name { + min-width: 250px; /* Project names get more space */ +} + +.vote-table .vote-count { + min-width: 120px; /* Vote counts get comfortable width */ + text-align: center; +} + +.vote-table .total-votes { + min-width: 140px; /* Total column gets more space */ + text-align: center; + background: var(--color-gray400); + font-weight: 600; + color: var(--color-gray100); + font-size: 1.1rem; +} + +/* Totals column - narrower width to fit the label */ +.vote-table th:last-child, +.vote-table td:last-child { + width: 80px; + min-width: 80px; + max-width: 80px; +} + +/* Group column styling */ +.vote-table th:nth-child(3), +.vote-table td:nth-child(3) { + width: 120px; + min-width: 120px; + max-width: 120px; + text-align: center; + padding: 0.75rem 0.5rem; +} + +.group-name { + font-size: 0.9rem; + color: #6c757d; + font-weight: 500; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .vote-table th, + .vote-table td { + padding: 0.5rem 0.75rem; + font-size: 0.8rem; + } + + .vote-table-container h3 { + font-size: 1.25rem; + } +} + +.vote-table-header { + cursor: pointer; + user-select: none; + transition: background-color 0.2s ease; +} + +.vote-table-header:hover { + background-color: #e9ecef; +} + +/* Active category column styling */ +.vote-table-header.active-category { + background-color: #007bff !important; + color: white !important; + position: relative; +} + +.vote-table-header.active-category::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background-color: #0056b3; +} + +/* Blue border around active category column using data attributes */ +.vote-table tbody tr td[data-category-active='true'] { + border-left: 2px solid #007bff !important; + border-right: 2px solid #007bff !important; +} + +/* Add top and bottom borders to header for complete column box effect */ +.vote-table-header.active-category { + border-top: 2px solid #007bff !important; + border-bottom: 2px solid #007bff !important; +} + +/* Center-align category headers to match vote count columns */ +.vote-table-header { + text-align: center !important; +} diff --git a/src/components/VoteTable.js b/src/components/VoteTable.js new file mode 100644 index 0000000..47c4db5 --- /dev/null +++ b/src/components/VoteTable.js @@ -0,0 +1,248 @@ +import React, {useState} from 'react'; +import PropTypes from 'prop-types'; + +const VoteTable = ({data, awardCategories, projects, groups, year}) => { + const [sortConfig, setSortConfig] = useState({ + key: null, + direction: 'desc', + }); + + // Set the first category as default active + const [activeCategory, setActiveCategory] = useState(() => { + const firstCategoryKey = Object.keys(awardCategories)[0]; + return firstCategoryKey || null; + }); + + // Transform data for table display + const tableData = Object.keys(projects).map((projectKey) => { + const project = projects[projectKey]; + const row = {projectKey, project, total: 0}; + + // Add vote counts for each category + Object.keys(awardCategories).forEach((categoryKey) => { + const categoryVotes = data[categoryKey] || {}; + const voteCount = categoryVotes[projectKey] || 0; + row[categoryKey] = voteCount; + row.total += voteCount; + }); + + return row; + }); + + // Sort function + const sortData = (data, config) => { + return [...data].sort((a, b) => { + let aValue = config.column === 'project' ? a.project.name : a[config.column]; + let bValue = config.column === 'project' ? b.project.name : b[config.column]; + + if (config.column === 'project') { + return config.direction === 'asc' + ? aValue.localeCompare(bValue) + : bValue.localeCompare(aValue); + } + + if (config.direction === 'asc') { + return aValue - bValue; + } + return bValue - aValue; + }); + }; + + // Handle column sorting + const handleSort = (key) => { + // If clicking the same column, toggle between showing all vs top 5 + if (activeCategory === key) { + setActiveCategory(null); + setSortConfig({key: null, direction: 'desc'}); + return; + } + + // Set new active category and show top 5 for that category + setActiveCategory(key); + setSortConfig({ + key, + direction: 'desc', + }); + }; + + // Filter data based on active category + const getFilteredData = () => { + // Always show all projects + const allProjects = Object.keys(projects).map((projectKey) => { + const project = projects[projectKey]; + const row = { + projectKey, + projectName: project.name, + groupName: + project.group && groups && groups[project.group] + ? groups[project.group].name + : 'No Group', + }; + + // Calculate total votes for this project + let totalVotes = 0; + Object.keys(awardCategories).forEach((categoryKey) => { + const votes = data[categoryKey]?.[projectKey] || 0; + row[categoryKey] = votes; + totalVotes += votes; + }); + + row.totalVotes = totalVotes; + return row; + }); + + return allProjects; + }; + + const getSortedData = () => { + let sortableData = getFilteredData(); + + if (activeCategory) { + // Sort by the active category or total votes (highest to lowest) + sortableData.sort((a, b) => { + if (activeCategory === 'totalVotes') { + return (b.totalVotes || 0) - (a.totalVotes || 0); + } + return (b[activeCategory] || 0) - (a[activeCategory] || 0); + }); + } + + return sortableData; + }; + + // Get sort indicator + const getSortIndicator = (key) => { + if (activeCategory === key) { + return ' ↓'; // Show down arrow for active category (sorted highest to lowest) + } + return ''; + }; + + const getHeaderClassName = (key) => { + let className = 'vote-table-header'; + if (activeCategory === key) { + className += ' active-category'; + } + return className; + }; + + // Calculate category totals for summary row + const categoryTotals = Object.keys(awardCategories).map((categoryKey) => { + const categoryVotes = data[categoryKey] || {}; + return Object.values(categoryVotes).reduce((sum, votes) => sum + votes, 0); + }); + + // Calculate grand total + const grandTotal = categoryTotals.reduce((sum, total) => sum + total, 0); + + const sortedData = getSortedData(); + + return ( +
+

Vote Summary by Category

+
+ + + + + + + {Object.keys(awardCategories).map((categoryKey) => ( + + ))} + + + + + {/* Category totals row */} + + {/* Empty cell for rank column */} + + {/* Empty cell for group column */} + {Object.keys(awardCategories).map((categoryKey) => { + const categoryVotes = Object.values(data[categoryKey] || {}).reduce( + (sum, votes) => sum + votes, + 0 + ); + return ( + + ); + })} + + + {sortedData.map((row, index) => ( + + + + + {Object.keys(awardCategories).map((categoryKey) => ( + + ))} + + + ))} + +
RankProjectGroup handleSort(categoryKey)} + className={getHeaderClassName(categoryKey)} + > + {awardCategories[categoryKey].name} + handleSort('totalVotes')} + className={getHeaderClassName('totalVotes')} + > + Totals +
+ Category Totals + + {categoryVotes} + + {grandTotal} +
{index + 1} + + {row.projectName} + + {row.groupName} + {row[categoryKey] || 0} + + {row.totalVotes} +
+
+
+ ); +}; + +VoteTable.propTypes = { + data: PropTypes.object.isRequired, + awardCategories: PropTypes.object.isRequired, + projects: PropTypes.object.isRequired, + groups: PropTypes.object, + year: PropTypes.string.isRequired, +}; + +export default VoteTable; diff --git a/src/index.css b/src/index.css index 8653f3b..7226392 100644 --- a/src/index.css +++ b/src/index.css @@ -399,3 +399,19 @@ input { .Project-details-summary { overflow: hidden; } + +/* Responsive adjustments */ +@media (max-width: 768px) { + .vote-table th, + .vote-table td { + padding: 0.5rem 0.75rem; + } +} + +/* Admin page full width - simple and reliable */ +.admin-page { + width: 98vw; + padding: 0px 12px; + margin-left: calc(-49vw + 50%); + box-sizing: border-box; +} diff --git a/src/pages/ManageVotes.js b/src/pages/ManageVotes.js index 9cff867..a7b1bf8 100644 --- a/src/pages/ManageVotes.js +++ b/src/pages/ManageVotes.js @@ -6,7 +6,11 @@ import {compose} from 'redux'; import {firebaseConnect, isLoaded, pathToJS, dataToJS} from 'react-redux-firebase'; import {mapObject, orderedPopulatedDataToJS} from '../helpers'; -import Button from '../components/Button'; + +import VoteTable from '../components/VoteTable'; +import '../components/VoteTable.css'; +import VoteAnalytics from '../components/VoteAnalytics'; +import '../components/VoteAnalytics.css'; class ManageAwardCategories extends Component { static propTypes = { @@ -106,12 +110,20 @@ class ManageAwardCategories extends Component { }); return ( -
-
- -
+
+ + {Object.keys(votesByProjectAndCategory).map((categoryKey) => { let votesByProject = votesByProjectAndCategory[categoryKey]; @@ -131,7 +143,14 @@ class ManageAwardCategories extends Component { let project = projects[projectKey]; return (
  • - {project.name} • {votesByProject[projectKey]} + + {project.name} + {' '} + • {votesByProject[projectKey]}
  • ); })} @@ -160,11 +179,24 @@ export default compose( storeAs: 'projects', }, {path: `/years/${params.year}/votes`, populates: keyPopulates, storeAs: 'voteList'}, + { + path: `/years/${params.year}/groups`, + populates: keyPopulates, + storeAs: 'groups', + }, + { + path: `/users`, + queryParams: ['orderByValue=displayName'], + populates: [], + storeAs: 'userList', + }, ]), connect(({firebase}) => ({ auth: pathToJS(firebase, 'auth'), awardCategories: dataToJS(firebase, 'awardCategories'), projects: dataToJS(firebase, 'projects'), voteList: orderedPopulatedDataToJS(firebase, 'voteList', keyPopulates), + groups: dataToJS(firebase, 'groups'), + userList: dataToJS(firebase, 'userList'), })) )(ManageAwardCategories);