From c8b12d7dff36af833ff3b07a5cf830c8eb4108b6 Mon Sep 17 00:00:00 2001 From: chark1es Date: Tue, 8 Apr 2025 23:53:04 -0700 Subject: [PATCH] Update OfficerManagement.tsx --- .../OfficerManagement/OfficerManagement.tsx | 704 ++++++++++++++++++ 1 file changed, 704 insertions(+) diff --git a/src/components/dashboard/OfficerManagement/OfficerManagement.tsx b/src/components/dashboard/OfficerManagement/OfficerManagement.tsx index 88f7843..ea75b49 100644 --- a/src/components/dashboard/OfficerManagement/OfficerManagement.tsx +++ b/src/components/dashboard/OfficerManagement/OfficerManagement.tsx @@ -20,6 +20,27 @@ interface UserSearchResult { 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 @@ -42,6 +63,16 @@ export default function OfficerManagement() { 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; @@ -660,6 +691,431 @@ export default function OfficerManagement() { } }; + // 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 */} @@ -923,6 +1379,254 @@ export default function OfficerManagement() {
+ {/* JSON Import Section */} +
+

+ + + + Import Officers from JSON +

+ +
+
+ +
+