Files
TREK/client/src/components/Vacay/VacayPersons.jsx
Maurice 384d583628 v2.5.0 — Addon System, Vacay, Atlas, Dashboard Widgets & Mobile Overhaul
The biggest NOMAD update yet. Introduces a modular addon architecture and three major new features.

Addon System:
- Admin panel addon management with enable/disable toggles
- Trip addons (Packing List, Budget, Documents) dynamically show/hide in trip tabs
- Global addons appear in the main navigation for all users

Vacay — Vacation Day Planner (Global Addon):
- Monthly calendar view with international public holidays (100+ countries via Nager.Date API)
- Company holidays with auto-cleanup of conflicting entries
- User-based system: each NOMAD user is a person in the calendar
- Fusion system: invite other users to share a combined calendar with real-time WebSocket sync
- Vacation entitlement tracking with automatic carry-over to next year
- Full settings: block weekends, public holidays, company holidays, carry-over toggle
- Invite/accept/decline flow with forced confirmation modal
- Color management per user with collision detection on fusion
- Dissolve fusion with preserved entries

Atlas — Travel World Map (Global Addon):
- Fullscreen Leaflet world map with colored country polygons (GeoJSON)
- Glass-effect bottom panel with stats, continent breakdown, streak tracking
- Country tooltips with trip count, places visited, first/last visit dates
- Liquid glass hover effect on the stats panel
- Canvas renderer with tile preloading for maximum performance
- Responsive: mobile stats bars, no zoom controls on touch

Dashboard Widgets:
- Currency converter with 50 currencies, CustomSelect dropdowns, localStorage persistence
- Timezone widget with customizable city list, live updating clock
- Per-user toggle via settings button, bottom sheet on mobile

Admin Panel:
- Consistent dark mode across all tabs (CSS variable overrides)
- Online/offline status badges on user list via WebSocket
- Unified heading sizes and subtitles across all sections
- Responsive tab grid on mobile

Mobile Improvements:
- Vacay: slide-in sidebar drawer, floating toolbar, responsive calendar grid
- Atlas: top/bottom glass stat bars, no popups
- Trip Planner: fixed position content container prevents overscroll, portal-based sidebar buttons
- Dashboard: fixed viewport container, mobile widget bottom sheet
- Admin: responsive tab grid, compact buttons
- Global: overscroll-behavior fixes, modal scroll containment

Other:
- Trip tab labels: Planung→Karte, Packliste→Liste, Buchungen→Buchung (DE mobile)
- Reservation form responsive layout
- Backup panel responsive buttons
2026-03-20 23:14:06 +01:00

191 lines
9.1 KiB
JavaScript

