Add duplicate component

This commit is contained in:
dvirlabs 2026-02-03 14:45:36 +02:00
parent 2144b4c446
commit ed0aedcadd
7 changed files with 472 additions and 0 deletions

View File

@ -108,3 +108,70 @@ def get_unique_owners(db: Session):
for owner in result[0].split(','): for owner in result[0].split(','):
owners.add(owner.strip()) owners.add(owner.strip())
return sorted(list(owners)) return sorted(list(owners))
def find_duplicate_guests(db: Session):
"""Find guests with duplicate phone numbers"""
from sqlalchemy import func
# Find phone numbers that appear more than once
duplicates = db.query(
models.Guest.phone_number,
func.count(models.Guest.id).label('count')
).filter(
models.Guest.phone_number.isnot(None),
models.Guest.phone_number != ''
).group_by(
models.Guest.phone_number
).having(
func.count(models.Guest.id) > 1
).all()
# Get full guest details for each duplicate phone number
result = []
for phone_number, count in duplicates:
guests = db.query(models.Guest).filter(
models.Guest.phone_number == phone_number
).all()
result.append({
'phone_number': phone_number,
'count': count,
'guests': guests
})
return result
def merge_guests(db: Session, keep_id: int, merge_ids: list[int]):
"""Merge multiple guests into one, keeping the specified guest"""
keep_guest = db.query(models.Guest).filter(models.Guest.id == keep_id).first()
if not keep_guest:
return None
merge_guests = db.query(models.Guest).filter(models.Guest.id.in_(merge_ids)).all()
# Merge data: combine information from all guests
for guest in merge_guests:
# Keep non-empty values from merged guests
if not keep_guest.email and guest.email:
keep_guest.email = guest.email
if not keep_guest.phone_number and guest.phone_number:
keep_guest.phone_number = guest.phone_number
if not keep_guest.meal_preference and guest.meal_preference:
keep_guest.meal_preference = guest.meal_preference
if not keep_guest.table_number and guest.table_number:
keep_guest.table_number = guest.table_number
# Combine owners
if guest.owner and guest.owner not in (keep_guest.owner or ''):
if keep_guest.owner:
keep_guest.owner = f"{keep_guest.owner}, {guest.owner}"
else:
keep_guest.owner = guest.owner
# Delete merged guests
db.query(models.Guest).filter(models.Guest.id.in_(merge_ids)).delete(synchronize_session=False)
db.commit()
db.refresh(keep_guest)
return keep_guest

View File

