Add considering table
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
parent
b8d02c43f8
commit
65a4b82fe5
@ -557,3 +557,214 @@ td {
|
||||
min-width: unset;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Sortable column header ── */
|
||||
.sortable-th {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sortable-th:hover {
|
||||
background: var(--color-background);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.sort-icon {
|
||||
opacity: 0.6;
|
||||
font-size: 0.8em;
|
||||
margin-inline-start: 4px;
|
||||
}
|
||||
|
||||
/* ── Consideration panel ── */
|
||||
.consideration-panel {
|
||||
background: var(--color-background-secondary);
|
||||
border: 1px solid var(--color-warning, #e8a838);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.consideration-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(232, 168, 56, 0.1);
|
||||
border-bottom: 1px solid var(--color-warning, #e8a838);
|
||||
}
|
||||
|
||||
.consideration-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.consideration-count {
|
||||
color: var(--color-warning, #e8a838);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-consideration-toggle {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-warning, #e8a838);
|
||||
border-radius: 4px;
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn-consideration-toggle:hover {
|
||||
background: rgba(232, 168, 56, 0.15);
|
||||
}
|
||||
|
||||
.consideration-list {
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.consideration-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--color-background);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.consideration-item-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.consideration-phone {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.btn-remove-consideration {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-danger);
|
||||
border-radius: 4px;
|
||||
color: var(--color-danger);
|
||||
padding: 0.2rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn-remove-consideration:hover {
|
||||
background: var(--color-danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.consideration-item-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-consideration-invite,
|
||||
.btn-consideration-decline {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.btn-consideration-invite {
|
||||
background: var(--color-success, #38a169);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-consideration-invite:hover {
|
||||
opacity: 0.85;
|
||||
box-shadow: 0 2px 6px rgba(56, 161, 105, 0.4);
|
||||
}
|
||||
|
||||
.btn-consideration-decline {
|
||||
background: var(--color-danger, #e53e3e);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-consideration-decline:hover {
|
||||
opacity: 0.85;
|
||||
box-shadow: 0 2px 6px rgba(229, 62, 62, 0.4);
|
||||
}
|
||||
|
||||
/* ── Sticky action bar ── */
|
||||
.sticky-action-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
background: var(--color-background-secondary);
|
||||
border-top: 2px solid var(--color-primary);
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.2);
|
||||
padding: 0.75rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.sticky-selection-count {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sticky-action-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-consideration {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 38px;
|
||||
padding: 0 1rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.2s ease;
|
||||
background: var(--color-warning, #e8a838);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-consideration:hover {
|
||||
background: #cf8f20;
|
||||
box-shadow: 0 2px 8px rgba(232, 168, 56, 0.4);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sticky-action-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sticky-action-buttons {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.sticky-action-buttons > * {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,7 +49,13 @@ const he = {
|
||||
selectGuestsFirst: 'בחר אורחים לשליחת הזמנה',
|
||||
deleteSelected: '🗑️ מחק נבחרים',
|
||||
confirmDeleteSelected: 'האם אתה בטוח שברצונך למחוק {count} אורחים?',
|
||||
failedToDeleteSelected: 'נכשל במחיקת האורחים הנבחרים'
|
||||
failedToDeleteSelected: 'נכשל במחיקת האורחים הנבחרים',
|
||||
addToConsideration: '📋 הוסף לשיקול',
|
||||
considerationList: 'רשימת שיקול',
|
||||
removeFromConsideration: 'הסר',
|
||||
sortByName: 'מיין לפי שם',
|
||||
inviteGuest: '✅ מזמין',
|
||||
notInviteGuest: '❌ לא מזמין'
|
||||
}
|
||||
|
||||
function GuestList({ eventId, onBack, onShowMembers }) {
|
||||
@ -72,6 +78,12 @@ function GuestList({ eventId, onBack, onShowMembers }) {
|
||||
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()
|
||||
@ -171,6 +183,39 @@ function GuestList({ eventId, onBack, onShowMembers }) {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@ -201,10 +246,11 @@ function GuestList({ eventId, onBack, onShowMembers }) {
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedGuestIds.size === filteredGuests.length) {
|
||||
const paged = itemsPerPage === 'all' ? sortedGuests : sortedGuests.slice(0, itemsPerPage)
|
||||
if (selectedGuestIds.size === paged.length && paged.length > 0) {
|
||||
setSelectedGuestIds(new Set())
|
||||
} else {
|
||||
setSelectedGuestIds(new Set(filteredGuests.map(g => g.id)))
|
||||
setSelectedGuestIds(new Set(paged.map(g => g.id)))
|
||||
}
|
||||
}
|
||||
|
||||
@ -253,6 +299,16 @@ function GuestList({ eventId, onBack, onShowMembers }) {
|
||||
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,
|
||||
@ -344,7 +400,7 @@ function GuestList({ eventId, onBack, onShowMembers }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="guest-list-container">
|
||||
<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">
|
||||
@ -372,23 +428,6 @@ function GuestList({ eventId, onBack, onShowMembers }) {
|
||||
</button>
|
||||
</div>
|
||||
<div className="btn-group btn-group-primary">
|
||||
{selectedGuestIds.size > 0 && (
|
||||
<>
|
||||
<button
|
||||
className="btn-delete-selected"
|
||||
onClick={handleDeleteSelected}
|
||||
>
|
||||
{he.deleteSelected} ({selectedGuestIds.size})
|
||||
</button>
|
||||
<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)
|
||||
@ -420,16 +459,45 @@ function GuestList({ eventId, onBack, onShowMembers }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedGuestIds.size > 0 && (
|
||||
<div className="selection-bar">
|
||||
<span className="selection-text">
|
||||
{he.selectedCount.replace('{count}', selectedGuestIds.size)}
|
||||
</span>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<SearchFilter eventId={eventId} onSearch={setSearchFilters} />
|
||||
|
||||
<div className="pagination-controls">
|
||||
<label htmlFor="items-per-page">הצג אורחים:</label>
|
||||
<select
|
||||
@ -440,7 +508,7 @@ function GuestList({ eventId, onBack, onShowMembers }) {
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="all">הכל ({filteredGuests.length})</option>
|
||||
<option value="all">הכל ({sortedGuests.length})</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@ -470,12 +538,18 @@ function GuestList({ eventId, onBack, onShowMembers }) {
|
||||
<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}
|
||||
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>{he.name}</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>
|
||||
@ -485,7 +559,7 @@ function GuestList({ eventId, onBack, onShowMembers }) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(itemsPerPage === 'all' ? filteredGuests : filteredGuests.slice(0, itemsPerPage)).map(guest => (
|
||||
{(itemsPerPage === 'all' ? sortedGuests : sortedGuests.slice(0, itemsPerPage)).map(guest => (
|
||||
<tr key={guest.id} className={selectedGuestIds.has(guest.id) ? 'selected' : ''}>
|
||||
<td className="checkbox-cell">
|
||||
<input
|
||||
@ -545,11 +619,31 @@ function GuestList({ eventId, onBack, onShowMembers }) {
|
||||
isOpen={showWhatsAppModal}
|
||||
onClose={() => setShowWhatsAppModal(false)}
|
||||
selectedGuests={Array.from(selectedGuestIds).map(id =>
|
||||
filteredGuests.find(g => g.id === 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>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user