From 4b97a523d2f31ad3f92d0019dc2d805d05093db0 Mon Sep 17 00:00:00 2001 From: Ismael Dosil Date: Tue, 27 Jan 2026 20:16:20 -0300 Subject: [PATCH 1/3] feat(all-users): add Last Action column showing most recent activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add getUsersLastAction() method with optimized batch queries - Query 5 collections in parallel (observations, knowledgeChecks, conferencePlans, actionPlans, emails) - Build Map in memory for O(1) lookups - Add lastAction to User interface and getAllUsers() result - Add sortable "Last Action" column to AllUsersTable - Include Last Action in CSV export Performance: 5 queries total instead of 2,995 (599 users × 5 collections) Closes CHALK-090 Closes CHALK-091 Closes CHALK-092 --- src/components/Firebase/Firebase.tsx | 97 ++++++++++++++++++- .../UsersComponents/AllUsersTable.tsx | 18 ++-- src/constants/Types.tsx | 1 + 3 files changed, 105 insertions(+), 11 deletions(-) diff --git a/src/components/Firebase/Firebase.tsx b/src/components/Firebase/Firebase.tsx index 471c58c09..e2cceacad 100644 --- a/src/components/Firebase/Firebase.tsx +++ b/src/components/Firebase/Firebase.tsx @@ -4737,6 +4737,89 @@ class Firebase { * Gets all users from Firestore with relevant information for admin dashboard * @returns {Array} Array of user objects with id, name, email, role, status, and lastLogin */ + /** + * Get last action date for all users by querying activity collections. + * Uses optimized approach: 5 total queries instead of per-user queries. + * @returns Map - Last action date per user + */ + getUsersLastAction = async (): Promise> => { + const lastActionMap = new Map() + + const updateIfNewer = (userId: string, date: Date | null) => { + if (!date || !userId) return + const current = lastActionMap.get(userId) + if (!current || date > current) { + lastActionMap.set(userId, date) + } + } + + // Helper to extract userId from /user/ID format (used in observations) + const extractUserId = (ref: string): string => { + if (!ref) return '' + return ref.startsWith('/user/') ? ref.replace('/user/', '') : ref + } + + // Query all 5 collections in parallel for better performance + const [observations, knowledgeChecks, conferencePlans, actionPlans, emails] = await Promise.all([ + this.db.collection('observations').get(), + this.db.collection('knowledgeChecks').get(), + this.db.collection('conferencePlans').get(), + this.db.collection('actionPlans').get(), + this.db.collection('emails').get() + ]) + + // 1. Observations (largest collection - 20K+) + observations.docs.forEach(doc => { + const data = doc.data() + const userId = extractUserId(data.teacher) + const endDate = data.end?.toDate?.() || null + updateIfNewer(userId, endDate) + }) + + // 2. Knowledge Checks (6K+) + knowledgeChecks.docs.forEach(doc => { + const data = doc.data() + const userId = data.answeredBy + const timestamp = data.timestamp?.toDate?.() || null + updateIfNewer(userId, timestamp) + }) + + // 3. Conference Plans + conferencePlans.docs.forEach(doc => { + const data = doc.data() + const userId = data.teacher + const created = data.dateCreated?.toDate?.() || null + const modified = data.dateModified?.toDate?.() || null + updateIfNewer(userId, created) + updateIfNewer(userId, modified) + }) + + // 4. Action Plans + actionPlans.docs.forEach(doc => { + const data = doc.data() + const userId = data.teacher + const created = data.dateCreated?.toDate?.() || null + const modified = data.dateModified?.toDate?.() || null + updateIfNewer(userId, created) + updateIfNewer(userId, modified) + }) + + // 5. Emails (check both sender and recipient) + emails.docs.forEach(doc => { + const data = doc.data() + const senderId = data.user + const recipientId = data.recipientId + const created = data.dateCreated?.toDate?.() || null + const modified = data.dateModified?.toDate?.() || null + updateIfNewer(senderId, created) + updateIfNewer(senderId, modified) + updateIfNewer(recipientId, created) + updateIfNewer(recipientId, modified) + }) + + return lastActionMap + } + getAllUsers = async () => { const result: Array<{ id: string @@ -4747,17 +4830,22 @@ class Firebase { program: string archived: boolean lastLogin: Date | null + lastAction: Date | null }> = [] - // Fetch all programs to build a lookup map - const programsSnapshot = await this.db.collection('programs').get() + // Fetch programs, users, and last action data in parallel + const [programsSnapshot, usersSnapshot, lastActionMap] = await Promise.all([ + this.db.collection('programs').get(), + this.db.collection('users').get(), + this.getUsersLastAction() + ]) + + // Build programs lookup map const programsMap = new Map() programsSnapshot.docs.forEach(doc => { programsMap.set(doc.id, doc.data().name || '') }) - const usersSnapshot = await this.db.collection('users').get() - usersSnapshot.docs.forEach(doc => { const data = doc.data() // Get first program name from user's programs array @@ -4800,6 +4888,7 @@ class Firebase { program: programName, archived: data.archived || false, lastLogin: data.lastLogin ? data.lastLogin.toDate() : null, + lastAction: lastActionMap.get(doc.id) || null, }) }) diff --git a/src/components/UsersComponents/AllUsersTable.tsx b/src/components/UsersComponents/AllUsersTable.tsx index c85d59c63..8785e8414 100644 --- a/src/components/UsersComponents/AllUsersTable.tsx +++ b/src/components/UsersComponents/AllUsersTable.tsx @@ -89,11 +89,12 @@ class AllUsersTable extends React.Component { if (statusFilter) users = users.filter(u => u.archived === (statusFilter === 'archived')) users.sort((a, b) => { - const aVal = sortField === 'lastLogin' - ? (a.lastLogin?.getTime() || 0) + const isDateField = sortField === 'lastLogin' || sortField === 'lastAction' + const aVal = isDateField + ? (a[sortField]?.getTime() || 0) : String(a[sortField] || '').toLowerCase() - const bVal = sortField === 'lastLogin' - ? (b.lastLogin?.getTime() || 0) + const bVal = isDateField + ? (b[sortField]?.getTime() || 0) : String(b[sortField] || '').toLowerCase() return sortDir === 'asc' ? (aVal > bVal ? 1 : -1) : (aVal < bVal ? 1 : -1) }) @@ -110,13 +111,14 @@ class AllUsersTable extends React.Component { handleExport = () => { const users = this.getFilteredUsers() - const headers = ['Last Name', 'First Name', 'Email', 'Role', 'Program', 'Status', 'Last Login'] + const headers = ['Last Name', 'First Name', 'Email', 'Role', 'Program', 'Status', 'Last Login', 'Last Action'] const escape = (val: string) => `"${(val || '').replace(/"/g, '""')}"` const rows = users.map(u => [ escape(u.lastName), escape(u.firstName), escape(u.email), escape(this.formatRole(u.role)), escape(u.program || ''), escape(u.archived ? 'Archived' : 'Active'), - escape(this.formatDate(u.lastLogin)) + escape(this.formatDate(u.lastLogin)), + escape(this.formatDate(u.lastAction)) ].join(',')) const csv = [headers.join(','), ...rows].join('\n') const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' }) @@ -179,12 +181,13 @@ class AllUsersTable extends React.Component { + Edit {paginated.length === 0 ? ( - No users found + No users found ) : paginated.map(user => ( {user.lastName} @@ -204,6 +207,7 @@ class AllUsersTable extends React.Component { {this.formatDate(user.lastLogin)} + {this.formatDate(user.lastAction)} e.stopPropagation()} style={{ textAlign: 'center' }}> this.props.onUserClick?.(user)}> diff --git a/src/constants/Types.tsx b/src/constants/Types.tsx index 64e162e8f..4ef5b9db9 100644 --- a/src/constants/Types.tsx +++ b/src/constants/Types.tsx @@ -233,6 +233,7 @@ export interface User { id: string }>, lastLogin?: Date, + lastAction?: Date, email?: string, school?: string, program?: string, From 24a231c3a9abc33b4042af6c1df173f4f114065f Mon Sep 17 00:00:00 2001 From: Ismael Dosil Date: Tue, 27 Jan 2026 20:36:04 -0300 Subject: [PATCH 2/3] feat(all-users): show action type in Last Action column - Display action type (Observation, Training, Conference Plan, etc.) alongside date - Add lastActionType field to User interface - Update CSV export with Action Type column - Fix potential undefined email issue in Edit dialog Closes CHALK-090 --- src/components/Firebase/Firebase.tsx | 39 ++++++++++--------- .../UsersComponents/AllUsersTable.tsx | 13 +++++-- src/constants/Types.tsx | 1 + .../protected/AdminViews/AllUsersPage.tsx | 2 +- 4 files changed, 33 insertions(+), 22 deletions(-) diff --git a/src/components/Firebase/Firebase.tsx b/src/components/Firebase/Firebase.tsx index e2cceacad..8850767d3 100644 --- a/src/components/Firebase/Firebase.tsx +++ b/src/components/Firebase/Firebase.tsx @@ -4738,18 +4738,18 @@ class Firebase { * @returns {Array} Array of user objects with id, name, email, role, status, and lastLogin */ /** - * Get last action date for all users by querying activity collections. + * Get last action date and type for all users by querying activity collections. * Uses optimized approach: 5 total queries instead of per-user queries. - * @returns Map - Last action date per user + * @returns Map - Last action info per user */ - getUsersLastAction = async (): Promise> => { - const lastActionMap = new Map() + getUsersLastAction = async (): Promise> => { + const lastActionMap = new Map() - const updateIfNewer = (userId: string, date: Date | null) => { + const updateIfNewer = (userId: string, date: Date | null, type: string) => { if (!date || !userId) return const current = lastActionMap.get(userId) - if (!current || date > current) { - lastActionMap.set(userId, date) + if (!current || date > current.date) { + lastActionMap.set(userId, { date, type }) } } @@ -4773,7 +4773,7 @@ class Firebase { const data = doc.data() const userId = extractUserId(data.teacher) const endDate = data.end?.toDate?.() || null - updateIfNewer(userId, endDate) + updateIfNewer(userId, endDate, 'Observation') }) // 2. Knowledge Checks (6K+) @@ -4781,7 +4781,7 @@ class Firebase { const data = doc.data() const userId = data.answeredBy const timestamp = data.timestamp?.toDate?.() || null - updateIfNewer(userId, timestamp) + updateIfNewer(userId, timestamp, 'Training') }) // 3. Conference Plans @@ -4790,8 +4790,8 @@ class Firebase { const userId = data.teacher const created = data.dateCreated?.toDate?.() || null const modified = data.dateModified?.toDate?.() || null - updateIfNewer(userId, created) - updateIfNewer(userId, modified) + updateIfNewer(userId, created, 'Conference Plan') + updateIfNewer(userId, modified, 'Conference Plan') }) // 4. Action Plans @@ -4800,8 +4800,8 @@ class Firebase { const userId = data.teacher const created = data.dateCreated?.toDate?.() || null const modified = data.dateModified?.toDate?.() || null - updateIfNewer(userId, created) - updateIfNewer(userId, modified) + updateIfNewer(userId, created, 'Action Plan') + updateIfNewer(userId, modified, 'Action Plan') }) // 5. Emails (check both sender and recipient) @@ -4811,10 +4811,10 @@ class Firebase { const recipientId = data.recipientId const created = data.dateCreated?.toDate?.() || null const modified = data.dateModified?.toDate?.() || null - updateIfNewer(senderId, created) - updateIfNewer(senderId, modified) - updateIfNewer(recipientId, created) - updateIfNewer(recipientId, modified) + updateIfNewer(senderId, created, 'Email') + updateIfNewer(senderId, modified, 'Email') + updateIfNewer(recipientId, created, 'Email') + updateIfNewer(recipientId, modified, 'Email') }) return lastActionMap @@ -4831,6 +4831,7 @@ class Firebase { archived: boolean lastLogin: Date | null lastAction: Date | null + lastActionType: string }> = [] // Fetch programs, users, and last action data in parallel @@ -4879,6 +4880,7 @@ class Firebase { } } } + const lastActionData = lastActionMap.get(doc.id) result.push({ id: doc.id, firstName: data.firstName || '', @@ -4888,7 +4890,8 @@ class Firebase { program: programName, archived: data.archived || false, lastLogin: data.lastLogin ? data.lastLogin.toDate() : null, - lastAction: lastActionMap.get(doc.id) || null, + lastAction: lastActionData?.date || null, + lastActionType: lastActionData?.type || '', }) }) diff --git a/src/components/UsersComponents/AllUsersTable.tsx b/src/components/UsersComponents/AllUsersTable.tsx index 8785e8414..641ff2336 100644 --- a/src/components/UsersComponents/AllUsersTable.tsx +++ b/src/components/UsersComponents/AllUsersTable.tsx @@ -109,16 +109,23 @@ class AllUsersTable extends React.Component { formatDate = (d: Date | null) => d ? d.toLocaleString([], { dateStyle: 'short', timeStyle: 'short' }) : 'Never' + formatLastAction = (user: Types.User) => { + if (!user.lastAction) return 'Never' + const date = user.lastAction.toLocaleString([], { dateStyle: 'short', timeStyle: 'short' }) + return user.lastActionType ? `${date} (${user.lastActionType})` : date + } + handleExport = () => { const users = this.getFilteredUsers() - const headers = ['Last Name', 'First Name', 'Email', 'Role', 'Program', 'Status', 'Last Login', 'Last Action'] + const headers = ['Last Name', 'First Name', 'Email', 'Role', 'Program', 'Status', 'Last Login', 'Last Action', 'Action Type'] const escape = (val: string) => `"${(val || '').replace(/"/g, '""')}"` const rows = users.map(u => [ escape(u.lastName), escape(u.firstName), escape(u.email), escape(this.formatRole(u.role)), escape(u.program || ''), escape(u.archived ? 'Archived' : 'Active'), escape(this.formatDate(u.lastLogin)), - escape(this.formatDate(u.lastAction)) + escape(this.formatDate(u.lastAction)), + escape(u.lastActionType || '') ].join(',')) const csv = [headers.join(','), ...rows].join('\n') const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' }) @@ -207,7 +214,7 @@ class AllUsersTable extends React.Component { {this.formatDate(user.lastLogin)} - {this.formatDate(user.lastAction)} + {this.formatLastAction(user)} e.stopPropagation()} style={{ textAlign: 'center' }}> this.props.onUserClick?.(user)}> diff --git a/src/constants/Types.tsx b/src/constants/Types.tsx index 4ef5b9db9..6afa57414 100644 --- a/src/constants/Types.tsx +++ b/src/constants/Types.tsx @@ -234,6 +234,7 @@ export interface User { }>, lastLogin?: Date, lastAction?: Date, + lastActionType?: string, email?: string, school?: string, program?: string, diff --git a/src/views/protected/AdminViews/AllUsersPage.tsx b/src/views/protected/AdminViews/AllUsersPage.tsx index 54615d1d6..6eff6ef9b 100644 --- a/src/views/protected/AdminViews/AllUsersPage.tsx +++ b/src/views/protected/AdminViews/AllUsersPage.tsx @@ -38,7 +38,7 @@ class AllUsersPage extends React.Component { handleUserClick = (user: Types.User) => { this.setState({ selected: user, firstName: user.firstName, lastName: user.lastName, - email: user.email, editOpen: true, + email: user.email || '', editOpen: true, }) } From 6e1d57ce6b116e487eaaa5230a20867b839d7f2b Mon Sep 17 00:00:00 2001 From: Ismael Dosil Date: Tue, 27 Jan 2026 20:51:29 -0300 Subject: [PATCH 3/3] fix(all-users): implement edit dialog and fix action format - Add edit dialog to UsersPage for All Users tab - Change Last Action format to "Action - Date" (action first) - Add archive confirmation dialog - Fix handleAllUserClick which was only logging Closes CHALK-090 --- .../UsersComponents/AllUsersTable.tsx | 2 +- src/views/protected/UsersViews/UsersPage.tsx | 128 +++++++++++++++++- 2 files changed, 123 insertions(+), 7 deletions(-) diff --git a/src/components/UsersComponents/AllUsersTable.tsx b/src/components/UsersComponents/AllUsersTable.tsx index 641ff2336..7a4b05c48 100644 --- a/src/components/UsersComponents/AllUsersTable.tsx +++ b/src/components/UsersComponents/AllUsersTable.tsx @@ -112,7 +112,7 @@ class AllUsersTable extends React.Component { formatLastAction = (user: Types.User) => { if (!user.lastAction) return 'Never' const date = user.lastAction.toLocaleString([], { dateStyle: 'short', timeStyle: 'short' }) - return user.lastActionType ? `${date} (${user.lastActionType})` : date + return user.lastActionType ? `${user.lastActionType} - ${date}` : date } handleExport = () => { diff --git a/src/views/protected/UsersViews/UsersPage.tsx b/src/views/protected/UsersViews/UsersPage.tsx index df0dfd56f..0b8589b7a 100644 --- a/src/views/protected/UsersViews/UsersPage.tsx +++ b/src/views/protected/UsersViews/UsersPage.tsx @@ -3,6 +3,7 @@ import * as PropTypes from "prop-types"; import { withStyles } from "@material-ui/core/styles"; import AppBar from "../../../components/AppBar"; import Grid from "@material-ui/core/Grid"; +import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, Typography } from "@material-ui/core"; import FirebaseContext from "../../../components/Firebase/FirebaseContext"; import { coachLoaded, Role } from '../../../state/actions/coach' import { connect } from 'react-redux'; @@ -112,6 +113,12 @@ interface State { sendToSites: Array allUsers: Types.User[] allUsersLoading: boolean + editDialogOpen: boolean + archiveDialogOpen: boolean + selectedUser: Types.User | null + editFirstName: string + editLastName: string + editEmail: string } function checkCurrent(item: string) { @@ -142,7 +149,13 @@ class UsersPage extends React.Component { propFilter: [], sendToSites: [], allUsers: [], - allUsersLoading: true + allUsersLoading: true, + editDialogOpen: false, + archiveDialogOpen: false, + selectedUser: null, + editFirstName: '', + editLastName: '', + editEmail: '' } } @@ -569,17 +582,53 @@ class UsersPage extends React.Component { } handleAllUserClick = (user: Types.User) => { - // Could open edit dialog - for now just log - console.log('User clicked:', user) + this.setState({ + selectedUser: user, + editFirstName: user.firstName, + editLastName: user.lastName, + editEmail: user.email || '', + editDialogOpen: true + }) + } + + handleEditSave = async () => { + const { selectedUser, editFirstName, editLastName, editEmail } = this.state + if (!selectedUser || !editFirstName.trim() || !editLastName.trim()) { + alert('Name is required') + return + } + + await this.context.editUserName(selectedUser.id, editFirstName, editLastName, editEmail, selectedUser.role) + this.setState(s => ({ + allUsers: s.allUsers.map(u => u.id === selectedUser.id + ? { ...u, firstName: editFirstName, lastName: editLastName, email: editEmail } + : u + ), + editDialogOpen: false, + selectedUser: null + })) } - handleAllUserArchive = async (user: Types.User) => { - await this.context.db.collection('users').doc(user.id).update({ archived: !user.archived }) + handleArchiveFromEdit = () => { + this.setState({ editDialogOpen: false, archiveDialogOpen: true }) + } + + handleArchiveConfirm = async () => { + const { selectedUser } = this.state + if (!selectedUser) return + + await this.context.db.collection('users').doc(selectedUser.id).update({ archived: !selectedUser.archived }) this.setState(s => ({ - allUsers: s.allUsers.map(u => u.id === user.id ? { ...u, archived: !user.archived } : u) + allUsers: s.allUsers.map(u => u.id === selectedUser.id ? { ...u, archived: !selectedUser.archived } : u), + archiveDialogOpen: false, + selectedUser: null })) } + handleAllUserArchive = (user: Types.User) => { + this.setState({ selectedUser: user, archiveDialogOpen: true }) + } + static propTypes = { classes: PropTypes.exact({ root: PropTypes.string, @@ -735,6 +784,73 @@ class UsersPage extends React.Component { + + {/* Edit User Dialog */} + this.setState({ editDialogOpen: false })} maxWidth="sm" fullWidth> + Edit User + + + + this.setState({ editFirstName: e.target.value })} + /> + + + this.setState({ editLastName: e.target.value })} + /> + + + this.setState({ editEmail: e.target.value })} + /> + + + + + +
+ + + +
+ + {/* Archive Confirmation Dialog */} + this.setState({ archiveDialogOpen: false })}> + {this.state.selectedUser?.archived ? 'Unarchive' : 'Archive'} User + + + Are you sure you want to {this.state.selectedUser?.archived ? 'unarchive' : 'archive'} {this.state.selectedUser?.firstName} {this.state.selectedUser?.lastName}? + + + + + + + ); }