529 lines
18 KiB
JavaScript
529 lines
18 KiB
JavaScript
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
|