diff --git a/src/components/Firebase/Firebase.tsx b/src/components/Firebase/Firebase.tsx index 471c58c09..8850767d3 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 and type for all users by querying activity collections. + * Uses optimized approach: 5 total queries instead of per-user queries. + * @returns Map - Last action info per user + */ + getUsersLastAction = async (): Promise> => { + const lastActionMap = new Map() + + const updateIfNewer = (userId: string, date: Date | null, type: string) => { + if (!date || !userId) return + const current = lastActionMap.get(userId) + if (!current || date > current.date) { + lastActionMap.set(userId, { date, type }) + } + } + + // 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, 'Observation') + }) + + // 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, 'Training') + }) + + // 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, 'Conference Plan') + updateIfNewer(userId, modified, 'Conference Plan') + }) + + // 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, 'Action Plan') + updateIfNewer(userId, modified, 'Action Plan') + }) + + // 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, 'Email') + updateIfNewer(senderId, modified, 'Email') + updateIfNewer(recipientId, created, 'Email') + updateIfNewer(recipientId, modified, 'Email') + }) + + return lastActionMap + } + getAllUsers = async () => { const result: Array<{ id: string @@ -4747,17 +4830,23 @@ class Firebase { program: string archived: boolean lastLogin: Date | null + lastAction: Date | null + lastActionType: string }> = [] - // 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 @@ -4791,6 +4880,7 @@ class Firebase { } } } + const lastActionData = lastActionMap.get(doc.id) result.push({ id: doc.id, firstName: data.firstName || '', @@ -4800,6 +4890,8 @@ class Firebase { program: programName, archived: data.archived || false, lastLogin: data.lastLogin ? data.lastLogin.toDate() : null, + lastAction: lastActionData?.date || null, + lastActionType: lastActionData?.type || '', }) }) diff --git a/src/components/UsersComponents/AllUsersTable.tsx b/src/components/UsersComponents/AllUsersTable.tsx index c85d59c63..7a4b05c48 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) }) @@ -108,15 +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 ? `${user.lastActionType} - ${date}` : date + } + 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', '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.lastLogin)), + 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;' }) @@ -179,12 +188,13 @@ class AllUsersTable extends React.Component { + Edit {paginated.length === 0 ? ( - No users found + No users found ) : paginated.map(user => ( {user.lastName} @@ -204,6 +214,7 @@ class AllUsersTable extends React.Component { {this.formatDate(user.lastLogin)} + {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 64e162e8f..6afa57414 100644 --- a/src/constants/Types.tsx +++ b/src/constants/Types.tsx @@ -233,6 +233,8 @@ export interface User { id: string }>, 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, }) } 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}? + + + + + + + ); }