@ -103,6 +103,32 @@ def get_owners(db: Session = Depends(get_db)):
return {"owners": owners} return {"owners": owners}
@app.get("/guests/duplicates/")
def get_duplicates(db: Session = Depends(get_db)):
"""
Find guests with duplicate phone numbers
"""
duplicates = crud.find_duplicate_guests(db)
return {"duplicates": duplicates}
@app.post("/guests/merge/")
def merge_guests(request: schemas.MergeRequest, db: Session = Depends(get_db)):
"""
Merge multiple guests into one
keep_id: ID of the guest to keep
merge_ids: List of IDs of guests to merge and delete
"""
if request.keep_id in request.merge_ids:
raise HTTPException(status_code=400, detail="Cannot merge guest with itself")
result = crud.merge_guests(db, request.keep_id, request.merge_ids)
if not result:
raise HTTPException(status_code=404, detail="Guest to keep not found")
return {"message": f"Successfully merged {len(request.merge_ids)} guests", "guest": result}
# Search and filter endpoints # Search and filter endpoints
@app.get("/guests/search/", response_model=List[schemas.Guest]) @app.get("/guests/search/", response_model=List[schemas.Guest])
def search_guests( def search_guests(

View File

@ -43,3 +43,9 @@ class GuestPublicUpdate(BaseModel):
meal_preference: Optional[str] = None meal_preference: Optional[str] = None
has_plus_one: Optional[bool] = None has_plus_one: Optional[bool] = None
plus_one_name: Optional[str] = None plus_one_name: Optional[str] = None
class MergeRequest(BaseModel):
"""Schema for merging guests"""
keep_id: int
merge_ids: list[int]

View File

@ -4,6 +4,7 @@ import GuestForm from './components/GuestForm'
import SearchFilter from './components/SearchFilter' import SearchFilter from './components/SearchFilter'
import GoogleImport from './components/GoogleImport' import GoogleImport from './components/GoogleImport'
import GuestSelfService from './components/GuestSelfService' import GuestSelfService from './components/GuestSelfService'
import DuplicateManager from './components/DuplicateManager'
import Login from './components/Login' import Login from './components/Login'
import { getGuests, searchGuests } from './api/api' import { getGuests, searchGuests } from './api/api'
import './App.css' import './App.css'
@ -13,6 +14,7 @@ function App() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false) const [showForm, setShowForm] = useState(false)
const [editingGuest, setEditingGuest] = useState(null) const [editingGuest, setEditingGuest] = useState(null)
const [showDuplicates, setShowDuplicates] = useState(false)
const [currentPage, setCurrentPage] = useState('admin') // 'admin' or 'guest' const [currentPage, setCurrentPage] = useState('admin') // 'admin' or 'guest'
const [isAuthenticated, setIsAuthenticated] = useState(false) const [isAuthenticated, setIsAuthenticated] = useState(false)
@ -117,11 +119,21 @@ function App() {
<button className="btn btn-primary" onClick={handleAddGuest}> <button className="btn btn-primary" onClick={handleAddGuest}>
+ הוסף אורח + הוסף אורח
</button> </button>
<button className="btn btn-secondary" onClick={() => setShowDuplicates(true)}>
🔍 מצא כפילויות
</button>
<GoogleImport onImportComplete={handleImportComplete} /> <GoogleImport onImportComplete={handleImportComplete} />
</div> </div>
<SearchFilter onSearch={handleSearch} /> <SearchFilter onSearch={handleSearch} />
{showDuplicates && (
<DuplicateManager
onUpdate={loadGuests}
onClose={() => setShowDuplicates(false)}
/>
)}
{loading ? ( {loading ? (
<div className="loading">טוען אורחים...</div> <div className="loading">טוען אורחים...</div>
) : ( ) : (

View File

@ -79,4 +79,18 @@ export const updateGuestByPhone = async (phoneNumber, guestData) => {
return response.data return response.data
} }
// Duplicate management
export const getDuplicates = async () => {
const response = await api.get('/guests/duplicates/')
return response.data
}
export const mergeGuests = async (keepId, mergeIds) => {
const response = await api.post('/guests/merge/', {
keep_id: keepId,
merge_ids: mergeIds
})
return response.data
}
export default api export default api

View File

@ -0,0 +1,199 @@
.duplicate-manager {
background: white;
border-radius: 12px;
padding: 25px;
margin-top: 30px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.duplicate-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
padding-bottom: 15px;
border-bottom: 2px solid #e5e7eb;
}
.duplicate-header h2 {
margin: 0;
color: #1f2937;
font-size: 1.5rem;
}
.btn-close {
background: #f3f4f6;
border: none;
color: #6b7280;
font-size: 24px;
width: 40px;
height: 40px;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s;
}
.btn-close:hover {
background: #e5e7eb;
color: #1f2937;
}
.loading,
.no-duplicates {
text-align: center;
padding: 60px 20px;
color: #6b7280;
font-size: 16px;
}
.no-duplicates {
color: #10b981;
font-size: 18px;
}
.duplicates-list {
display: flex;
flex-direction: column;
gap: 30px;
}
.duplicate-group {
background: #f9fafb;
border-radius: 12px;
padding: 20px;
border: 2px solid #e5e7eb;
}
.duplicate-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.duplicate-info h3 {
margin: 0;
color: #374151;
font-size: 1.2rem;
}
.count-badge {
background: #ef4444;
color: white;
padding: 6px 14px;
border-radius: 20px;
font-size: 14px;
font-weight: 600;
}
.guests-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.guest-card {
background: white;
border: 2px solid #d1d5db;
border-radius: 8px;
padding: 15px;
cursor: pointer;
transition: all 0.2s;
}
.guest-card:hover {
border-color: #667eea;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.2);
}
.guest-card.selected {
border-color: #667eea;
background: #f0f4ff;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.radio-wrapper {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #e5e7eb;
}
.radio-wrapper input[type="radio"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.radio-wrapper label {
font-weight: 600;
color: #667eea;
cursor: pointer;
margin: 0;
}
.guest-details {
font-size: 14px;
}
.guest-details h4 {
margin: 0 0 10px 0;
color: #1f2937;
font-size: 16px;
}
.guest-details p {
margin: 6px 0;
color: #6b7280;
}
.guest-details strong {
color: #374151;
}
.owner-tag {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #e5e7eb;
font-size: 12px;
color: #9ca3af;
}
.merge-actions {
display: flex;
justify-content: center;
padding-top: 15px;
}
.merge-actions .btn {
min-width: 200px;
}
.merge-actions .btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Responsive */
@media (max-width: 768px) {
.duplicate-manager {
padding: 15px;
}
.guests-grid {
grid-template-columns: 1fr;
}
.duplicate-info {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.merge-actions .btn {
width: 100%;
}
}

View File

@ -0,0 +1,148 @@
import { useState, useEffect } from 'react'
import { getDuplicates, mergeGuests } from '../api/api'
import './DuplicateManager.css'
function DuplicateManager({ onUpdate, onClose }) {
const [duplicates, setDuplicates] = useState([])
const [loading, setLoading] = useState(true)
const [selectedKeep, setSelectedKeep] = useState({})
const [merging, setMerging] = useState(false)
useEffect(() => {
loadDuplicates()
}, [])
const loadDuplicates = async () => {
try {
setLoading(true)
const response = await getDuplicates()
setDuplicates(response.duplicates || [])
} catch (error) {
console.error('Error loading duplicates:', error)
alert('שגיאה בטעינת כפילויות')
} finally {
setLoading(false)
}
}
const handleMerge = async (phoneNumber, guests) => {
const keepId = selectedKeep[phoneNumber]
if (!keepId) {
alert('אנא בחר איזה אורח לשמור')
return
}
const mergeIds = guests
.filter(g => g.id !== keepId)
.map(g => g.id)
if (mergeIds.length === 0) {
alert('לא נבחרו אורחים למיזוג')
return
}
if (!window.confirm(`האם למזג ${mergeIds.length} אורחים לאורח הנבחר?`)) {
return
}
try {
setMerging(true)
await mergeGuests(keepId, mergeIds)
alert('האורחים מוזגו בהצלחה!')
await loadDuplicates()
if (onUpdate) onUpdate()
} catch (error) {
console.error('Error merging guests:', error)
alert('שגיאה במיזוג אורחים')
} finally {
setMerging(false)
}
}
if (loading) {
return (
<div className="duplicate-manager">
<div className="duplicate-header">
<h2>🔍 ניהול כפילויות</h2>
<button className="btn-close" onClick={onClose}></button>
</div>
<div className="loading">טוען כפילויות...</div>
</div>
)
}
if (duplicates.length === 0) {
return (
<div className="duplicate-manager">
<div className="duplicate-header">
<h2>🔍 ניהול כפילויות</h2>
<button className="btn-close" onClick={onClose}></button>
</div>
<div className="no-duplicates">
<p> לא נמצאו כפילויות! כל האורחים ייחודיים.</p>
</div>
</div>
)
}
return (
<div className="duplicate-manager" dir="rtl">
<div className="duplicate-header">
<h2>🔍 ניהול כפילויות ({duplicates.length} מספרי טלפון)</h2>
<button className="btn-close" onClick={onClose}></button>
</div>
<div className="duplicates-list">
{duplicates.map((dup, index) => (
<div key={index} className="duplicate-group">
<div className="duplicate-info">
<h3>📞 {dup.phone_number}</h3>
<span className="count-badge">{dup.count} אורחים</span>
</div>
<div className="guests-grid">
{dup.guests.map((guest) => (
<div
key={guest.id}
className={`guest-card ${selectedKeep[dup.phone_number] === guest.id ? 'selected' : ''}`}
onClick={() => setSelectedKeep({...selectedKeep, [dup.phone_number]: guest.id})}
>
<div className="radio-wrapper">
<input
type="radio"
name={`keep-${dup.phone_number}`}
checked={selectedKeep[dup.phone_number] === guest.id}
onChange={() => setSelectedKeep({...selectedKeep, [dup.phone_number]: guest.id})}
/>
<label>שמור אורח זה</label>
</div>
<div className="guest-details">
<h4>{guest.first_name} {guest.last_name}</h4>
<p><strong>אימייל:</strong> {guest.email || '-'}</p>
<p><strong>אישור:</strong> {guest.rsvp_status}</p>
<p><strong>ארוחה:</strong> {guest.meal_preference || '-'}</p>
<p><strong>פלאס ואן:</strong> {guest.has_plus_one ? `כן${guest.plus_one_name ? ` (${guest.plus_one_name})` : ''}` : 'לא'}</p>
<p><strong>שולחן:</strong> {guest.table_number || '-'}</p>
<p className="owner-tag"><strong>מקור:</strong> {guest.owner || '-'}</p>
</div>
</div>
))}
</div>
<div className="merge-actions">
<button
className="btn btn-primary"
onClick={() => handleMerge(dup.phone_number, dup.guests)}
disabled={!selectedKeep[dup.phone_number] || merging}
>
{merging ? 'ממזג...' : `🔗 מזג אורחים`}
</button>
</div>
</div>
))}
</div>
</div>
)
}
export default DuplicateManager