invy/frontend/src/components/GuestList.jsx

529 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react'
import { getGuests, getGuestOwners, createGuest, updateGuest, deleteGuest, sendWhatsAppInvitationToGuests, getEvent } from '../api/api'
import GuestForm from './GuestForm'
import GoogleImport from './GoogleImport'
import ImportContacts from './ImportContacts'
import SearchFilter from './SearchFilter'
import DuplicateManager from './DuplicateManager'
import WhatsAppInviteModal from './WhatsAppInviteModal'
import * as XLSX from 'xlsx'
import './GuestList.css'
// Hebrew translations
const he = {
backToEvents: '← חזרה לאירועים',
guestManagement: 'ניהול אורחים',
manageMembers: '👥 ניהול חברים',
exportExcel: '📥 ייצוא לאקסל',
addGuest: '+ הוסף אורח',
totalGuests: 'סה"כ אורחים',
confirmed: 'אישרו הגעה',
declined: 'דחו הגעה',
inviteSent: 'הזמנות שנשלחו',
filterByStatus: 'סנן לפי סטטוס:',
filterByOwner: 'האורחים של:',
allGuests: 'כל האורחים',
selfService: 'רישום עצמי',
noGuestsFound: 'לא נמצאו אורחים. התחל בהוספת אורח ראשון!',
addFirstGuest: 'הוסף אורח ראשון',
name: 'שם',
phone: 'טלפון',
email: 'אימייל',
rsvpStatus: 'סטטוס RSVP',
mealPref: 'העדפת מזון',
plusOne: 'חברה נוספת',
actions: 'פעולות',
edit: 'עריכה',
delete: 'מחיקה',
selectAll: 'בחר הכל',
selectedCount: 'נבחרו {count} אורחים',
confirm: 'אישור',
decline: 'דחייה',
invited: 'הזמנה',
sure: 'האם אתה בטוח שברצונך למחוק אורח זה?',
failedToLoadOwners: 'נכשל בטעינת בעלים',
failedToLoadGuests: 'נכשל בטעינת אורחים',
failedToDelete: 'נכשל במחיקת אורח',
sendWhatsApp: '💬 שלח בוואטסאפ',
noGuestsSelected: 'בחר לפחות אורח אחד',
selectGuestsFirst: 'בחר אורחים לשליחת הזמנה'
}
function GuestList({ eventId, onBack, onShowMembers }) {
const [guests, setGuests] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [eventNotFound, setEventNotFound] = useState(false)
const [showGuestForm, setShowGuestForm] = useState(false)
const [editingGuest, setEditingGuest] = useState(null)
const [owners, setOwners] = useState([])
const [ownerList, setOwnerList] = useState([])
const [selectedGuestIds, setSelectedGuestIds] = useState(new Set())
const [searchFilters, setSearchFilters] = useState({
query: '',
rsvpStatus: '',
mealPreference: '',
owner: ''
})
const [showDuplicateManager, setShowDuplicateManager] = useState(false)
const [itemsPerPage, setItemsPerPage] = useState(25)
const [showWhatsAppModal, setShowWhatsAppModal] = useState(false)
const [eventData, setEventData] = useState({})
useEffect(() => {
loadGuests()
loadOwners()
loadEventData()
}, [eventId])
const loadOwners = async () => {
try {
const data = await getGuestOwners(eventId)
if (data.owners) {
setOwnerList(data.owners)
setOwners(data)
}
} catch (err) {
if (err?.response?.status === 404) {
setEventNotFound(true)
} else {
console.error('Failed to load guest owners:', err)
setError(he.failedToLoadOwners)
}
}
}
const loadEventData = async () => {
try {
const data = await getEvent(eventId)
setEventData(data)
} catch (err) {
if (err?.response?.status === 404) {
setEventNotFound(true)
setLoading(false)
}
console.error('Failed to load event data:', err)
}
}
const loadGuests = async () => {
try {
setLoading(true)
const data = await getGuests(eventId)
setGuests(data)
setSelectedGuestIds(new Set())
setError('')
} catch (err) {
if (err?.response?.status === 404) {
setEventNotFound(true)
} else {
setError(he.failedToLoadGuests)
console.error(err)
}
} finally {
setLoading(false)
}
}
const handleGuestCreated = async (guestData) => {
try {
const newGuest = await createGuest(eventId, guestData)
setGuests([...guests, newGuest])
setShowGuestForm(false)
setEditingGuest(null)
} catch (err) {
console.error('Failed to create guest:', err)
throw err
}
}
const handleGuestUpdated = async (guestId, guestData) => {
try {
const updatedGuest = await updateGuest(eventId, guestId, guestData)
setGuests(guests.map(g => g.id === guestId ? updatedGuest : g))
setShowGuestForm(false)
setEditingGuest(null)
} catch (err) {
console.error('Failed to update guest:', err)
throw err
}
}
const handleDelete = async (guestId) => {
if (!window.confirm(he.sure)) {
return
}
try {
await deleteGuest(eventId, guestId)
setGuests(guests.filter(g => g.id !== guestId))
setSelectedGuestIds(prev => {
const newSet = new Set(prev)
newSet.delete(guestId)
return newSet
})
} catch (err) {
setError(he.failedToDelete)
console.error(err)
}
}
const handleEdit = (guest) => {
setEditingGuest(guest)
setShowGuestForm(true)
}
const toggleGuestSelection = (guestId) => {
const newSet = new Set(selectedGuestIds)
if (newSet.has(guestId)) {
newSet.delete(guestId)
} else {
newSet.add(guestId)
}
setSelectedGuestIds(newSet)
}
const toggleSelectAll = () => {
if (selectedGuestIds.size === filteredGuests.length) {
setSelectedGuestIds(new Set())
} else {
setSelectedGuestIds(new Set(filteredGuests.map(g => g.id)))
}
}
// Apply search and filter logic
const filteredGuests = guests.filter(guest => {
// Text search — normalize whitespace first, then match token-by-token so that:
// • trailing/leading spaces don't break results ("דור " == "דור")
// • multiple spaces collapse to one ("דור נחמני" == "דור נחמני")
// • full-name search works ("דור נחמני" matches first="דור" last="נחמני")
if (searchFilters.query) {
const normalized = searchFilters.query.trim().replace(/\s+/g, ' ')
if (normalized === '') {
// After normalization the query is blank → treat as "no filter"
} else {
const tokens = normalized.toLowerCase().split(' ').filter(Boolean)
const haystack = [
guest.first_name || '',
guest.last_name || '',
guest.phone_number|| '',
guest.email || '',
].join(' ').toLowerCase()
const matchesQuery = tokens.every(token => haystack.includes(token))
if (!matchesQuery) return false
}
}
// RSVP Status filter
if (searchFilters.rsvpStatus && guest.rsvp_status !== searchFilters.rsvpStatus) {
return false
}
// Meal preference filter
if (searchFilters.mealPreference && guest.meal_preference !== searchFilters.mealPreference) {
return false
}
// Owner filter
if (searchFilters.owner) {
if (searchFilters.owner === 'self-service' && guest.owner_email !== 'self-service') {
return false
} else if (searchFilters.owner !== 'self-service' && guest.owner_email !== searchFilters.owner) {
return false
}
}
return true
})
const stats = {
total: guests.length,
confirmed: guests.filter(g => g.rsvp_status === 'confirmed').length,
declined: guests.filter(g => g.rsvp_status === 'declined').length,
invited: guests.filter(g => g.rsvp_status === 'invited').length,
}
const exportToExcel = () => {
const exportData = guests.map(guest => ({
'First Name': guest.first_name,
'Last Name': guest.last_name,
'Email': guest.email || '',
'Phone': guest.phone_number || '',
'RSVP Status': guest.rsvp_status,
'Meal Preference': guest.meal_preference || '',
'Plus One': guest.has_plus_one ? 'Yes' : 'No',
'Plus One Name': guest.plus_one_name || '',
'Table Number': guest.table_number || '',
'Notes': guest.notes || ''
}))
const ws = XLSX.utils.json_to_sheet(exportData)
ws['!cols'] = [
{ wch: 15 }, // First Name
{ wch: 15 }, // Last Name
{ wch: 25 }, // Email
{ wch: 15 }, // Phone
{ wch: 15 }, // RSVP Status
{ wch: 15 }, // Meal Preference
{ wch: 10 }, // Plus One
{ wch: 15 }, // Plus One Name
{ wch: 12 }, // Table Number
{ wch: 20 } // Notes
]
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, 'Guests')
const date = new Date().toISOString().split('T')[0]
const fileName = `guest-list-${date}.xlsx`
XLSX.writeFile(wb, fileName)
}
const handleSendWhatsApp = async (data) => {
if (selectedGuestIds.size === 0) {
alert(he.noGuestsSelected)
return null
}
try {
const selectedGuests = filteredGuests.filter(g => selectedGuestIds.has(g.id))
const result = await sendWhatsAppInvitationToGuests(
eventId,
Array.from(selectedGuestIds),
data.formData,
data.templateKey || 'wedding_invitation',
data.extraParams || null
)
// Clear selection after successful send
setSelectedGuestIds(new Set())
return result
} catch (err) {
console.error('Failed to send WhatsApp invitations:', err)
throw err
}
}
if (eventNotFound) {
return (
<div className="guest-list-container">
<div className="guest-list-header">
<button className="btn-back" onClick={onBack}>{he.backToEvents}</button>
</div>
<div style={{ textAlign: 'center', padding: '4rem 2rem', color: 'var(--text-secondary)' }}>
<div style={{ fontSize: '3rem', marginBottom: '1rem' }}>🔍</div>
<h2 style={{ marginBottom: '0.5rem' }}>האירוע לא נמצא</h2>
<p style={{ marginBottom: '2rem' }}>האירוע שביקשת אינו קיים או שאין לך הרשאה לצפות בו.</p>
<button className="btn-primary" onClick={onBack}>{he.backToEvents}</button>
</div>
</div>
)
}
if (loading) {
return <div className="guest-list-loading">טוען {he.guestManagement}...</div>
}
return (
<div className="guest-list-container">
<div className="guest-list-header">
<button className="btn-back" onClick={onBack}>{he.backToEvents}</button>
<div className="header-title">
{eventData?.name ? (
<>
<h2 className="header-event-title">{eventData.name}</h2>
<span className="header-event-subtitle">{he.guestManagement}</span>
</>
) : (
<h2>{he.guestManagement}</h2>
)}
</div>
<div className="header-actions">
{/* <button className="btn-members" onClick={onShowMembers}>
{he.manageMembers}
</button> */}
<button className="btn-duplicate" onClick={() => setShowDuplicateManager(true)}>
🔍 חיפוש כפולויות
</button>
<GoogleImport eventId={eventId} onImportComplete={loadGuests} />
<ImportContacts eventId={eventId} onImportComplete={loadGuests} />
<button className="btn-export" onClick={exportToExcel}>
{he.exportExcel}
</button>
{selectedGuestIds.size > 0 && (
<button
className="btn-whatsapp"
onClick={() => setShowWhatsAppModal(true)}
title={he.selectGuestsFirst}
>
{he.sendWhatsApp} ({selectedGuestIds.size})
</button>
)}
<button className="btn-add-guest" onClick={() => {
setEditingGuest(null)
setShowGuestForm(true)
}}>
{he.addGuest}
</button>
</div>
</div>
{error && <div className="error-message">{error}</div>}
<div className="guest-stats">
<div className="stat-card">
<span className="stat-label">{he.totalGuests}</span>
<span className="stat-value">{stats.total}</span>
</div>
<div className="stat-card">
<span className="stat-label">{he.confirmed}</span>
<span className="stat-value" style={{ color: 'var(--color-success)' }}>{stats.confirmed}</span>
</div>
<div className="stat-card">
<span className="stat-label">{he.declined}</span>
<span className="stat-value" style={{ color: 'var(--color-danger)' }}>{stats.declined}</span>
</div>
<div className="stat-card">
<span className="stat-label">{he.invited}</span>
<span className="stat-value" style={{ color: 'var(--color-warning)' }}>{stats.invited}</span>
</div>
</div>
{selectedGuestIds.size > 0 && (
<div className="selection-bar">
<span className="selection-text">
{he.selectedCount.replace('{count}', selectedGuestIds.size)}
</span>
</div>
)}
<SearchFilter eventId={eventId} onSearch={setSearchFilters} />
<div className="pagination-controls">
<label htmlFor="items-per-page">הצג אורחים:</label>
<select
id="items-per-page"
value={itemsPerPage}
onChange={(e) => setItemsPerPage(e.target.value === 'all' ? 'all' : parseInt(e.target.value))}
>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="all">הכל ({filteredGuests.length})</option>
</select>
</div>
{showDuplicateManager && (
<DuplicateManager
eventId={eventId}
onUpdate={loadGuests}
onClose={() => setShowDuplicateManager(false)}
/>
)}
{(itemsPerPage === 'all' ? filteredGuests : filteredGuests.slice(0, itemsPerPage)).length === 0 ? (
<div className="empty-state">
<p>{he.noGuestsFound}</p>
<button className="btn-add-guest-large" onClick={() => {
setEditingGuest(null)
setShowGuestForm(true)
}}>
{he.addFirstGuest}
</button>
</div>
) : (
<div className="guests-table">
<table>
<thead>
<tr>
<th className="checkbox-cell">
<input
type="checkbox"
checked={selectedGuestIds.size === (itemsPerPage === 'all' ? filteredGuests : filteredGuests.slice(0, itemsPerPage)).length && (itemsPerPage === 'all' ? filteredGuests : filteredGuests.slice(0, itemsPerPage)).length > 0}
onChange={toggleSelectAll}
title={he.selectAll}
/>
</th>
<th>{he.name}</th>
<th>{he.phone}</th>
<th>{he.email}</th>
<th>{he.rsvpStatus}</th>
<th>{he.mealPref}</th>
<th>{he.plusOne}</th>
<th>{he.actions}</th>
</tr>
</thead>
<tbody>
{(itemsPerPage === 'all' ? filteredGuests : filteredGuests.slice(0, itemsPerPage)).map(guest => (
<tr key={guest.id} className={selectedGuestIds.has(guest.id) ? 'selected' : ''}>
<td className="checkbox-cell">
<input
type="checkbox"
checked={selectedGuestIds.has(guest.id)}
onChange={() => toggleGuestSelection(guest.id)}
/>
</td>
<td className="guest-name">
<strong>{guest.first_name} {guest.last_name}</strong>
</td>
<td>{guest.phone_number || '-'}</td>
<td>{guest.email || '-'}</td>
<td>
<span className={`rsvp-badge rsvp-${guest.rsvp_status}`}>
{he[guest.rsvp_status] || guest.rsvp_status}
</span>
</td>
<td>{guest.meal_preference || '-'}</td>
<td>{guest.plus_one_name || (guest.has_plus_one ? 'Yes (not named)' : 'No')}</td>
<td className="guest-actions">
<button
className="btn-edit-small"
onClick={() => handleEdit(guest)}
>
{he.edit}
</button>
<button
className="btn-delete-small"
onClick={() => handleDelete(guest.id)}
>
{he.delete}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{showGuestForm && (
<GuestForm
eventId={eventId}
guest={editingGuest}
onGuestCreated={handleGuestCreated}
onGuestUpdated={handleGuestUpdated}
onCancel={() => {
setShowGuestForm(false)
setEditingGuest(null)
}}
/>
)}
{/* WhatsApp Invitation Modal */}
<WhatsAppInviteModal
isOpen={showWhatsAppModal}
onClose={() => setShowWhatsAppModal(false)}
selectedGuests={Array.from(selectedGuestIds).map(id =>
filteredGuests.find(g => g.id === id)
).filter(Boolean)}
eventData={eventData}
onSend={handleSendWhatsApp}
/>
</div>
)
}
export default GuestList