From ed0aedcadd5f721c9acbdb56fac79af8fe185111 Mon Sep 17 00:00:00 2001 From: dvirlabs Date: Tue, 3 Feb 2026 14:45:36 +0200 Subject: [PATCH] Add duplicate component --- backend/crud.py | 67 +++++++ backend/main.py | 26 +++ backend/schemas.py | 6 + frontend/src/App.jsx | 12 ++ frontend/src/api/api.js | 14 ++ frontend/src/components/DuplicateManager.css | 199 +++++++++++++++++++ frontend/src/components/DuplicateManager.jsx | 148 ++++++++++++++ 7 files changed, 472 insertions(+) create mode 100644 frontend/src/components/DuplicateManager.css create mode 100644 frontend/src/components/DuplicateManager.jsx diff --git a/backend/crud.py b/backend/crud.py index 99c4db7..d950be5 100644 --- a/backend/crud.py +++ b/backend/crud.py @@ -108,3 +108,70 @@ def get_unique_owners(db: Session): for owner in result[0].split(','): owners.add(owner.strip()) 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 diff --git a/backend/main.py b/backend/main.py index 279ef07..c280b1a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -103,6 +103,32 @@ def get_owners(db: Session = Depends(get_db)): 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 @app.get("/guests/search/", response_model=List[schemas.Guest]) def search_guests( diff --git a/backend/schemas.py b/backend/schemas.py index 8eebac3..08cf6d0 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -43,3 +43,9 @@ class GuestPublicUpdate(BaseModel): meal_preference: Optional[str] = None has_plus_one: Optional[bool] = None plus_one_name: Optional[str] = None + + +class MergeRequest(BaseModel): + """Schema for merging guests""" + keep_id: int + merge_ids: list[int] diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 240c401..6748d1a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -4,6 +4,7 @@ import GuestForm from './components/GuestForm' import SearchFilter from './components/SearchFilter' import GoogleImport from './components/GoogleImport' import GuestSelfService from './components/GuestSelfService' +import DuplicateManager from './components/DuplicateManager' import Login from './components/Login' import { getGuests, searchGuests } from './api/api' import './App.css' @@ -13,6 +14,7 @@ function App() { const [loading, setLoading] = useState(true) const [showForm, setShowForm] = useState(false) const [editingGuest, setEditingGuest] = useState(null) + const [showDuplicates, setShowDuplicates] = useState(false) const [currentPage, setCurrentPage] = useState('admin') // 'admin' or 'guest' const [isAuthenticated, setIsAuthenticated] = useState(false) @@ -117,11 +119,21 @@ function App() { + + {showDuplicates && ( + setShowDuplicates(false)} + /> + )} + {loading ? (
טוען אורחים...
) : ( diff --git a/frontend/src/api/api.js b/frontend/src/api/api.js index 7eb6569..b71f334 100644 --- a/frontend/src/api/api.js +++ b/frontend/src/api/api.js @@ -79,4 +79,18 @@ export const updateGuestByPhone = async (phoneNumber, guestData) => { 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 diff --git a/frontend/src/components/DuplicateManager.css b/frontend/src/components/DuplicateManager.css new file mode 100644 index 0000000..cb6105d --- /dev/null +++ b/frontend/src/components/DuplicateManager.css @@ -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%; + } +} diff --git a/frontend/src/components/DuplicateManager.jsx b/frontend/src/components/DuplicateManager.jsx new file mode 100644 index 0000000..faeb17e --- /dev/null +++ b/frontend/src/components/DuplicateManager.jsx @@ -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 ( +
+
+

🔍 ניהול כפילויות

+ +
+
טוען כפילויות...
+
+ ) + } + + if (duplicates.length === 0) { + return ( +
+
+

🔍 ניהול כפילויות

+ +
+
+

✅ לא נמצאו כפילויות! כל האורחים ייחודיים.

+
+
+ ) + } + + return ( +
+
+

🔍 ניהול כפילויות ({duplicates.length} מספרי טלפון)

+ +
+ +
+ {duplicates.map((dup, index) => ( +
+
+

📞 {dup.phone_number}

+ {dup.count} אורחים +
+ +
+ {dup.guests.map((guest) => ( +
setSelectedKeep({...selectedKeep, [dup.phone_number]: guest.id})} + > +
+ setSelectedKeep({...selectedKeep, [dup.phone_number]: guest.id})} + /> + +
+
+

{guest.first_name} {guest.last_name}

+

אימייל: {guest.email || '-'}

+

אישור: {guest.rsvp_status}

+

ארוחה: {guest.meal_preference || '-'}

+

פלאס ואן: {guest.has_plus_one ? `כן${guest.plus_one_name ? ` (${guest.plus_one_name})` : ''}` : 'לא'}

+

שולחן: {guest.table_number || '-'}

+

מקור: {guest.owner || '-'}

+
+
+ ))} +
+ +
+ +
+
+ ))} +
+
+ ) +} + +export default DuplicateManager