Add duplicate component
This commit is contained in:
parent
2144b4c446
commit
ed0aedcadd
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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() {
|
||||
<button className="btn btn-primary" onClick={handleAddGuest}>
|
||||
+ הוסף אורח
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => setShowDuplicates(true)}>
|
||||
🔍 מצא כפילויות
|
||||
</button>
|
||||
<GoogleImport onImportComplete={handleImportComplete} />
|
||||
</div>
|
||||
|
||||
<SearchFilter onSearch={handleSearch} />
|
||||
|
||||
{showDuplicates && (
|
||||
<DuplicateManager
|
||||
onUpdate={loadGuests}
|
||||
onClose={() => setShowDuplicates(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="loading">טוען אורחים...</div>
|
||||
) : (
|
||||
|
||||
@ -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
|
||||
|
||||
199
frontend/src/components/DuplicateManager.css
Normal file
199
frontend/src/components/DuplicateManager.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
148
frontend/src/components/DuplicateManager.jsx
Normal file
148
frontend/src/components/DuplicateManager.jsx
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user