import React, { useState, useEffect } from 'react'
import ReactDOM from 'react-dom'
import { UserPlus, Unlink, Check, Loader2, Clock, X } from 'lucide-react'
import { useVacayStore } from '../../store/vacayStore'
import { useAuthStore } from '../../store/authStore'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect'
import apiClient from '../../api/client'
const PRESET_COLORS = [
'#6366f1', '#ec4899', '#14b8a6', '#8b5cf6', '#ef4444',
'#3b82f6', '#22c55e', '#06b6d4', '#f43f5e', '#a855f7',
'#10b981', '#0ea5e9', '#64748b', '#be185d', '#0d9488',
]
export default function VacayPersons() {
const { t } = useTranslation()
const toast = useToast()
const { users, pendingInvites, invite, cancelInvite, updateColor, selectedUserId, setSelectedUserId, isFused } = useVacayStore()
const { user: currentUser } = useAuthStore()
// Default selectedUserId to current user
useEffect(() => {
if (!selectedUserId && currentUser) setSelectedUserId(currentUser.id)
}, [currentUser, selectedUserId])
const [showInvite, setShowInvite] = useState(false)
const [showColorPicker, setShowColorPicker] = useState(false)
const [colorEditUserId, setColorEditUserId] = useState(null)
const [availableUsers, setAvailableUsers] = useState([])
const [selectedInviteUser, setSelectedInviteUser] = useState(null)
const [inviting, setInviting] = useState(false)
const loadAvailable = async () => {
try {
const data = await apiClient.get('/addons/vacay/available-users').then(r => r.data)
setAvailableUsers(data.users)
} catch { /* */ }
}
const handleInvite = async () => {
if (!selectedInviteUser) return
setInviting(true)
try {
await invite(selectedInviteUser)
toast.success(t('vacay.inviteSent'))
setShowInvite(false)
setSelectedInviteUser(null)
} catch (err) {
toast.error(err.response?.data?.error || t('vacay.inviteError'))
} finally {
setInviting(false)
}
}
const handleColorChange = async (color) => {
await updateColor(color, colorEditUserId)
setShowColorPicker(false)
setColorEditUserId(null)
}
const editingUserColor = users.find(u => u.id === colorEditUserId)?.color || '#6366f1'
return (
<div className="rounded-xl border p-3" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}>{t('vacay.persons')}</span>
<button onClick={() => { setShowInvite(true); loadAvailable() }}
className="p-0.5 rounded transition-colors" style={{ color: 'var(--text-faint)' }}>
<UserPlus size={14} />
</button>
</div>
<div className="flex flex-col gap-0.5">
{users.map(u => {
const isSelected = selectedUserId === u.id
return (
<div key={u.id}
onClick={() => { if (isFused) setSelectedUserId(u.id) }}
className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg group transition-all"
style={{
background: isSelected ? 'var(--bg-hover)' : 'transparent',
border: isSelected ? '1px solid var(--border-primary)' : '1px solid transparent',
cursor: isFused ? 'pointer' : 'default',
}}>
<button
onClick={(e) => { e.stopPropagation(); setColorEditUserId(u.id); setShowColorPicker(true) }}
className="w-3.5 h-3.5 rounded-full shrink-0 transition-transform hover:scale-125"
style={{ backgroundColor: u.color, cursor: 'pointer' }}
title={t('vacay.changeColor')}
/>
<span className="text-xs font-medium flex-1 truncate" style={{ color: 'var(--text-primary)' }}>
{u.username}
{u.id === currentUser?.id && <span style={{ color: 'var(--text-faint)' }}> ({t('vacay.you')})</span>}
</span>
{isSelected && isFused && (
<Check size={12} style={{ color: 'var(--text-primary)' }} />
)}
</div>
)
})}
{/* Pending invites */}
{pendingInvites.map(inv => (
<div key={inv.id} className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg group"
style={{ background: 'var(--bg-secondary)', opacity: 0.7 }}>
<Clock size={12} style={{ color: 'var(--text-faint)' }} />
<span className="text-xs flex-1 truncate" style={{ color: 'var(--text-muted)' }}>
{inv.username} <span className="text-[10px]">({t('vacay.pending')})</span>
</span>
<button onClick={() => cancelInvite(inv.user_id)}
className="opacity-0 group-hover:opacity-100 text-[10px] px-1.5 py-0.5 rounded transition-all"
style={{ color: 'var(--text-faint)' }}>
{t('common.cancel')}
</button>
</div>
))}
</div>
{/* Invite Modal — Portal to body to avoid z-index issues */}
{showInvite && ReactDOM.createPortal(
<div className="fixed inset-0 flex items-center justify-center px-4" style={{ zIndex: 99990, backgroundColor: 'rgba(15,23,42,0.5)', paddingTop: 70 }}
onClick={() => setShowInvite(false)}>
<div className="rounded-2xl shadow-2xl w-full max-w-sm" style={{ background: 'var(--bg-card)', animation: 'modalIn 0.2s ease-out' }}
onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-5" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
<h2 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('vacay.inviteUser')}</h2>
<button onClick={() => setShowInvite(false)} className="p-1.5 rounded-lg transition-colors" style={{ color: 'var(--text-faint)' }}>
<X size={16} />
</button>
</div>
<div className="p-5 space-y-4">
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>{t('vacay.inviteHint')}</p>
{availableUsers.length === 0 ? (
<p className="text-xs text-center py-4" style={{ color: 'var(--text-faint)' }}>{t('vacay.noUsersAvailable')}</p>
) : (
<CustomSelect
value={selectedInviteUser}
onChange={setSelectedInviteUser}
options={availableUsers.map(u => ({ value: u.id, label: `${u.username} (${u.email})` }))}
placeholder={t('vacay.selectUser')}
searchable
/>
)}
<div className="flex gap-3 justify-end pt-2">
<button onClick={() => setShowInvite(false)} className="px-4 py-2 text-sm rounded-lg"
style={{ color: 'var(--text-muted)', border: '1px solid var(--border-primary)' }}>
{t('common.cancel')}
</button>
<button onClick={handleInvite} disabled={!selectedInviteUser || inviting}
className="px-4 py-2 text-sm rounded-lg transition-colors flex items-center gap-1.5 disabled:opacity-40"
style={{ background: 'var(--text-primary)', color: 'var(--bg-card)' }}>
{inviting && <Loader2 size={13} className="animate-spin" />}
{t('vacay.sendInvite')}
</button>
</div>
</div>
</div>
</div>,
document.body
)}
{/* Color Picker Modal — Portal to body */}
{showColorPicker && ReactDOM.createPortal(
<div className="fixed inset-0 flex items-center justify-center px-4" style={{ zIndex: 99990, backgroundColor: 'rgba(15,23,42,0.5)', paddingTop: 70 }}
onClick={() => { setShowColorPicker(false); setColorEditUserId(null) }}>
<div className="rounded-2xl shadow-2xl w-full max-w-xs" style={{ background: 'var(--bg-card)', animation: 'modalIn 0.2s ease-out' }}
onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-5" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
<h2 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('vacay.changeColor')}</h2>
<button onClick={() => { setShowColorPicker(false); setColorEditUserId(null) }} className="p-1.5 rounded-lg transition-colors" style={{ color: 'var(--text-faint)' }}>
<X size={16} />
</button>
</div>
<div className="p-5">
<div className="flex flex-wrap gap-2 justify-center">
{PRESET_COLORS.map(c => (
<button key={c} onClick={() => handleColorChange(c)}
className={`w-8 h-8 rounded-full transition-all ${editingUserColor === c ? 'ring-2 ring-offset-2 scale-110' : 'hover:scale-110'}`}
style={{ backgroundColor: c }} />
))}
</div>
</div>
</div>
</div>,
document.body
)}
</div>
)
}