import React, { useState, useEffect, useRef } from 'react'; import { Authentication, Get, Update, Realtime } from '../../../scripts/pocketbase'; import { Collections, OfficerTypes } from '../../../schemas/pocketbase'; import type { User, Officer } from '../../../schemas/pocketbase/schema'; import { Button } from '../universal/Button'; import toast from 'react-hot-toast'; import { Toast } from '../universal/Toast'; // Interface for officer with expanded user data interface OfficerWithUser extends Officer { expand?: { user: User; }; } // Interface for user search results interface UserSearchResult { id: string; name: string; email: string; } // Interface for JSON import data interface ImportedOfficerData { name: string; email: string; role: string; type: string; pid?: string; major?: string; graduation_year?: number; } // Interface for matched user data interface UserMatch { importedData: ImportedOfficerData; matchedUser: UserSearchResult | null; confidence: number; matchReason: string[]; isApproved?: boolean; id: string; // Add stable ID for selection } // Export the component as default export default function OfficerManagement() { // State for officers data const [officers, setOfficers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // State for filtering and searching const [searchTerm, setSearchTerm] = useState(''); const [filterType, setFilterType] = useState(''); // State for new officer form const [userSearchTerm, setUserSearchTerm] = useState(''); const [userSearchResults, setUserSearchResults] = useState([]); const [selectedUsers, setSelectedUsers] = useState([]); const [newOfficerRole, setNewOfficerRole] = useState(''); const [newOfficerType, setNewOfficerType] = useState(OfficerTypes.GENERAL); // State for bulk actions const [selectedOfficers, setSelectedOfficers] = useState([]); const [bulkActionType, setBulkActionType] = useState(''); // State for JSON import const [jsonInput, setJsonInput] = useState(''); const [importedData, setImportedData] = useState([]); const [userMatches, setUserMatches] = useState([]); const [isProcessingImport, setIsProcessingImport] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [processingProgress, setProcessingProgress] = useState(0); const [importError, setImportError] = useState(null); const [showUnmatched, setShowUnmatched] = useState(false); // State for officer replacement confirmation const [officerToReplace, setOfficerToReplace] = useState<{ existingOfficer: OfficerWithUser; newRole: string; newType: string; } | null>(null); // State for keyboard navigation const [currentHighlightedIndex, setCurrentHighlightedIndex] = useState(-1); const searchInputRef = useRef(null); // Get instances of services const getService = Get.getInstance(); const updateService = Update.getInstance(); const realtime = Realtime.getInstance(); const auth = Authentication.getInstance(); // State for current user's role const [isCurrentUserAdmin, setIsCurrentUserAdmin] = useState(false); // Check if current user is an administrator const checkCurrentUserRole = async () => { try { const userId = auth.getUserId(); if (!userId) return; // Get all officers where user is the current user and type is administrator const result = await getService.getFirst( Collections.OFFICERS, `user = "${userId}" && type = "${OfficerTypes.ADMINISTRATOR}"` ); setIsCurrentUserAdmin(!!result); } catch (err) { console.error('Failed to check user role:', err); setIsCurrentUserAdmin(false); } }; // Fetch officers on component mount useEffect(() => { fetchOfficers(); checkCurrentUserRole(); // Subscribe to realtime updates for officers collection const subscriptionId = realtime.subscribeToCollection<{ action: string; record: OfficerWithUser }>( Collections.OFFICERS, (data) => { if (data.action === 'create' || data.action === 'update' || data.action === 'delete') { fetchOfficers(); } } ); // Cleanup subscription on unmount return () => { realtime.unsubscribe(subscriptionId); }; }, []); // Fetch officers data const fetchOfficers = async () => { try { setLoading(true); const result = await getService.getAll( Collections.OFFICERS, '', 'created', { expand: 'user' } ); setOfficers(result); setError(null); } catch (err) { console.error('Failed to fetch officers:', err); setError('Failed to load officers. Please try again.'); } finally { setLoading(false); } }; // Search for users when adding a new officer const searchUsers = async (term: string) => { if (!term || term.length < 2) { setUserSearchResults([]); return; } try { // Search for users by name or email const result = await getService.getAll( Collections.USERS, `name ~ "${term}" || email ~ "${term}"`, 'name' ); // Map to search results format const searchResults: UserSearchResult[] = result.map(user => ({ id: user.id, name: user.name, email: user.email })); setUserSearchResults(searchResults); } catch (err) { console.error('Failed to search users:', err); toast.error('Failed to search users. Please try again.'); } }; // Handle user search input change const handleUserSearchChange = (e: React.ChangeEvent) => { const term = e.target.value; setUserSearchTerm(term); setCurrentHighlightedIndex(-1); // Debounce search to avoid too many requests const debounceTimer = setTimeout(() => { searchUsers(term); }, 300); return () => clearTimeout(debounceTimer); }; // Handle keyboard navigation in the dropdown const handleSearchKeyDown = (e: React.KeyboardEvent) => { // Only handle keys when results are showing if (userSearchResults.length === 0) return; switch (e.key) { case 'ArrowDown': e.preventDefault(); setCurrentHighlightedIndex(prev => prev < userSearchResults.length - 1 ? prev + 1 : prev ); break; case 'ArrowUp': e.preventDefault(); setCurrentHighlightedIndex(prev => prev > 0 ? prev - 1 : 0); break; case 'Enter': e.preventDefault(); if (currentHighlightedIndex >= 0 && currentHighlightedIndex < userSearchResults.length) { handleSelectUser(userSearchResults[currentHighlightedIndex]); } break; case 'Escape': e.preventDefault(); setUserSearchResults([]); break; } }; // Handle selecting a user from search results const handleSelectUser = (user: UserSearchResult) => { // Toggle selection - add if not selected, remove if already selected setSelectedUsers(prev => { // Check if user is already in the array const exists = prev.some(u => u.id === user.id); if (exists) { return prev.filter(u => u.id !== user.id); } return [...prev, user]; }); // Keep the search results open to allow selecting more users }; // Handle removing a user from selected users const handleRemoveSelectedUser = (userId: string) => { setSelectedUsers(prev => prev.filter(user => user.id !== userId)); }; // Handle replacing an existing officer const handleReplaceOfficer = async () => { if (!officerToReplace) return; try { const pb = auth.getPocketBase(); // Update the existing officer record await pb.collection(Collections.OFFICERS).update(officerToReplace.existingOfficer.id, { role: officerToReplace.newRole, type: officerToReplace.newType }); // Show success message toast.success(`Officer role updated successfully for ${officerToReplace.existingOfficer.expand?.user.name}`); // Close the modal const modal = document.getElementById("replaceOfficerModal") as HTMLDialogElement; if (modal) modal.close(); // Reset the state and form completely setOfficerToReplace(null); setSelectedUsers([]); setUserSearchTerm(''); setUserSearchResults([]); setNewOfficerRole(''); setNewOfficerType(OfficerTypes.GENERAL); // Force clear any input fields const roleInput = document.getElementById('role') as HTMLInputElement; if (roleInput) roleInput.value = ''; // Refresh officers list fetchOfficers(); } catch (err) { console.error('Failed to update officer:', err); toast.error('Failed to update officer. Please try again.'); } }; // Handle adding an existing officer to the selection const handleAddExistingOfficer = () => { if (!officerToReplace) return; // Get the user from the existing officer const user = { id: officerToReplace.existingOfficer.expand?.user.id || '', name: officerToReplace.existingOfficer.expand?.user.name || 'Unknown User', email: officerToReplace.existingOfficer.expand?.user.email || '' }; // Add the user to the selected users list setSelectedUsers(prev => { // Check if user is already in the array const exists = prev.some(u => u.id === user.id); if (exists) { return prev; } return [...prev, user]; }); // Close the modal const modal = document.getElementById("replaceOfficerModal") as HTMLDialogElement; if (modal) modal.close(); // Reset the state and form setOfficerToReplace(null); // Clear form completely setSelectedUsers([]); setUserSearchTerm(''); setUserSearchResults([]); setNewOfficerRole(''); setNewOfficerType(OfficerTypes.GENERAL); // Force clear any input fields const roleInput = document.getElementById('role') as HTMLInputElement; if (roleInput) roleInput.value = ''; // Show a toast message toast(`${user.name} added to selection. Submit the form to update their role.`); }; // Handle adding a new officer const handleAddOfficer = async (e: React.FormEvent) => { e.preventDefault(); if (selectedUsers.length === 0) { toast.error('Please select at least one user'); return; } if (!newOfficerRole) { toast.error('Please enter a role'); return; } // Check if trying to add an administrator without being an administrator if (newOfficerType === OfficerTypes.ADMINISTRATOR && !isCurrentUserAdmin) { toast.error('Only administrators can add new administrators'); return; } // Check if any of the selected users are already officers const existingOfficers = selectedUsers.filter(user => officers.some(officer => officer.expand?.user.id === user.id) ); // If there's exactly one existing officer, show the replacement modal if (existingOfficers.length === 1) { const existingOfficer = officers.find(officer => officer.expand?.user.id === existingOfficers[0].id ); if (existingOfficer) { setOfficerToReplace({ existingOfficer, newRole: newOfficerRole, newType: newOfficerType }); // Open the confirmation modal const modal = document.getElementById("replaceOfficerModal") as HTMLDialogElement; if (modal) modal.showModal(); return; } } // If there are multiple existing officers, ask for confirmation if (existingOfficers.length > 1) { if (!window.confirm(`${existingOfficers.length} selected users are already officers. Do you want to update their roles to "${newOfficerRole}" and type to "${newOfficerType}"?`)) { return; } } try { // Get all users to add or update const usersToProcess = selectedUsers; // First verify that all users exist in the database const userVerificationPromises = selectedUsers.map(async user => { try { // Verify the user exists before creating an officer record await getService.getOne(Collections.USERS, user.id); return { user, exists: true }; } catch (error) { console.error(`User with ID ${user.id} does not exist:`, error); return { user, exists: false }; } }); const userVerificationResults = await Promise.all(userVerificationPromises); const validUsers = userVerificationResults.filter(result => result.exists).map(result => result.user); const invalidUsers = userVerificationResults.filter(result => !result.exists).map(result => result.user); if (invalidUsers.length > 0) { toast.error(`Cannot add ${invalidUsers.length} user(s) as officers: User records not found`); if (validUsers.length === 0) { return; // No valid users to add } } // Get direct access to PocketBase instance const pb = auth.getPocketBase(); // Track successful creations, updates, and failures const successfulCreations = []; const successfulUpdates = []; const failedOperations = []; // Process each valid user for (const user of validUsers) { try { // Ensure type is one of the valid enum values const validType = Object.values(OfficerTypes).includes(newOfficerType) ? newOfficerType : OfficerTypes.GENERAL; // Check if user is already an officer const existingOfficer = officers.find(officer => officer.expand?.user.id === user.id); if (existingOfficer) { // Update existing officer const updatedOfficer = await pb.collection(Collections.OFFICERS).update(existingOfficer.id, { role: newOfficerRole, type: validType }); successfulUpdates.push(updatedOfficer); } else { // Create new officer record const createdOfficer = await pb.collection(Collections.OFFICERS).create({ user: user.id, role: newOfficerRole, type: validType }); successfulCreations.push(createdOfficer); } } catch (error) { console.error(`Failed to process officer for user ${user.name}:`, error); failedOperations.push(user); } } // Update the success/error message based on results if (successfulCreations.length === 0 && successfulUpdates.length === 0 && failedOperations.length > 0) { throw new Error(`Failed to add or update any officers. Please check user permissions and try again.`); } // Reset form completely setSelectedUsers([]); setUserSearchTerm(''); setUserSearchResults([]); setNewOfficerRole(''); setNewOfficerType(OfficerTypes.GENERAL); // Force clear any input fields const roleInput = document.getElementById('role') as HTMLInputElement; if (roleInput) roleInput.value = ''; toast.success(`${successfulCreations.length} officer(s) added and ${successfulUpdates.length} updated successfully${failedOperations.length > 0 ? ` (${failedOperations.length} failed)` : ''}`); // Refresh officers list fetchOfficers(); } catch (err: any) { console.error('Failed to add officer:', err); // Provide more specific error messages based on the error let errorMessage = 'Failed to add officer. Please try again.'; if (err.data?.data) { // Handle validation errors from PocketBase const validationErrors = err.data.data; if (validationErrors.user) { errorMessage = `User error: ${validationErrors.user.message}`; } else if (validationErrors.role) { errorMessage = `Role error: ${validationErrors.role.message}`; } else if (validationErrors.type) { errorMessage = `Type error: ${validationErrors.type.message}`; } } else if (err.message) { errorMessage = err.message; } toast.error(errorMessage); } }; // Handle selecting/deselecting an officer for bulk actions const handleSelectOfficer = (officerId: string) => { setSelectedOfficers(prev => { if (prev.includes(officerId)) { return prev.filter(id => id !== officerId); } else { return [...prev, officerId]; } }); }; // Handle bulk action type change const handleBulkActionTypeChange = (e: React.ChangeEvent) => { setBulkActionType(e.target.value); }; // Apply bulk action to selected officers const applyBulkAction = async () => { if (!bulkActionType || selectedOfficers.length === 0) { toast('Please select officers and an action type'); return; } // Check if trying to set officers to administrator without being an administrator if (bulkActionType === OfficerTypes.ADMINISTRATOR && !isCurrentUserAdmin) { toast.error('Only administrators can promote officers to administrator'); return; } // Check if trying to modify administrators without being an administrator if (!isCurrentUserAdmin) { const hasAdmins = selectedOfficers.some(id => { const officer = officers.find(o => o.id === id); return officer?.type === OfficerTypes.ADMINISTRATOR; }); if (hasAdmins) { toast.error('Only administrators can modify administrator officers'); return; } } try { // Update all selected officers to the new type const updates = selectedOfficers.map(id => ({ id, data: { type: bulkActionType } })); await updateService.batchUpdateFields(Collections.OFFICERS, updates); toast.success(`Successfully updated ${selectedOfficers.length} officers`); // Reset selection setSelectedOfficers([]); setBulkActionType(''); // Refresh officers list fetchOfficers(); } catch (err) { console.error('Failed to apply bulk action:', err); toast.error('Failed to update officers. Please try again.'); } }; // Set all general and executive officers to past const archiveCurrentOfficers = async () => { // Only administrators can perform this bulk action if (!isCurrentUserAdmin) { toast.error('Only administrators can archive all officers'); return; } try { // Find all general and executive officers const officersToArchive = officers.filter( officer => officer.type === OfficerTypes.GENERAL || officer.type === OfficerTypes.EXECUTIVE ); if (officersToArchive.length === 0) { toast('No general or executive officers to archive'); return; } // Confirm before proceeding if (!window.confirm(`Are you sure you want to set ${officersToArchive.length} officers to "past"? This action is typically done at the end of the academic year.`)) { return; } // Update all selected officers to past const updates = officersToArchive.map(officer => ({ id: officer.id, data: { type: OfficerTypes.PAST } })); await updateService.batchUpdateFields(Collections.OFFICERS, updates); toast.success(`Successfully archived ${officersToArchive.length} officers`); // Refresh officers list fetchOfficers(); } catch (err) { console.error('Failed to archive officers:', err); toast.error('Failed to archive officers. Please try again.'); } }; // Filter officers based on search term and type filter const filteredOfficers = officers.filter(officer => { const matchesSearch = searchTerm === '' || officer.expand?.user.name.toLowerCase().includes(searchTerm.toLowerCase()) || officer.role.toLowerCase().includes(searchTerm.toLowerCase()); const matchesType = filterType === '' || officer.type === filterType; return matchesSearch && matchesType; }); // Handle removing an officer const handleRemoveOfficer = async (officerId: string) => { // Get the officer to check if they're an administrator const officerToRemove = officers.find(o => o.id === officerId); if (!officerToRemove) { toast.error('Officer not found'); return; } // Check permissions if (officerToRemove.type === OfficerTypes.ADMINISTRATOR && !isCurrentUserAdmin) { toast.error('Only administrators can remove administrator officers'); return; } if (!window.confirm('Are you sure you want to remove this officer?')) { return; } try { const pb = auth.getPocketBase(); await pb.collection(Collections.OFFICERS).delete(officerId); toast.success('Officer removed successfully'); // Refresh officers list fetchOfficers(); } catch (err) { console.error('Failed to remove officer:', err); toast.error('Failed to remove officer. Please try again.'); } }; // Handle editing an officer's type const handleEditOfficerType = async (officerId: string, newType: string) => { // Get the officer to check current type const officerToEdit = officers.find(o => o.id === officerId); if (!officerToEdit) { toast.error('Officer not found'); return; } // Get current user ID const currentUserId = auth.getUserId(); // Check if user is trying to edit their own role and they're not an admin if ( officerToEdit.expand?.user.id === currentUserId && !isCurrentUserAdmin && officerToEdit.type !== newType ) { toast.error('You cannot change your own role. Only administrators can do that.'); return; } // Check permissions for changing to/from administrator if ((officerToEdit.type === OfficerTypes.ADMINISTRATOR || newType === OfficerTypes.ADMINISTRATOR) && !isCurrentUserAdmin) { toast.error('Only administrators can change administrator status'); return; } try { await updateService.updateField(Collections.OFFICERS, officerId, 'type', newType); toast.success('Officer updated successfully'); // Refresh officers list fetchOfficers(); } catch (err) { console.error('Failed to update officer:', err); toast.error('Failed to update officer. Please try again.'); } }; // Calculate confidence score for user match const calculateMatchConfidence = (importedData: ImportedOfficerData, user: User): { confidence: number; reasons: string[] } => { let confidence = 0; const reasons: string[] = []; // Helper function to calculate string similarity (Levenshtein distance based) const calculateStringSimilarity = (str1: string, str2: string): number => { const s1 = str1.toLowerCase(); const s2 = str2.toLowerCase(); // Exact match if (s1 === s2) return 1; // Calculate Levenshtein distance const track = Array(s2.length + 1).fill(null).map(() => Array(s1.length + 1).fill(null)); for (let i = 0; i <= s1.length; i += 1) { track[0][i] = i; } for (let j = 0; j <= s2.length; j += 1) { track[j][0] = j; } for (let j = 1; j <= s2.length; j += 1) { for (let i = 1; i <= s1.length; i += 1) { const indicator = s1[i - 1] === s2[j - 1] ? 0 : 1; track[j][i] = Math.min( track[j][i - 1] + 1, track[j - 1][i] + 1, track[j - 1][i - 1] + indicator ); } } // Convert distance to similarity score (0 to 1) const maxLength = Math.max(s1.length, s2.length); return maxLength === 0 ? 1 : (maxLength - track[s2.length][s1.length]) / maxLength; }; // Helper function to normalize strings for comparison const normalizeString = (str: string): string => { return str.toLowerCase() .replace(/[^a-z0-9\s]/g, '') // Remove special characters .trim(); }; // Email matching (weighted more heavily) const emailSimilarity = calculateStringSimilarity( importedData.email.split('@')[0], // Compare only the part before @ user.email.split('@')[0] ); if (emailSimilarity === 1) { confidence += 50; reasons.push('Email matches exactly'); } else if (emailSimilarity > 0.8) { confidence += 40; reasons.push('Email is very similar'); } else if (emailSimilarity > 0.6) { confidence += 25; reasons.push('Email has some similarity'); } // Name matching with various techniques const importedName = normalizeString(importedData.name); const userName = normalizeString(user.name); // Full name similarity const nameSimilarity = calculateStringSimilarity(importedName, userName); if (nameSimilarity === 1) { confidence += 30; reasons.push('Name matches exactly'); } else if (nameSimilarity > 0.8) { confidence += 25; reasons.push('Name is very similar'); } else if (nameSimilarity > 0.6) { confidence += 15; reasons.push('Name has some similarity'); } // Name parts matching (for handling different order, missing middle names, etc.) const importedParts = importedName.split(' ').filter(Boolean); const userParts = userName.split(' ').filter(Boolean); // Check each part individually const matchingParts = importedParts.filter(part => userParts.some(userPart => calculateStringSimilarity(part, userPart) > 0.8) ); if (matchingParts.length > 0) { const partialScore = (matchingParts.length / Math.max(importedParts.length, userParts.length)) * 20; if (!reasons.some(r => r.includes('Name'))) { // Only add if no other name match was found confidence += partialScore; reasons.push(`${matchingParts.length} name parts match closely`); } } // PID matching with partial match support if (importedData.pid && user.pid) { const pidSimilarity = calculateStringSimilarity( importedData.pid.replace(/[^a-zA-Z0-9]/g, ''), user.pid.replace(/[^a-zA-Z0-9]/g, '') ); if (pidSimilarity === 1) { confidence += 10; reasons.push('PID matches exactly'); } else if (pidSimilarity > 0.8) { confidence += 7; reasons.push('PID is very similar'); } } // Major matching with fuzzy match if (importedData.major && user.major) { const majorSimilarity = calculateStringSimilarity( normalizeString(importedData.major), normalizeString(user.major) ); if (majorSimilarity === 1) { confidence += 5; reasons.push('Major matches exactly'); } else if (majorSimilarity > 0.8) { confidence += 3; reasons.push('Major is very similar'); } } // Graduation year matching with fuzzy logic if (importedData.graduation_year && user.graduation_year) { const yearDiff = Math.abs(importedData.graduation_year - user.graduation_year); if (yearDiff === 0) { confidence += 5; reasons.push('Graduation year matches exactly'); } else if (yearDiff === 1) { confidence += 2; reasons.push('Graduation year is off by 1 year'); } } // Normalize confidence to 100 confidence = Math.min(100, Math.round(confidence * 10) / 10); // Round to 1 decimal place return { confidence, reasons }; }; // Process JSON input and match with existing users const processJsonImport = async (jsonStr: string) => { try { setIsProcessingImport(true); setImportError(null); // Parse JSON input const parsed = JSON.parse(jsonStr); const data: ImportedOfficerData[] = Array.isArray(parsed) ? parsed : [parsed]; // Validate required fields const invalidEntries = data.filter(entry => !entry.name || !entry.email || !entry.role); if (invalidEntries.length > 0) { throw new Error('All entries must have name, email, and role'); } // Set imported data setImportedData(data); // Match each imported record with existing users const matches: UserMatch[] = []; const seenUserIds = new Set(); // Track user IDs for deduplication during processing for (const importedRecord of data) { try { // Search for potential matches const searchResults = await getService.getAll( Collections.USERS, `name ~ "${importedRecord.name}" || email ~ "${importedRecord.email}"`, 'name' ); // Find best match let bestMatch: UserSearchResult | null = null; let bestConfidence = 0; let bestReasons: string[] = []; for (const user of searchResults) { const { confidence, reasons } = calculateMatchConfidence(importedRecord, user); if (confidence > bestConfidence) { bestConfidence = confidence; bestReasons = reasons; bestMatch = { id: user.id, name: user.name, email: user.email }; } } // Add a unique ID for stable selection const matchId = `match-${matches.length}`; matches.push({ id: matchId, importedData: importedRecord, matchedUser: bestMatch, confidence: bestConfidence, matchReason: bestReasons }); } catch (err) { console.error('Error matching user:', err); matches.push({ id: `match-error-${matches.length}`, importedData: importedRecord, matchedUser: null, confidence: 0, matchReason: ['Error matching user'] }); } } // Deduplicate matches (keep the match with highest confidence for each user) const uniqueMatches = matches.reduce>((acc, match) => { if (!match.matchedUser) { // Always keep unmatched entries acc[match.id] = match; return acc; } const userId = match.matchedUser.id; // If we haven't seen this user or this match has higher confidence, keep it if (!seenUserIds.has(userId) || !acc[`user-${userId}`] || match.confidence > acc[`user-${userId}`].confidence) { // Remove previous entry for this user if exists if (acc[`user-${userId}`]) { delete acc[`user-${userId}`]; } // Add this match with a stable ID based on user acc[`user-${userId}`] = { ...match, id: `user-${userId}` }; seenUserIds.add(userId); } return acc; }, {}); setUserMatches(Object.values(uniqueMatches)); } catch (err: any) { console.error('Error processing JSON import:', err); setImportError(err.message || 'Failed to process JSON import'); setImportedData([]); setUserMatches([]); } finally { setIsProcessingImport(false); } }; // Handle JSON input change const handleJsonInputChange = (e: React.ChangeEvent) => { setJsonInput(e.target.value); setImportError(null); }; // Process matched users and create/update officers const processMatchedUsers = async () => { try { // Prevent multiple submissions if (isSubmitting) return; setIsSubmitting(true); setProcessingProgress(0); // Filter out matches with low confidence that haven't been manually approved const validMatches = userMatches.filter(match => match.matchedUser && (match.confidence >= 70 || match.isApproved) ); if (validMatches.length === 0) { toast.error('No valid matches to process'); setIsSubmitting(false); return; } // Get current user ID const currentUserId = auth.getUserId(); // Counters for feedback let updatedCount = 0; let createdCount = 0; let failedCount = 0; let skippedCount = 0; let processedCount = 0; // Get direct access to PocketBase instance const pb = auth.getPocketBase(); // Process each match for (const match of validMatches) { if (!match.matchedUser) { processedCount++; setProcessingProgress(Math.round((processedCount / validMatches.length) * 100)); continue; } // Skip if trying to edit own role if (match.matchedUser.id === currentUserId) { console.log('Skipping own record update for security reasons'); skippedCount++; processedCount++; setProcessingProgress(Math.round((processedCount / validMatches.length) * 100)); continue; } try { // Check if user is already an officer const existingOfficer = officers.find(o => o.expand?.user.id === match.matchedUser?.id ); if (existingOfficer) { // Update existing officer record with single call await pb.collection(Collections.OFFICERS).update(existingOfficer.id, { role: match.importedData.role, type: match.importedData.type || OfficerTypes.GENERAL }); updatedCount++; } else { // Create new officer record await pb.collection(Collections.OFFICERS).create({ user: match.matchedUser.id, role: match.importedData.role, type: match.importedData.type || OfficerTypes.GENERAL }); createdCount++; } } catch (err) { console.error('Error processing match:', err); toast.error(`Failed to process ${match.importedData.name}`); failedCount++; } finally { // Update progress regardless of success/failure processedCount++; setProcessingProgress(Math.round((processedCount / validMatches.length) * 100)); } } // Reset import state setJsonInput(''); setImportedData([]); setUserMatches([]); // Show detailed success message if (updatedCount > 0 || createdCount > 0) { let successMessage = ''; if (updatedCount > 0) { successMessage += `Updated ${updatedCount} existing officer record${updatedCount !== 1 ? 's' : ''}`; } if (createdCount > 0) { successMessage += successMessage ? ' and ' : ''; successMessage += `created ${createdCount} new officer record${createdCount !== 1 ? 's' : ''}`; } // Add failure and skipped information if present let additionalInfo = []; if (failedCount > 0) { additionalInfo.push(`${failedCount} failed`); } if (skippedCount > 0) { additionalInfo.push(`${skippedCount} skipped (cannot edit own role)`); } if (additionalInfo.length > 0) { successMessage += ` (${additionalInfo.join(', ')})`; } toast.success(successMessage); } else if (failedCount > 0 || skippedCount > 0) { let errorMessage = 'No records processed:'; if (failedCount > 0) { errorMessage += ` ${failedCount} failed`; } if (skippedCount > 0) { errorMessage += errorMessage !== 'No records processed:' ? ' and' : ''; errorMessage += ` ${skippedCount} skipped (cannot edit own role)`; } toast.error(errorMessage); } // Refresh officers list fetchOfficers(); } catch (err) { console.error('Error processing matches:', err); toast.error('Failed to process some or all matches'); } finally { // Reset submission state setIsSubmitting(false); setProcessingProgress(0); } }; // Display warning on card when match is the current user const isCurrentUser = (matchedUserId: string | undefined): boolean => { if (!matchedUserId) return false; const currentUserId = auth.getUserId(); return matchedUserId === currentUserId; }; // Handle match approval toggle const handleToggleApproval = (matchId: string) => { setUserMatches(prev => { return prev.map(match => { if (match.id === matchId) { return { ...match, isApproved: !match.isApproved }; } return match; }); }); }; return (
{/* Toast notifications are handled by react-hot-toast */} {/* Admin status indicator */} {isCurrentUserAdmin && (
You have administrator privileges and can manage all officers
)}
{/* Add New Officer Section */}

