Update OfficerManagement.tsx
This commit is contained in:
parent
137a68c867
commit
c8b12d7dff
1 changed files with 704 additions and 0 deletions
|
@ -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<string[]>([]);
|
||||
const [bulkActionType, setBulkActionType] = useState<string>('');
|
||||
|
||||
// State for JSON import
|
||||
const [jsonInput, setJsonInput] = useState<string>('');
|
||||
const [importedData, setImportedData] = useState<ImportedOfficerData[]>([]);
|
||||
const [userMatches, setUserMatches] = useState<UserMatch[]>([]);
|
||||
const [isProcessingImport, setIsProcessingImport] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [processingProgress, setProcessingProgress] = useState(0);
|
||||
const [importError, setImportError] = useState<string | null>(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<string>(); // Track user IDs for deduplication during processing
|
||||
|
||||
for (const importedRecord of data) {
|
||||
try {
|
||||
// Search for potential matches
|
||||
const searchResults = await getService.getAll<User>(
|
||||
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<Record<string, UserMatch>>((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<HTMLTextAreaElement>) => {
|
||||
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 (
|
||||
<div className="container mx-auto text-base-content">
|
||||
{/* Toast notifications are handled by react-hot-toast */}
|
||||
|
@ -923,6 +1379,254 @@ export default function OfficerManagement() {
|
|||
</form>
|
||||
</div>
|
||||
|
||||
{/* JSON Import Section */}
|
||||
<div className="bg-base-200 p-6 rounded-xl shadow-md border border-base-content/5 transition-all duration-300 hover:shadow-lg mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4 text-base-content flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 mr-2 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
Import Officers from JSON
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium text-base-content">
|
||||
Paste JSON Data
|
||||
<button
|
||||
onClick={() => {
|
||||
const exampleJson = JSON.stringify([
|
||||
{
|
||||
name: "John Doe",
|
||||
email: "john@example.com",
|
||||
role: "President",
|
||||
type: "executive",
|
||||
pid: "A12345678",
|
||||
major: "Computer Science",
|
||||
graduation_year: 2024
|
||||
},
|
||||
{
|
||||
name: "Jane Smith",
|
||||
email: "jane@example.com",
|
||||
role: "Technical Vice Chair",
|
||||
type: "executive",
|
||||
major: "Electrical Engineering",
|
||||
graduation_year: 2025
|
||||
}
|
||||
], null, 2);
|
||||
navigator.clipboard.writeText(exampleJson);
|
||||
toast.success('Example JSON copied to clipboard!');
|
||||
}}
|
||||
className="ml-2 text-xs text-primary hover:text-primary/80 inline-flex items-center"
|
||||
type="button"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Copy Example
|
||||
</button>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={jsonInput}
|
||||
onChange={handleJsonInputChange}
|
||||
placeholder={`[
|
||||
{
|
||||
"name": "John Doe",
|
||||
"email": "john@example.com",
|
||||
"role": "President",
|
||||
"type": "executive"
|
||||
}
|
||||
]`}
|
||||
className="w-full h-48 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 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-base-content/70 space-y-2">
|
||||
<p><strong>Available Types:</strong> "executive", "general", "honorary", "past"</p>
|
||||
<p><strong>Note:</strong> Any role containing "VC" or "Vice Chair" should be considered executive type.</p>
|
||||
<p><strong>Do not include:</strong> Any numbers in the role field (e.g. "Webmaster #1" should just be "Webmaster")</p>
|
||||
</div>
|
||||
{importError && (
|
||||
<div className="mt-2 text-error text-sm">
|
||||
{importError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => processJsonImport(jsonInput)}
|
||||
disabled={!jsonInput || isProcessingImport}
|
||||
className="w-full"
|
||||
>
|
||||
{isProcessingImport ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<span className="loading loading-spinner loading-sm mr-2"></span>
|
||||
Processing...
|
||||
</div>
|
||||
) : (
|
||||
'Process JSON'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* User Matches Display */}
|
||||
{userMatches.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 mb-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h3 className="text-lg font-medium whitespace-nowrap">
|
||||
Matches
|
||||
<span className="ml-1">
|
||||
({userMatches.filter(m => m.confidence > 0 || showUnmatched).length})
|
||||
</span>
|
||||
{!showUnmatched && userMatches.some(m => m.confidence === 0) && (
|
||||
<span className="ml-1 text-sm text-base-content/70 whitespace-nowrap">
|
||||
+{userMatches.filter(m => m.confidence === 0).length} hidden
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<div className="w-[140px] flex-shrink-0">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-xs"
|
||||
checked={showUnmatched}
|
||||
onChange={(e) => setShowUnmatched(e.target.checked)}
|
||||
/>
|
||||
<span className="text-sm whitespace-nowrap">Show Unmatched</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 w-full sm:w-auto">
|
||||
<Button
|
||||
onClick={() => setUserMatches([])}
|
||||
className="btn-sm btn-outline h-9 px-3 min-h-0 flex-1 sm:flex-none"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
onClick={processMatchedUsers}
|
||||
disabled={!userMatches.some(m => m.confidence >= 70 || m.isApproved) || isSubmitting}
|
||||
className="btn-sm btn-primary h-9 px-3 min-h-0 flex-1 sm:flex-none"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="loading loading-spinner loading-xs"></div>
|
||||
<span className="whitespace-nowrap">
|
||||
Processing {processingProgress}%
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="whitespace-nowrap">
|
||||
Process ({userMatches.filter(m => m.confidence >= 70 || m.isApproved).length})
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 max-h-[600px] overflow-y-auto pr-1">
|
||||
{userMatches
|
||||
.filter(match => match.confidence > 0 || showUnmatched)
|
||||
.map((match) => {
|
||||
// Check if this is the current user
|
||||
const isUserSelf = isCurrentUser(match.matchedUser?.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={match.id}
|
||||
onClick={() => {
|
||||
// Only allow toggle if not self and confidence < 70
|
||||
if (!isUserSelf && match.matchedUser && match.confidence < 70) {
|
||||
handleToggleApproval(match.id);
|
||||
}
|
||||
}}
|
||||
className={`bg-base-300 p-4 rounded-lg border transition-all duration-200 ${match.confidence >= 70
|
||||
? 'border-success/30 bg-success/5'
|
||||
: match.confidence >= 40
|
||||
? match.isApproved
|
||||
? 'border-success/30 bg-success/5 cursor-pointer'
|
||||
: 'border-warning/30 hover:border-success/30 cursor-pointer'
|
||||
: match.isApproved
|
||||
? 'border-success/30 bg-success/5 cursor-pointer'
|
||||
: match.confidence === 0
|
||||
? 'border-base-content/10 bg-base-200/50'
|
||||
: 'border-error/30 hover:border-success/30 cursor-pointer'
|
||||
} ${match.confidence < 70 && match.confidence > 0 && !isUserSelf ? 'hover:shadow-md' : ''}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0 pr-2">
|
||||
<h4 className="font-medium text-base flex items-center gap-1 truncate">
|
||||
<span className="truncate">{match.importedData.name}</span>
|
||||
{(match.confidence >= 70 || match.isApproved) && (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 shrink-0 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</h4>
|
||||
<p className="text-sm text-base-content/70">{match.importedData.email}</p>
|
||||
<p className="text-sm mt-1">Role: {match.importedData.role}</p>
|
||||
{match.importedData.type && (
|
||||
<p className="text-sm mt-1">Type: {match.importedData.type}</p>
|
||||
)}
|
||||
{match.importedData.pid && (
|
||||
<p className="text-sm mt-1 text-base-content/70">PID: {match.importedData.pid}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={`text-right px-2 py-1 rounded text-sm font-medium shrink-0 ${match.confidence >= 70
|
||||
? 'bg-success/20 text-success'
|
||||
: match.confidence >= 40
|
||||
? 'bg-warning/20 text-warning'
|
||||
: 'bg-error/20 text-error'
|
||||
}`}>
|
||||
{match.confidence.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{match.matchedUser ? (
|
||||
<div className="bg-base-200 p-3 rounded text-sm mt-3">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className="w-full">
|
||||
<div className="font-medium">{match.matchedUser.name}</div>
|
||||
<div className="text-base-content/70 break-all">{match.matchedUser.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm space-y-1 mt-2">
|
||||
<div className="font-medium">Match Reasons:</div>
|
||||
{match.matchReason.map((reason, i) => (
|
||||
<div key={i} className="flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary shrink-0"></span>
|
||||
<span className="break-words">{reason}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-base-200 p-3 rounded text-sm text-error mt-3">
|
||||
No matching user found
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Display warning when match is current user */}
|
||||
{isUserSelf && (
|
||||
<div className="mt-3 py-2 text-sm text-warning text-center bg-warning/10 border border-warning/20 rounded-lg">
|
||||
Cannot edit your own role
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Only show approval message for non-self users with confidence < 70 */}
|
||||
{match.confidence < 70 && match.matchedUser && !isUserSelf && (
|
||||
<div className="mt-3 py-2 text-sm text-base-content/70 text-center bg-base-100 rounded-lg">
|
||||
{match.isApproved ? '↑ Click to remove approval ↑' : '↑ Click to approve match ↑'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk Actions Section */}
|
||||
<div className="bg-base-200 p-6 rounded-xl shadow-md border border-base-content/5 transition-all duration-300 hover:shadow-lg">
|
||||
<h2 className="text-2xl font-semibold mb-4 text-base-content flex items-center">
|
||||
|
|
Loading…
Reference in a new issue