invy/frontend/src/components/GuestList.jsx
dvirlabs 65a4b82fe5
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Add considering table
2026-04-03 02:41:23 +03:00

652 lines
23 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, bulkDeleteGuests, 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: 'בחר אורחים לשליחת הזמנה',
deleteSelected: '🗑️ מחק נבחרים',
confirmDeleteSelected: 'האם אתה בטוח שברצונך למחוק {count} אורחים?',
failedToDeleteSelected: 'נכשל במחיקת האורחים הנבחרים',
addToConsideration: '📋 הוסף לשיקול',
considerationList: 'רשימת שיקול',
removeFromConsideration: 'הסר',
sortByName: 'מיין לפי שם',
inviteGuest: '✅ מזמין',
notInviteGuest: '❌ לא מזמין'
}
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({})
const [sortOrder, setSortOrder] = useState(() => {
const saved = localStorage.getItem(`guestSortOrder_${eventId}`)
return saved && ['asc', 'desc', 'none'].includes(saved) ? saved : 'none'
}) // 'none' | 'asc' | 'desc'
const [considerationIds, setConsiderationIds] = useState(new Set())
const [showConsiderationPanel, setShowConsiderationPanel] = useState(true)
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 handleAddToConsideration = () => {
setConsiderationIds(prev => new Set([...prev, ...selectedGuestIds]))
setSelectedGuestIds(new Set())
setShowConsiderationPanel(true)
}
const handleRemoveFromConsideration = (guestId) => {
setConsiderationIds(prev => {
const next = new Set(prev)
next.delete(guestId)
return next
})
}
const handleConsiderationDecision = async (guestId, invite) => {
if (invite) {
try {
await updateGuest(eventId, guestId, { rsvp_status: 'invited' })
setGuests(prev => prev.map(g => g.id === guestId ? { ...g, rsvp_status: 'invited' } : g))
} catch (err) {
console.error('Failed to update guest:', err)
}
} else {
try {
await deleteGuest(eventId, guestId)
setGuests(prev => prev.filter(g => g.id !== guestId))
} catch (err) {
console.error('Failed to delete guest:', err)
}
}
handleRemoveFromConsideration(guestId)
}
const handleDeleteSelected = async () => {
if (selectedGuestIds.size === 0) return
if (!window.confirm(he.confirmDeleteSelected.replace('{count}', selectedGuestIds.size))) return
try {
await bulkDeleteGuests(eventId, Array.from(selectedGuestIds))
setGuests(guests.filter(g => !selectedGuestIds.has(g.id)))
setSelectedGuestIds(new Set())
} catch (err) {
setError(he.failedToDeleteSelected)
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 = () => {
const paged = itemsPerPage === 'all' ? sortedGuests : sortedGuests.slice(0, itemsPerPage)
if (selectedGuestIds.size === paged.length && paged.length > 0) {
setSelectedGuestIds(new Set())
} else {
setSelectedGuestIds(new Set(paged.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 sortedGuests = sortOrder === 'none'
? filteredGuests
: [...filteredGuests].sort((a, b) => {
const nameA = `${a.first_name || ''} ${a.last_name || ''}`.toLowerCase()
const nameB = `${b.first_name || ''} ${b.last_name || ''}`.toLowerCase()
return sortOrder === 'asc'
? nameA.localeCompare(nameB, 'he')
: nameB.localeCompare(nameA, 'he')
})
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" style={selectedGuestIds.size > 0 ? { paddingBottom: '80px' } : {}}>
<div className="guest-list-header">
{/* ── Row 1: back + title ── */}
<div className="guest-list-header-top">
<button className="btn-back" onClick={onBack}>{he.backToEvents}</button>
<div className="header-title">
<h2 className="header-event-title">
{eventData?.name || he.guestManagement}
</h2>
{eventData?.name && (
<span className="header-event-subtitle">{he.guestManagement}</span>
)}
</div>
</div>
{/* ── Row 2: toolbar ── */}
<div className="guest-list-header-actions">
<div className="btn-group btn-group-tools">
<button className="btn-tool" onClick={() => setShowDuplicateManager(true)}>
🔍 כפולויות
</button>
<GoogleImport eventId={eventId} onImportComplete={loadGuests} />
<ImportContacts eventId={eventId} onImportComplete={loadGuests} />
<button className="btn-tool" onClick={exportToExcel}>
📥 אקסל
</button>
</div>
<div className="btn-group btn-group-primary">
<button className="btn-add-guest" onClick={() => {
setEditingGuest(null)
setShowGuestForm(true)
}}>
{he.addGuest}
</button>
</div>
</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>
<SearchFilter eventId={eventId} onSearch={setSearchFilters} />
{considerationIds.size > 0 && (
<div className="consideration-panel">
<div className="consideration-header">
<h3>📋 {he.considerationList} <span className="consideration-count">({considerationIds.size})</span></h3>
<button className="btn-consideration-toggle" onClick={() => setShowConsiderationPanel(p => !p)}>
{showConsiderationPanel ? '▲ הסתר' : '▼ הצג'}
</button>
</div>
{showConsiderationPanel && (
<div className="consideration-list">
{guests.filter(g => considerationIds.has(g.id)).map(guest => (
<div key={guest.id} className="consideration-item">
<div className="consideration-item-info">
<strong>{guest.first_name} {guest.last_name}</strong>
{guest.phone_number && <span className="consideration-phone">{guest.phone_number}</span>}
</div>
<div className="consideration-item-actions">
<button
className="btn-consideration-invite"
onClick={() => handleConsiderationDecision(guest.id, true)}
>
{he.inviteGuest}
</button>
<button
className="btn-consideration-decline"
onClick={() => handleConsiderationDecision(guest.id, false)}
>
{he.notInviteGuest}
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
<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">הכל ({sortedGuests.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' ? sortedGuests : sortedGuests.slice(0, itemsPerPage)).length && (itemsPerPage === 'all' ? sortedGuests : sortedGuests.slice(0, itemsPerPage)).length > 0}
onChange={toggleSelectAll}
title={he.selectAll}
/>
</th>
<th className="sortable-th" onClick={() => setSortOrder(o => {
const next = o === 'asc' ? 'desc' : o === 'desc' ? 'none' : 'asc'
localStorage.setItem(`guestSortOrder_${eventId}`, next)
return next
})} title={he.sortByName}>
{he.name} <span className="sort-icon">{sortOrder === 'asc' ? '↑' : sortOrder === 'desc' ? '↓' : '⇅'}</span>
</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' ? sortedGuests : sortedGuests.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 =>
guests.find(g => g.id === id)
).filter(Boolean)}
eventData={eventData}
onSend={handleSendWhatsApp}
/>
{/* Sticky action bar — always visible when guests are selected */}
{selectedGuestIds.size > 0 && (
<div className="sticky-action-bar">
<span className="sticky-selection-count">
{he.selectedCount.replace('{count}', selectedGuestIds.size)}
</span>
<div className="sticky-action-buttons">
<button className="btn-consideration" onClick={handleAddToConsideration}>
{he.addToConsideration}
</button>
<button className="btn-whatsapp" onClick={() => setShowWhatsAppModal(true)}>
💬 {he.sendWhatsApp}
</button>
<button className="btn-delete-selected" onClick={handleDeleteSelected}>
{he.deleteSelected}
</button>
</div>
</div>
)}
</div>
)
}
export default GuestList