221 lines
7.7 KiB
Vue
221 lines
7.7 KiB
Vue
<template>
|
|
<v-container fluid>
|
|
<v-row>
|
|
<v-col>
|
|
<h1 class="text-h4 mb-4">
|
|
<v-icon class="mr-2">mdi-content-duplicate</v-icon>
|
|
Duplicate Management
|
|
</h1>
|
|
|
|
<v-card>
|
|
<v-card-title>
|
|
<v-icon class="mr-2">mdi-magnify</v-icon>
|
|
Find Duplicate Interests
|
|
</v-card-title>
|
|
|
|
<v-card-text>
|
|
<v-row align="center" class="mb-4">
|
|
<v-col cols="auto">
|
|
<v-btn
|
|
color="primary"
|
|
:loading="scanning"
|
|
@click="findDuplicates"
|
|
prepend-icon="mdi-magnify"
|
|
>
|
|
Scan for Duplicates
|
|
</v-btn>
|
|
</v-col>
|
|
|
|
<v-col cols="auto">
|
|
<v-text-field
|
|
v-model="threshold"
|
|
label="Similarity Threshold (%)"
|
|
type="number"
|
|
min="50"
|
|
max="100"
|
|
density="compact"
|
|
style="width: 200px"
|
|
/>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<v-alert v-if="error" type="error" class="mb-4">
|
|
{{ error }}
|
|
</v-alert>
|
|
|
|
<v-alert v-if="duplicateGroups.length === 0 && !scanning && hasScanned" type="success" class="mb-4">
|
|
<v-icon class="mr-2">mdi-check-circle</v-icon>
|
|
No duplicates found! Your database is clean.
|
|
</v-alert>
|
|
|
|
<v-expansion-panels v-if="duplicateGroups.length > 0" multiple>
|
|
<v-expansion-panel
|
|
v-for="(group, index) in duplicateGroups"
|
|
:key="index"
|
|
:title="`Duplicate Group ${index + 1} (${group.duplicates.length + 1} records, ${Math.round(group.similarity)}% similar)`"
|
|
>
|
|
<v-expansion-panel-text>
|
|
<v-row>
|
|
<v-col cols="12">
|
|
<h4 class="mb-3">Master Record:</h4>
|
|
<v-card variant="outlined" color="success" class="mb-4">
|
|
<v-card-text>
|
|
<v-row>
|
|
<v-col cols="6">
|
|
<strong>Name:</strong> {{ group.master['Full Name'] }}<br>
|
|
<strong>Email:</strong> {{ group.master['Email Address'] || 'N/A' }}<br>
|
|
<strong>Phone:</strong> {{ group.master['Phone Number'] || 'N/A' }}
|
|
</v-col>
|
|
<v-col cols="6">
|
|
<strong>Address:</strong> {{ group.master['Address'] || 'N/A' }}<br>
|
|
<strong>Created:</strong> {{ group.master['Created At'] || 'N/A' }}<br>
|
|
<strong>ID:</strong> {{ group.master.Id }}
|
|
</v-col>
|
|
</v-row>
|
|
</v-card-text>
|
|
</v-card>
|
|
|
|
<h4 class="mb-3">Duplicates:</h4>
|
|
<v-card
|
|
v-for="duplicate in group.duplicates"
|
|
:key="duplicate.record.Id"
|
|
variant="outlined"
|
|
color="warning"
|
|
class="mb-2"
|
|
>
|
|
<v-card-text>
|
|
<v-row>
|
|
<v-col cols="6">
|
|
<strong>Name:</strong> {{ duplicate.record['Full Name'] }}<br>
|
|
<strong>Email:</strong> {{ duplicate.record['Email Address'] || 'N/A' }}<br>
|
|
<strong>Phone:</strong> {{ duplicate.record['Phone Number'] || 'N/A' }}
|
|
</v-col>
|
|
<v-col cols="6">
|
|
<strong>Address:</strong> {{ duplicate.record['Address'] || 'N/A' }}<br>
|
|
<strong>Created:</strong> {{ duplicate.record['Created At'] || 'N/A' }}<br>
|
|
<strong>Similarity:</strong> {{ Math.round(duplicate.similarity) }}%
|
|
</v-col>
|
|
</v-row>
|
|
</v-card-text>
|
|
</v-card>
|
|
|
|
<v-divider class="my-4"></v-divider>
|
|
|
|
<v-btn
|
|
color="primary"
|
|
:loading="merging === index"
|
|
@click="mergeDuplicates(group, index)"
|
|
prepend-icon="mdi-merge"
|
|
>
|
|
Merge All Into Master
|
|
</v-btn>
|
|
</v-col>
|
|
</v-row>
|
|
</v-expansion-panel-text>
|
|
</v-expansion-panel>
|
|
</v-expansion-panels>
|
|
</v-card-text>
|
|
</v-card>
|
|
</v-col>
|
|
</v-row>
|
|
</v-container>
|
|
</template>
|
|
|
|
<script setup>
|
|
definePageMeta({
|
|
middleware: ["authentication"],
|
|
layout: false,
|
|
});
|
|
|
|
const duplicateGroups = ref([]);
|
|
const scanning = ref(false);
|
|
const merging = ref(-1);
|
|
const error = ref('');
|
|
const hasScanned = ref(false);
|
|
const threshold = ref(80);
|
|
|
|
const { hasRole } = useAuthorization();
|
|
|
|
// Check sales or admin access
|
|
onMounted(async () => {
|
|
const canAccess = await hasRole(['sales', 'admin']);
|
|
if (!canAccess) {
|
|
throw createError({
|
|
statusCode: 403,
|
|
statusMessage: 'Access denied. Sales or admin privileges required.'
|
|
});
|
|
}
|
|
});
|
|
|
|
const findDuplicates = async () => {
|
|
scanning.value = true;
|
|
error.value = '';
|
|
duplicateGroups.value = [];
|
|
|
|
try {
|
|
const response = await $fetch('/api/interests/duplicates/find', {
|
|
method: 'GET',
|
|
query: {
|
|
threshold: threshold.value / 100, // Convert percentage to decimal
|
|
dateRange: 365
|
|
}
|
|
});
|
|
|
|
if (response.success) {
|
|
// Convert the new API format to the format expected by the UI
|
|
const convertedGroups = response.data.duplicateGroups.map(group => ({
|
|
master: group.masterCandidate,
|
|
duplicates: group.interests.filter(interest => interest.Id !== group.masterCandidate.Id).map(interest => ({
|
|
record: interest,
|
|
similarity: group.confidence * 100 // Convert back to percentage
|
|
})),
|
|
similarity: group.confidence * 100,
|
|
matchReason: group.matchReason
|
|
}));
|
|
|
|
duplicateGroups.value = convertedGroups;
|
|
hasScanned.value = true;
|
|
} else {
|
|
error.value = response.error || 'Failed to find duplicates';
|
|
}
|
|
} catch (err) {
|
|
error.value = 'Failed to scan for duplicates: ' + (err.message || 'Unknown error');
|
|
} finally {
|
|
scanning.value = false;
|
|
}
|
|
};
|
|
|
|
const mergeDuplicates = async (group, index) => {
|
|
merging.value = index;
|
|
error.value = '';
|
|
|
|
try {
|
|
const duplicateIds = group.duplicates.map(d => d.record.Id);
|
|
|
|
const response = await $fetch('/api/interests/duplicates/merge', {
|
|
method: 'POST',
|
|
body: {
|
|
masterId: group.master.Id,
|
|
duplicateIds: duplicateIds,
|
|
mergeData: {} // Using master as-is for now
|
|
}
|
|
});
|
|
|
|
if (response.success) {
|
|
// Remove the merged group from the list
|
|
duplicateGroups.value.splice(index, 1);
|
|
|
|
// Show success message
|
|
const toast = useToast();
|
|
toast.success(`Successfully merged ${duplicateIds.length} duplicate records into master record ${group.master.Id}`);
|
|
} else {
|
|
error.value = response.error || 'Failed to merge duplicates';
|
|
}
|
|
} catch (err) {
|
|
error.value = 'Failed to merge duplicates: ' + (err.message || 'Unknown error');
|
|
} finally {
|
|
merging.value = -1;
|
|
}
|
|
};
|
|
</script>
|