Add New Officer

searchUsers(userSearchTerm)} />
{selectedUsers.length > 0 ? (
{selectedUsers.length}
) : null}
{/* User search results dropdown */} {userSearchTerm.length > 0 && (
{userSearchResults.length > 0 ? `${userSearchResults.length} result${userSearchResults.length !== 1 ? 's' : ''}` : 'No results found'}
{userSearchResults.length === 0 ? (

No users found matching "{userSearchTerm}"

Try a different search term

) : (
    {userSearchResults.map((user, index) => { const isSelected = selectedUsers.some(u => u.id === user.id); const isAlreadyOfficer = officers.some(officer => officer.expand?.user.id === user.id); const isHighlighted = index === currentHighlightedIndex; return (
  • handleSelectUser(user)} className={`px-4 py-2 hover:bg-base-100 cursor-pointer ${isSelected ? 'bg-primary/10' : ''} ${isHighlighted ? 'bg-base-content/10' : ''}`} >
    {user.name} {isAlreadyOfficer && ( Already an officer )}
    {user.email}
    {!isAlreadyOfficer && ( isSelected ? (
    ) : (
    ) )}
  • ); })}
)}
)}
{/* Selected users display */} {selectedUsers.length > 0 && (
{selectedUsers.length > 0 && ( )}
{selectedUsers.length === 0 ? (
No users selected
) : ( selectedUsers.map(user => (
{user.name}
)) )}
)}
setNewOfficerRole(e.target.value)} placeholder="e.g. President, Technical Vice Chair" className="w-full p-3 bg-base-300 border border-base-content/20 rounded-lg text-base-content placeholder-base-content/50 focus:ring-2 focus:ring-primary focus:border-transparent transition-all duration-200" />
{/* JSON Import Section */}

Import Officers from JSON