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
This commit is contained in:
Maurice
2026-03-20 23:14:06 +01:00
parent 3edf65957b
commit 384d583628
35 changed files with 3841 additions and 82 deletions
@@ -0,0 +1,158 @@
import React, { useEffect, useState } from 'react'
import { adminApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase } from 'lucide-react'
const ICON_MAP = {
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase,
}
function AddonIcon({ name, size = 20 }) {
const Icon = ICON_MAP[name] || Puzzle
return <Icon size={size} />
}
export default function AddonManager() {
const { t } = useTranslation()
const toast = useToast()
const [addons, setAddons] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
loadAddons()
}, [])
const loadAddons = async () => {
setLoading(true)
try {
const data = await adminApi.addons()
setAddons(data.addons)
} catch (err) {
toast.error(t('admin.addons.toast.error'))
} finally {
setLoading(false)
}
}
const handleToggle = async (addon) => {
const newEnabled = !addon.enabled
// Optimistic update
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a))
try {
await adminApi.updateAddon(addon.id, { enabled: newEnabled })
window.dispatchEvent(new Event('addons-changed'))
toast.success(t('admin.addons.toast.updated'))
} catch (err) {
// Rollback
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: !newEnabled } : a))
toast.error(t('admin.addons.toast.error'))
}
}
const tripAddons = addons.filter(a => a.type === 'trip')
const globalAddons = addons.filter(a => a.type === 'global')
if (loading) {
return (
<div className="p-8 text-center">
<div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto" style={{ borderTopColor: 'var(--text-primary)' }}></div>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="px-6 py-4 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.addons.title')}</h2>
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('admin.addons.subtitle')}</p>
</div>
{addons.length === 0 ? (
<div className="p-8 text-center text-sm" style={{ color: 'var(--text-faint)' }}>
{t('admin.addons.noAddons')}
</div>
) : (
<div>
{/* Trip Addons */}
{tripAddons.length > 0 && (
<div>
<div className="px-6 py-2.5 border-b flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}>
<Briefcase size={13} style={{ color: 'var(--text-muted)' }} />
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
{t('admin.addons.type.trip')} {t('admin.addons.tripHint')}
</span>
</div>
{tripAddons.map(addon => (
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
))}
</div>
)}
{/* Global Addons */}
{globalAddons.length > 0 && (
<div>
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}>
<Globe size={13} style={{ color: 'var(--text-muted)' }} />
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
{t('admin.addons.type.global')} {t('admin.addons.globalHint')}
</span>
</div>
{globalAddons.map(addon => (
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
))}
</div>
)}
</div>
)}
</div>
</div>
)
}
function AddonRow({ addon, onToggle, t }) {
return (
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95" style={{ borderColor: 'var(--border-secondary)' }}>
{/* Icon */}
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}>
<AddonIcon name={addon.icon} size={20} />
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{addon.name}</span>
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full" style={{
background: addon.type === 'global' ? 'var(--bg-secondary)' : 'var(--bg-secondary)',
color: 'var(--text-muted)',
}}>
{addon.type === 'global' ? t('admin.addons.type.global') : t('admin.addons.type.trip')}
</span>
</div>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{addon.description}</p>
</div>
{/* Toggle */}
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs font-medium" style={{ color: addon.enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
{addon.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button
onClick={() => onToggle(addon)}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: addon.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span
className="inline-block h-4 w-4 transform rounded-full transition-transform"
style={{
background: addon.enabled ? 'var(--bg-card)' : 'var(--bg-card)',
transform: addon.enabled ? 'translateX(22px)' : 'translateX(4px)',
}}
/>
</button>
</div>
</div>
)
}
+13 -11
View File
@@ -153,8 +153,8 @@ export default function BackupPanel() {
<div className="flex items-center gap-3">
<HardDrive className="w-5 h-5 text-gray-400" />
<div>
<h2 className="text-lg font-semibold text-gray-900">{t('backup.title')}</h2>
<p className="text-sm text-gray-500 mt-0.5">{t('backup.subtitle')}</p>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('backup.title')}</h2>
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('backup.subtitle')}</p>
</div>
</div>
<div className="flex items-center gap-2">
@@ -179,26 +179,28 @@ export default function BackupPanel() {
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="flex items-center gap-2 border border-gray-200 text-gray-700 px-3 py-2 rounded-lg hover:bg-gray-50 text-sm font-medium disabled:opacity-60"
title={isUploading ? t('backup.uploading') : t('backup.upload')}
>
{isUploading ? (
<div className="w-4 h-4 border-2 border-gray-400 border-t-transparent rounded-full animate-spin" />
) : (
<Upload className="w-4 h-4" />
)}
{isUploading ? t('backup.uploading') : t('backup.upload')}
<span className="hidden sm:inline">{isUploading ? t('backup.uploading') : t('backup.upload')}</span>
</button>
<button
onClick={handleCreate}
disabled={isCreating}
className="flex items-center gap-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 px-4 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-60"
className="flex items-center gap-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-60"
title={isCreating ? t('backup.creating') : t('backup.create')}
>
{isCreating ? (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<Plus className="w-4 h-4" />
)}
{isCreating ? t('backup.creating') : t('backup.create')}
<span className="hidden sm:inline">{isCreating ? t('backup.creating') : t('backup.create')}</span>
</button>
</div>
</div>
@@ -275,23 +277,23 @@ export default function BackupPanel() {
<div className="flex items-center gap-3 mb-6">
<Clock className="w-5 h-5 text-gray-400" />
<div>
<h2 className="text-lg font-semibold text-gray-900">{t('backup.auto.title')}</h2>
<p className="text-sm text-gray-500 mt-0.5">{t('backup.auto.subtitle')}</p>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('backup.auto.title')}</h2>
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('backup.auto.subtitle')}</p>
</div>
</div>
<div className="flex flex-col gap-5">
{/* Enable toggle */}
<label className="flex items-center justify-between cursor-pointer">
<div>
<label className="flex items-center justify-between gap-4 cursor-pointer">
<div className="min-w-0">
<span className="text-sm font-medium text-gray-900">{t('backup.auto.enable')}</span>
<p className="text-xs text-gray-500 mt-0.5">{t('backup.auto.enableHint')}</p>
</div>
<button
onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${autoSettings.enabled ? 'bg-slate-900 dark:bg-slate-100' : 'bg-gray-200 dark:bg-gray-600'}`}
className={`relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors ${autoSettings.enabled ? 'bg-slate-900 dark:bg-slate-100' : 'bg-gray-200 dark:bg-gray-600'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${autoSettings.enabled ? 'translate-x-6' : 'translate-x-1'}`} />
<span className={`absolute left-1 h-4 w-4 rounded-full bg-white shadow transition-transform duration-200 ${autoSettings.enabled ? 'translate-x-5' : 'translate-x-0'}`} />
</button>
</label>
@@ -190,13 +190,14 @@ export default function CategoryManager() {
<div className="bg-white rounded-2xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-gray-900">{t('categories.title')}</h2>
<p className="text-sm text-gray-500 mt-0.5">{t('categories.subtitle')}</p>
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('categories.title')}</h2>
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('categories.subtitle')}</p>
</div>
<button onClick={handleStartCreate}
className="flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
className="flex items-center gap-2 bg-slate-900 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
<Plus className="w-4 h-4" />
{t('categories.new')}
<span className="hidden sm:inline">{t('categories.new')}</span>
<span className="sm:hidden">Add</span>
</button>
</div>
@@ -0,0 +1,89 @@
import React, { useState, useEffect, useCallback } from 'react'
import { ArrowRightLeft, RefreshCw } from 'lucide-react'
import { useTranslation } from '../../i18n'
import CustomSelect from '../shared/CustomSelect'
const CURRENCIES = [
'EUR','USD','GBP','JPY','CHF','CAD','AUD','NZD','CNY','HKD',
'SGD','THB','TRY','SEK','NOK','DKK','PLN','CZK','HUF','RON',
'BGN','HRK','ISK','RUB','UAH','BRL','MXN','ARS','CLP','COP',
'INR','IDR','MYR','PHP','KRW','TWD','VND','ZAR','EGP','MAD',
'NGN','KES','AED','SAR','QAR','KWD','BHD','OMR','ILS',
]
const CURRENCY_OPTIONS = CURRENCIES.map(c => ({ value: c, label: c }))
export default function CurrencyWidget() {
const { t } = useTranslation()
const [from, setFrom] = useState(() => localStorage.getItem('currency_from') || 'EUR')
const [to, setTo] = useState(() => localStorage.getItem('currency_to') || 'USD')
const [amount, setAmount] = useState('100')
const [rate, setRate] = useState(null)
const [loading, setLoading] = useState(false)
const fetchRate = useCallback(async () => {
if (from === to) { setRate(1); return }
setLoading(true)
try {
const resp = await fetch(`https://api.exchangerate-api.com/v4/latest/${from}`)
const data = await resp.json()
setRate(data.rates?.[to] || null)
} catch { setRate(null) }
finally { setLoading(false) }
}, [from, to])
useEffect(() => { fetchRate() }, [fetchRate])
useEffect(() => { localStorage.setItem('currency_from', from) }, [from])
useEffect(() => { localStorage.setItem('currency_to', to) }, [to])
const swap = () => { setFrom(to); setTo(from) }
const rawResult = rate && amount ? (parseFloat(amount) * rate).toFixed(2) : null
const formatNumber = (num) => {
if (!num || num === '—') return '—'
return parseFloat(num).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
const result = rawResult
return (
<div className="rounded-2xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{t('dashboard.currency')}</span>
<button onClick={fetchRate} className="p-1 rounded-md transition-colors" style={{ color: 'var(--text-faint)' }}>
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} />
</button>
</div>
{/* Amount */}
<div className="rounded-xl px-4 py-3 mb-3" style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)' }}>
<input
type="number"
value={amount}
onChange={e => setAmount(e.target.value)}
className="w-full text-2xl font-black tabular-nums outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
style={{ color: 'var(--text-primary)', background: 'transparent', border: 'none' }}
/>
</div>
{/* From / Swap / To */}
<div className="flex items-center gap-2 mb-3">
<div className="flex-1" style={{ '--bg-input': 'transparent', '--border-primary': 'transparent' }}>
<CustomSelect value={from} onChange={setFrom} options={CURRENCY_OPTIONS} searchable size="sm" />
</div>
<button onClick={swap} className="p-1.5 rounded-lg shrink-0 transition-colors" style={{ color: 'var(--text-muted)' }}>
<ArrowRightLeft size={13} />
</button>
<div className="flex-1" style={{ '--bg-input': 'transparent', '--border-primary': 'transparent' }}>
<CustomSelect value={to} onChange={setTo} options={CURRENCY_OPTIONS} searchable size="sm" />
</div>
</div>
{/* Result */}
<div className="rounded-xl p-3" style={{ background: 'var(--bg-secondary)' }}>
<p className="text-xl font-black tabular-nums" style={{ color: 'var(--text-primary)' }}>
{formatNumber(result)} <span className="text-sm font-semibold" style={{ color: 'var(--text-muted)' }}>{to}</span>
</p>
{rate && <p className="text-[10px] mt-0.5" style={{ color: 'var(--text-faint)' }}>1 {from} = {rate.toFixed(4)} {to}</p>}
</div>
</div>
)
}
@@ -0,0 +1,126 @@
import React, { useState, useEffect } from 'react'
import { Clock, Plus, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
const POPULAR_ZONES = [
{ label: 'New York', tz: 'America/New_York' },
{ label: 'London', tz: 'Europe/London' },
{ label: 'Berlin', tz: 'Europe/Berlin' },
{ label: 'Paris', tz: 'Europe/Paris' },
{ label: 'Dubai', tz: 'Asia/Dubai' },
{ label: 'Mumbai', tz: 'Asia/Kolkata' },
{ label: 'Bangkok', tz: 'Asia/Bangkok' },
{ label: 'Tokyo', tz: 'Asia/Tokyo' },
{ label: 'Sydney', tz: 'Australia/Sydney' },
{ label: 'Los Angeles', tz: 'America/Los_Angeles' },
{ label: 'Chicago', tz: 'America/Chicago' },
{ label: 'São Paulo', tz: 'America/Sao_Paulo' },
{ label: 'Istanbul', tz: 'Europe/Istanbul' },
{ label: 'Singapore', tz: 'Asia/Singapore' },
{ label: 'Hong Kong', tz: 'Asia/Hong_Kong' },
{ label: 'Seoul', tz: 'Asia/Seoul' },
{ label: 'Moscow', tz: 'Europe/Moscow' },
{ label: 'Cairo', tz: 'Africa/Cairo' },
]
function getTime(tz) {
try {
return new Date().toLocaleTimeString('de-DE', { timeZone: tz, hour: '2-digit', minute: '2-digit' })
} catch { return '—' }
}
function getOffset(tz) {
try {
const now = new Date()
const local = new Date(now.toLocaleString('en-US', { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }))
const remote = new Date(now.toLocaleString('en-US', { timeZone: tz }))
const diff = (remote - local) / 3600000
const sign = diff >= 0 ? '+' : ''
return `${sign}${diff}h`
} catch { return '' }
}
export default function TimezoneWidget() {
const { t } = useTranslation()
const [zones, setZones] = useState(() => {
const saved = localStorage.getItem('dashboard_timezones')
return saved ? JSON.parse(saved) : [
{ label: 'New York', tz: 'America/New_York' },
{ label: 'Tokyo', tz: 'Asia/Tokyo' },
]
})
const [now, setNow] = useState(Date.now())
const [showAdd, setShowAdd] = useState(false)
useEffect(() => {
const i = setInterval(() => setNow(Date.now()), 10000)
return () => clearInterval(i)
}, [])
useEffect(() => {
localStorage.setItem('dashboard_timezones', JSON.stringify(zones))
}, [zones])
const addZone = (zone) => {
if (!zones.find(z => z.tz === zone.tz)) {
setZones([...zones, zone])
}
setShowAdd(false)
}
const removeZone = (tz) => setZones(zones.filter(z => z.tz !== tz))
const localTime = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
const rawZone = Intl.DateTimeFormat().resolvedOptions().timeZone
const localZone = rawZone.split('/').pop().replace(/_/g, ' ')
// Show abbreviated timezone name (e.g. CET, CEST, EST)
const tzAbbr = new Date().toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop()
return (
<div className="rounded-2xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{t('dashboard.timezone')}</span>
<button onClick={() => setShowAdd(!showAdd)} className="p-1 rounded-md transition-colors" style={{ color: 'var(--text-faint)' }}>
<Plus size={12} />
</button>
</div>
{/* Local time */}
<div className="mb-3 pb-3" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
<p className="text-2xl font-black tabular-nums" style={{ color: 'var(--text-primary)' }}>{localTime}</p>
<p className="text-[10px] font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{localZone} ({tzAbbr}) · {t('dashboard.localTime')}</p>
</div>
{/* Zone list */}
<div className="space-y-2">
{zones.map(z => (
<div key={z.tz} className="flex items-center justify-between group">
<div>
<p className="text-lg font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{getTime(z.tz)}</p>
<p className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{z.label} <span style={{ color: 'var(--text-muted)' }}>{getOffset(z.tz)}</span></p>
</div>
<button onClick={() => removeZone(z.tz)} className="opacity-0 group-hover:opacity-100 p-1 rounded transition-all" style={{ color: 'var(--text-faint)' }}>
<X size={11} />
</button>
</div>
))}
</div>
{/* Add zone dropdown */}
{showAdd && (
<div className="mt-2 rounded-xl p-2 max-h-[200px] overflow-auto" style={{ background: 'var(--bg-secondary)' }}>
{POPULAR_ZONES.filter(z => !zones.find(existing => existing.tz === z.tz)).map(z => (
<button key={z.tz} onClick={() => addZone(z)}
className="w-full flex items-center justify-between px-2 py-1.5 rounded-lg text-xs text-left transition-colors"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<span className="font-medium">{z.label}</span>
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{getTime(z.tz)}</span>
</button>
))}
</div>
)}
</div>
)
}
+59 -2
View File
@@ -1,19 +1,39 @@
import React, { useState, useEffect } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Link, useNavigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun } from 'lucide-react'
import { addonsApi } from '../../api/client'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, CalendarDays, Briefcase, Globe } from 'lucide-react'
const ADDON_ICONS = { CalendarDays, Briefcase, Globe }
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }) {
const { user, logout } = useAuthStore()
const { settings, updateSetting } = useSettingsStore()
const { t, locale } = useTranslation()
const navigate = useNavigate()
const location = useLocation()
const [userMenuOpen, setUserMenuOpen] = useState(false)
const [appVersion, setAppVersion] = useState(null)
const [globalAddons, setGlobalAddons] = useState([])
const dark = settings.dark_mode
const loadAddons = () => {
if (user) {
addonsApi.enabled().then(data => {
setGlobalAddons(data.addons.filter(a => a.type === 'global'))
}).catch(() => {})
}
}
useEffect(loadAddons, [user, location.pathname])
// Listen for addon changes from AddonManager
useEffect(() => {
const handler = () => loadAddons()
window.addEventListener('addons-changed', handler)
return () => window.removeEventListener('addons-changed', handler)
}, [user])
useEffect(() => {
import('../../api/client').then(({ authApi }) => {
authApi.getAppConfig?.().then(c => setAppVersion(c?.version)).catch(() => {})
@@ -35,6 +55,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
borderBottom: `1px solid ${dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)'}`,
boxShadow: dark ? '0 1px 12px rgba(0,0,0,0.2)' : '0 1px 12px rgba(0,0,0,0.05)',
touchAction: 'manipulation',
}} className="h-14 flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
{/* Left side */}
<div className="flex items-center gap-3 min-w-0">
@@ -55,6 +76,42 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
<span className="font-bold text-sm hidden sm:inline">NOMAD</span>
</Link>
{/* Global addon nav items */}
{globalAddons.length > 0 && !tripTitle && (
<>
<span style={{ color: 'var(--text-faint)' }}>|</span>
<Link to="/dashboard"
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors flex-shrink-0"
style={{
color: location.pathname === '/dashboard' ? 'var(--text-primary)' : 'var(--text-muted)',
background: location.pathname === '/dashboard' ? 'var(--bg-hover)' : 'transparent',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => { if (location.pathname !== '/dashboard') e.currentTarget.style.background = 'transparent' }}>
<Briefcase className="w-3.5 h-3.5" />
<span className="hidden md:inline">{t('nav.myTrips')}</span>
</Link>
{globalAddons.map(addon => {
const Icon = ADDON_ICONS[addon.icon] || CalendarDays
const path = `/${addon.id}`
const isActive = location.pathname === path
return (
<Link key={addon.id} to={path}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors flex-shrink-0"
style={{
color: isActive ? 'var(--text-primary)' : 'var(--text-muted)',
background: isActive ? 'var(--bg-hover)' : 'transparent',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent' }}>
<Icon className="w-3.5 h-3.5" />
<span className="hidden md:inline">{addon.name}</span>
</Link>
)
})}
</>
)}
{tripTitle && (
<>
<span className="hidden sm:inline" style={{ color: 'var(--text-faint)' }}>/</span>
@@ -300,9 +300,9 @@ export default function PlaceFormModal({
{/* Reservation */}
<div className="border border-gray-200 rounded-xl p-3 space-y-3">
<div className="flex items-center gap-3">
<label className="block text-sm font-medium text-gray-700">{t('places.formReservation')}</label>
<div className="flex gap-2">
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3">
<label className="block text-sm font-medium text-gray-700 shrink-0">{t('places.formReservation')}</label>
<div className="flex gap-2 flex-wrap">
{['none', 'pending', 'confirmed'].map(status => (
<button
key={status}
@@ -0,0 +1,96 @@
import React, { useMemo, useState, useCallback } from 'react'
import { useVacayStore } from '../../store/vacayStore'
import { useTranslation } from '../../i18n'
import { isWeekend } from './holidays'
import VacayMonthCard from './VacayMonthCard'
import { Building2, MousePointer2 } from 'lucide-react'
export default function VacayCalendar() {
const { t } = useTranslation()
const { selectedYear, selectedUserId, entries, companyHolidays, toggleEntry, toggleCompanyHoliday, plan, users, holidays } = useVacayStore()
const [companyMode, setCompanyMode] = useState(false)
const companyHolidaySet = useMemo(() => {
const s = new Set()
companyHolidays.forEach(h => s.add(h.date))
return s
}, [companyHolidays])
const entryMap = useMemo(() => {
const map = {}
entries.forEach(e => {
if (!map[e.date]) map[e.date] = []
map[e.date].push(e)
})
return map
}, [entries])
const blockWeekends = plan?.block_weekends !== false
const companyHolidaysEnabled = plan?.company_holidays_enabled !== false
const handleCellClick = useCallback(async (dateStr) => {
if (companyMode) {
if (!companyHolidaysEnabled) return
await toggleCompanyHoliday(dateStr)
return
}
if (holidays[dateStr]) return
if (blockWeekends && isWeekend(dateStr)) return
if (companyHolidaysEnabled && companyHolidaySet.has(dateStr)) return
await toggleEntry(dateStr, selectedUserId || undefined)
}, [companyMode, toggleEntry, toggleCompanyHoliday, holidays, companyHolidaySet, blockWeekends, companyHolidaysEnabled, selectedUserId])
const selectedUser = users.find(u => u.id === selectedUserId)
return (
<div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{Array.from({ length: 12 }, (_, i) => (
<VacayMonthCard
key={i}
year={selectedYear}
month={i}
holidays={holidays}
companyHolidaySet={companyHolidaySet}
companyHolidaysEnabled={companyHolidaysEnabled}
entryMap={entryMap}
onCellClick={handleCellClick}
companyMode={companyMode}
blockWeekends={blockWeekends}
/>
))}
</div>
{/* Floating toolbar */}
<div className="sticky bottom-3 sm:bottom-4 mt-3 sm:mt-4 flex items-center justify-center z-30 px-2">
<div className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 rounded-xl border" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', boxShadow: '0 8px 32px rgba(0,0,0,0.12)' }}>
<button
onClick={() => setCompanyMode(false)}
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-lg text-[11px] sm:text-xs font-medium transition-all"
style={{
background: !companyMode ? 'var(--text-primary)' : 'transparent',
color: !companyMode ? 'var(--bg-card)' : 'var(--text-muted)',
border: companyMode ? '1px solid var(--border-primary)' : '1px solid transparent',
}}>
<MousePointer2 size={13} />
{selectedUser && <span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: selectedUser.color }} />}
{selectedUser ? selectedUser.username : t('vacay.modeVacation')}
</button>
{companyHolidaysEnabled && (
<button
onClick={() => setCompanyMode(true)}
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-lg text-[11px] sm:text-xs font-medium transition-all"
style={{
background: companyMode ? '#d97706' : 'transparent',
color: companyMode ? '#fff' : 'var(--text-muted)',
border: !companyMode ? '1px solid var(--border-primary)' : '1px solid transparent',
}}>
<Building2 size={13} />
{t('vacay.modeCompany')}
</button>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,118 @@
import React, { useMemo } from 'react'
import { useTranslation } from '../../i18n'
import { isWeekend } from './holidays'
const WEEKDAYS_EN = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
export default function VacayMonthCard({
year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap,
onCellClick, companyMode, blockWeekends
}) {
const { language } = useTranslation()
const weekdays = language === 'de' ? WEEKDAYS_DE : WEEKDAYS_EN
const monthNames = language === 'de' ? MONTHS_DE : MONTHS_EN
const weeks = useMemo(() => {
const firstDay = new Date(year, month, 1)
const daysInMonth = new Date(year, month + 1, 0).getDate()
let startDow = firstDay.getDay() - 1
if (startDow < 0) startDow = 6
const cells = []
for (let i = 0; i < startDow; i++) cells.push(null)
for (let d = 1; d <= daysInMonth; d++) cells.push(d)
while (cells.length % 7 !== 0) cells.push(null)
const w = []
for (let i = 0; i < cells.length; i += 7) w.push(cells.slice(i, i + 7))
return w
}, [year, month])
const pad = (n) => String(n).padStart(2, '0')
return (
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="px-3 py-2 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
<span className="text-xs font-semibold" style={{ color: 'var(--text-primary)' }}>{monthNames[month]}</span>
</div>
<div className="grid grid-cols-7 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
{weekdays.map((wd, i) => (
<div key={wd} className="text-center text-[10px] font-medium py-1" style={{ color: i >= 5 ? 'var(--text-faint)' : 'var(--text-muted)' }}>
{wd}
</div>
))}
</div>
<div>
{weeks.map((week, wi) => (
<div key={wi} className="grid grid-cols-7">
{week.map((day, di) => {
if (day === null) return <div key={di} style={{ height: 28 }} />
const dateStr = `${year}-${pad(month + 1)}-${pad(day)}`
const weekend = di >= 5
const holiday = holidays[dateStr]
const isCompany = companyHolidaysEnabled && companyHolidaySet.has(dateStr)
const dayEntries = entryMap[dateStr] || []
const isBlocked = !!holiday || (weekend && blockWeekends) || (isCompany && !companyMode)
return (
<div
key={di}
className="relative flex items-center justify-center cursor-pointer transition-colors"
style={{
height: 28,
background: weekend ? 'var(--bg-secondary)' : 'transparent',
borderTop: '1px solid var(--border-secondary)',
borderRight: '1px solid var(--border-secondary)',
cursor: isBlocked ? 'default' : 'pointer',
}}
onClick={() => onCellClick(dateStr)}
onMouseEnter={e => { if (!isBlocked) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = weekend ? 'var(--bg-secondary)' : 'transparent' }}
>
{holiday && <div className="absolute inset-0.5 rounded" style={{ background: 'rgba(239,68,68,0.12)' }} />}
{isCompany && <div className="absolute inset-0.5 rounded" style={{ background: 'rgba(245,158,11,0.15)' }} />}
{dayEntries.length === 1 && (
<div className="absolute inset-0.5 rounded" style={{ backgroundColor: dayEntries[0].person_color, opacity: 0.4 }} />
)}
{dayEntries.length === 2 && (
<div className="absolute inset-0.5 rounded" style={{
background: `linear-gradient(135deg, ${dayEntries[0].person_color} 50%, ${dayEntries[1].person_color} 50%)`,
opacity: 0.4,
}} />
)}
{dayEntries.length === 3 && (
<div className="absolute inset-0.5 rounded overflow-hidden" style={{ opacity: 0.4 }}>
<div className="absolute top-0 left-0 w-1/2 h-full" style={{ backgroundColor: dayEntries[0].person_color }} />
<div className="absolute top-0 right-0 w-1/2 h-1/2" style={{ backgroundColor: dayEntries[1].person_color }} />
<div className="absolute bottom-0 right-0 w-1/2 h-1/2" style={{ backgroundColor: dayEntries[2].person_color }} />
</div>
)}
{dayEntries.length >= 4 && (
<div className="absolute inset-0.5 rounded overflow-hidden" style={{ opacity: 0.4 }}>
<div className="absolute top-0 left-0 w-1/2 h-1/2" style={{ backgroundColor: dayEntries[0].person_color }} />
<div className="absolute top-0 right-0 w-1/2 h-1/2" style={{ backgroundColor: dayEntries[1].person_color }} />
<div className="absolute bottom-0 left-0 w-1/2 h-1/2" style={{ backgroundColor: dayEntries[2].person_color }} />
<div className="absolute bottom-0 right-0 w-1/2 h-1/2" style={{ backgroundColor: dayEntries[3].person_color }} />
</div>
)}
<span className="relative z-[1] text-[11px] font-medium" style={{
color: holiday ? '#dc2626' : weekend ? 'var(--text-faint)' : 'var(--text-primary)',
fontWeight: dayEntries.length > 0 ? 700 : 500,
}}>
{day}
</span>
</div>
)
})}
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,190 @@
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>
)
}
@@ -0,0 +1,213 @@
import React, { useState, useEffect } from 'react'
import { MapPin, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe } from 'lucide-react'
import { useVacayStore } from '../../store/vacayStore'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect'
import apiClient from '../../api/client'
export default function VacaySettings({ onClose }) {
const { t } = useTranslation()
const toast = useToast()
const { plan, updatePlan, isFused, dissolve, users } = useVacayStore()
const [countries, setCountries] = useState([])
const [regions, setRegions] = useState([])
const [loadingRegions, setLoadingRegions] = useState(false)
const { language } = useTranslation()
// Load available countries with localized names
useEffect(() => {
apiClient.get('/addons/vacay/holidays/countries').then(r => {
let displayNames
try { displayNames = new Intl.DisplayNames([language === 'de' ? 'de' : 'en'], { type: 'region' }) } catch { /* */ }
const list = r.data.map(c => ({
value: c.countryCode,
label: displayNames ? (displayNames.of(c.countryCode) || c.name) : c.name,
}))
list.sort((a, b) => a.label.localeCompare(b.label))
setCountries(list)
}).catch(() => {})
}, [language])
// When country changes, check if it has regions
const selectedCountry = plan?.holidays_region?.split('-')[0] || ''
const selectedRegion = plan?.holidays_region?.includes('-') ? plan.holidays_region : ''
useEffect(() => {
if (!selectedCountry || !plan?.holidays_enabled) { setRegions([]); return }
setLoadingRegions(true)
const year = new Date().getFullYear()
apiClient.get(`/addons/vacay/holidays/${year}/${selectedCountry}`).then(r => {
const allCounties = new Set()
r.data.forEach(h => {
if (h.counties) h.counties.forEach(c => allCounties.add(c))
})
if (allCounties.size > 0) {
let subdivisionNames
try { subdivisionNames = new Intl.DisplayNames([language === 'de' ? 'de' : 'en'], { type: 'region' }) } catch { /* */ }
const regionList = [...allCounties].sort().map(c => {
let label = c.split('-')[1] || c
// Try Intl for full subdivision name (not all browsers support subdivision codes)
// Fallback: use known mappings for DE
if (c.startsWith('DE-')) {
const deRegions = { BW:'Baden-Württemberg',BY:'Bayern',BE:'Berlin',BB:'Brandenburg',HB:'Bremen',HH:'Hamburg',HE:'Hessen',MV:'Mecklenburg-Vorpommern',NI:'Niedersachsen',NW:'Nordrhein-Westfalen',RP:'Rheinland-Pfalz',SL:'Saarland',SN:'Sachsen',ST:'Sachsen-Anhalt',SH:'Schleswig-Holstein',TH:'Thüringen' }
label = deRegions[c.split('-')[1]] || label
} else if (c.startsWith('CH-')) {
const chRegions = { AG:'Aargau',AI:'Appenzell Innerrhoden',AR:'Appenzell Ausserrhoden',BE:'Bern',BL:'Basel-Landschaft',BS:'Basel-Stadt',FR:'Freiburg',GE:'Genf',GL:'Glarus',GR:'Graubünden',JU:'Jura',LU:'Luzern',NE:'Neuenburg',NW:'Nidwalden',OW:'Obwalden',SG:'St. Gallen',SH:'Schaffhausen',SO:'Solothurn',SZ:'Schwyz',TG:'Thurgau',TI:'Tessin',UR:'Uri',VD:'Waadt',VS:'Wallis',ZG:'Zug',ZH:'Zürich' }
label = chRegions[c.split('-')[1]] || label
}
return { value: c, label }
})
setRegions(regionList)
} else {
setRegions([])
// If no regions, just set country code as region
if (plan.holidays_region !== selectedCountry) {
updatePlan({ holidays_region: selectedCountry })
}
}
}).catch(() => setRegions([])).finally(() => setLoadingRegions(false))
}, [selectedCountry, plan?.holidays_enabled])
if (!plan) return null
const toggle = (key) => updatePlan({ [key]: !plan[key] })
const handleCountryChange = (countryCode) => {
updatePlan({ holidays_region: countryCode })
}
const handleRegionChange = (regionCode) => {
updatePlan({ holidays_region: regionCode })
}
return (
<div className="space-y-5">
{/* Block weekends */}
<SettingToggle
icon={CalendarOff}
label={t('vacay.blockWeekends')}
hint={t('vacay.blockWeekendsHint')}
value={plan.block_weekends}
onChange={() => toggle('block_weekends')}
/>
{/* Carry-over */}
<SettingToggle
icon={ArrowRightLeft}
label={t('vacay.carryOver')}
hint={t('vacay.carryOverHint')}
value={plan.carry_over_enabled}
onChange={() => toggle('carry_over_enabled')}
/>
{/* Company holidays */}
<div>
<SettingToggle
icon={Building2}
label={t('vacay.companyHolidays')}
hint={t('vacay.companyHolidaysHint')}
value={plan.company_holidays_enabled}
onChange={() => toggle('company_holidays_enabled')}
/>
{plan.company_holidays_enabled && (
<div className="ml-7 mt-2">
<div className="flex items-center gap-1.5 px-2 py-1.5 rounded-md" style={{ background: 'var(--bg-secondary)' }}>
<AlertCircle size={12} style={{ color: 'var(--text-faint)' }} />
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{t('vacay.companyHolidaysNoDeduct')}</span>
</div>
</div>
)}
</div>
{/* Public holidays */}
<div>
<SettingToggle
icon={Globe}
label={t('vacay.publicHolidays')}
hint={t('vacay.publicHolidaysHint')}
value={plan.holidays_enabled}
onChange={() => toggle('holidays_enabled')}
/>
{plan.holidays_enabled && (
<div className="ml-7 mt-2 space-y-2">
<CustomSelect
value={selectedCountry}
onChange={handleCountryChange}
options={countries}
placeholder={t('vacay.selectCountry')}
searchable
/>
{regions.length > 0 && (
<CustomSelect
value={selectedRegion}
onChange={handleRegionChange}
options={regions}
placeholder={t('vacay.selectRegion')}
searchable
/>
)}
</div>
)}
</div>
{/* Dissolve fusion */}
{isFused && (
<div className="pt-4 mt-2 border-t" style={{ borderColor: 'var(--border-secondary)' }}>
<div className="rounded-xl overflow-hidden" style={{ border: '1px solid rgba(239,68,68,0.2)' }}>
<div className="px-4 py-3 flex items-center gap-3" style={{ background: 'rgba(239,68,68,0.06)' }}>
<div className="w-8 h-8 rounded-lg flex items-center justify-center" style={{ background: 'rgba(239,68,68,0.1)' }}>
<Unlink size={16} className="text-red-500" />
</div>
<div>
<p className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('vacay.dissolve')}</p>
<p className="text-[11px]" style={{ color: 'var(--text-faint)' }}>{t('vacay.dissolveHint')}</p>
</div>
</div>
<div className="px-4 py-3 flex items-center gap-2 flex-wrap" style={{ borderTop: '1px solid rgba(239,68,68,0.1)' }}>
{users.map(u => (
<div key={u.id} className="flex items-center gap-1.5 px-2 py-1 rounded-md" style={{ background: 'var(--bg-secondary)' }}>
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: u.color || '#6366f1' }} />
<span className="text-xs font-medium" style={{ color: 'var(--text-primary)' }}>{u.username}</span>
</div>
))}
</div>
<div className="px-4 py-3" style={{ borderTop: '1px solid rgba(239,68,68,0.1)' }}>
<button
onClick={async () => {
await dissolve()
toast.success(t('vacay.dissolved'))
onClose()
}}
className="w-full px-3 py-2 text-xs font-medium bg-red-500 hover:bg-red-600 text-white rounded-lg transition-colors"
>
{t('vacay.dissolveAction')}
</button>
</div>
</div>
</div>
)}
</div>
)
}
function SettingToggle({ icon: Icon, label, hint, value, onChange }) {
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<Icon size={15} className="shrink-0" style={{ color: 'var(--text-muted)' }} />
<div className="min-w-0">
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p>
<p className="text-[11px]" style={{ color: 'var(--text-faint)' }}>{hint}</p>
</div>
</div>
<button onClick={onChange}
className="relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: value ? 'var(--text-primary)' : 'var(--border-primary)' }}>
<span className="absolute left-1 h-4 w-4 rounded-full transition-transform duration-200"
style={{ background: 'var(--bg-card)', transform: value ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
)
}
+124
View File
@@ -0,0 +1,124 @@
import React, { useEffect, useState } from 'react'
import { Briefcase, Pencil } from 'lucide-react'
import { useVacayStore } from '../../store/vacayStore'
import { useAuthStore } from '../../store/authStore'
import { useTranslation } from '../../i18n'
export default function VacayStats() {
const { t } = useTranslation()
const { stats, selectedYear, loadStats, updateVacationDays, isFused } = useVacayStore()
const { user: currentUser } = useAuthStore()
useEffect(() => { loadStats(selectedYear) }, [selectedYear])
return (
<div className="rounded-xl border p-3" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="flex items-center gap-1.5 mb-3">
<Briefcase size={13} style={{ color: 'var(--text-faint)' }} />
<span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}>
{t('vacay.entitlement')} {selectedYear}
</span>
</div>
{stats.length === 0 ? (
<p className="text-[11px] text-center py-3" style={{ color: 'var(--text-faint)' }}>{t('vacay.noData')}</p>
) : (
<div className="space-y-2">
{stats.map(s => (
<StatCard
key={s.user_id}
stat={s}
isMe={s.user_id === currentUser?.id}
canEdit={s.user_id === currentUser?.id || isFused}
selectedYear={selectedYear}
onSave={updateVacationDays}
t={t}
/>
))}
</div>
)}
</div>
)
}
function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }) {
const [editing, setEditing] = useState(false)
const [localDays, setLocalDays] = useState(s.vacation_days)
const pct = s.total_available > 0 ? Math.min(100, (s.used / s.total_available) * 100) : 0
// Sync local state when stats reload from server
useEffect(() => {
if (!editing) setLocalDays(s.vacation_days)
}, [s.vacation_days, editing])
const handleSave = () => {
setEditing(false)
const days = parseInt(localDays)
if (!isNaN(days) && days >= 0 && days <= 365 && days !== s.vacation_days) {
onSave(selectedYear, days, s.user_id)
}
}
return (
<div className="rounded-lg p-2.5 space-y-2" style={{ border: '1px solid var(--border-secondary)' }}>
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: s.person_color }} />
<span className="text-xs font-semibold flex-1 truncate" style={{ color: 'var(--text-primary)' }}>
{s.person_name}
{isMe && <span style={{ color: 'var(--text-faint)' }}> ({t('vacay.you')})</span>}
</span>
<span className="text-[10px] tabular-nums" style={{ color: 'var(--text-faint)' }}>{s.used}/{s.total_available}</span>
</div>
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--bg-secondary)' }}>
<div className="h-full rounded-full transition-all duration-500" style={{ width: `${pct}%`, backgroundColor: s.person_color }} />
</div>
<div className="grid grid-cols-3 gap-1.5">
{/* Days — editable */}
<div
className="rounded-md px-2 py-2 group/days"
style={{
background: canEdit ? 'var(--bg-card)' : 'var(--bg-secondary)',
border: canEdit ? '1px solid var(--border-primary)' : '1px solid transparent',
cursor: canEdit ? 'pointer' : 'default',
}}
onClick={() => { if (canEdit && !editing) setEditing(true) }}
>
<div className="text-[10px] mb-1" style={{ color: 'var(--text-faint)', height: 14, lineHeight: '14px' }}>
{t('vacay.entitlementDays')} {canEdit && !editing && <Pencil size={9} className="inline opacity-0 group-hover/days:opacity-100 transition-opacity" style={{ color: 'var(--text-faint)', verticalAlign: 'middle' }} />}
</div>
{editing ? (
<input
type="number"
value={localDays}
onChange={e => setLocalDays(e.target.value)}
onBlur={handleSave}
onKeyDown={e => { if (e.key === 'Enter') handleSave(); if (e.key === 'Escape') { setEditing(false); setLocalDays(s.vacation_days) } }}
autoFocus
className="w-full bg-transparent text-sm font-bold outline-none p-0 m-0 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
style={{ color: 'var(--text-primary)', height: 18, lineHeight: '18px' }}
/>
) : (
<div className="text-sm font-bold" style={{ color: 'var(--text-primary)', height: 18, lineHeight: '18px' }}>{s.vacation_days}</div>
)}
</div>
{/* Used */}
<div className="rounded-md px-2 py-2" style={{ background: 'var(--bg-secondary)' }}>
<div className="text-[10px] mb-1" style={{ color: 'var(--text-faint)', height: 14, lineHeight: '14px' }}>{t('vacay.used')}</div>
<div className="text-sm font-bold" style={{ color: 'var(--text-primary)', height: 18, lineHeight: '18px' }}>{s.used}</div>
</div>
{/* Remaining */}
<div className="rounded-md px-2 py-2" style={{ background: 'var(--bg-secondary)' }}>
<div className="text-[10px] mb-1" style={{ color: 'var(--text-faint)', height: 14, lineHeight: '14px' }}>{t('vacay.remaining')}</div>
<div className="text-sm font-bold" style={{ color: s.remaining < 0 ? '#ef4444' : s.remaining <= 3 ? '#f59e0b' : '#22c55e', height: 18, lineHeight: '18px' }}>
{s.remaining}
</div>
</div>
</div>
{s.carried_over > 0 && (
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md" style={{ background: 'rgba(245,158,11,0.08)', border: '1px solid rgba(245,158,11,0.15)' }}>
<span className="text-[10px]" style={{ color: '#d97706' }}>+{s.carried_over} {t('vacay.carriedOver', { year: selectedYear - 1 })}</span>
</div>
)}
</div>
)
}
+146
View File
@@ -0,0 +1,146 @@
// German public holidays (Feiertage) calculation per Bundesland
// Includes fixed and Easter-dependent movable holidays
const BUNDESLAENDER = {
BW: 'Baden-Württemberg',
BY: 'Bayern',
BE: 'Berlin',
BB: 'Brandenburg',
HB: 'Bremen',
HH: 'Hamburg',
HE: 'Hessen',
MV: 'Mecklenburg-Vorpommern',
NI: 'Niedersachsen',
NW: 'Nordrhein-Westfalen',
RP: 'Rheinland-Pfalz',
SL: 'Saarland',
SN: 'Sachsen',
ST: 'Sachsen-Anhalt',
SH: 'Schleswig-Holstein',
TH: 'Thüringen',
};
// Gauss Easter algorithm
function easterSunday(year) {
const a = year % 19;
const b = Math.floor(year / 100);
const c = year % 100;
const d = Math.floor(b / 4);
const e = b % 4;
const f = Math.floor((b + 8) / 25);
const g = Math.floor((b - f + 1) / 3);
const h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4);
const k = c % 4;
const l = (32 + 2 * e + 2 * i - h - k) % 7;
const m = Math.floor((a + 11 * h + 22 * l) / 451);
const month = Math.floor((h + l - 7 * m + 114) / 31);
const day = ((h + l - 7 * m + 114) % 31) + 1;
return new Date(year, month - 1, day);
}
function addDays(date, days) {
const d = new Date(date);
d.setDate(d.getDate() + days);
return d;
}
function fmt(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
export function getHolidays(year, bundesland = 'NW') {
const easter = easterSunday(year);
const holidays = {};
// Fixed holidays (nationwide)
holidays[`${year}-01-01`] = 'Neujahr';
holidays[`${year}-05-01`] = 'Tag der Arbeit';
holidays[`${year}-10-03`] = 'Tag der Deutschen Einheit';
holidays[`${year}-12-25`] = '1. Weihnachtsfeiertag';
holidays[`${year}-12-26`] = '2. Weihnachtsfeiertag';
// Easter-dependent (nationwide)
holidays[fmt(addDays(easter, -2))] = 'Karfreitag';
holidays[fmt(addDays(easter, 1))] = 'Ostermontag';
holidays[fmt(addDays(easter, 39))] = 'Christi Himmelfahrt';
holidays[fmt(addDays(easter, 50))] = 'Pfingstmontag';
// State-specific
const bl = bundesland.toUpperCase();
// Heilige Drei Könige (6. Jan) — BW, BY, ST
if (['BW', 'BY', 'ST'].includes(bl)) {
holidays[`${year}-01-06`] = 'Heilige Drei Könige';
}
// Internationaler Frauentag (8. März) — BE, MV
if (['BE', 'MV'].includes(bl)) {
holidays[`${year}-03-08`] = 'Internationaler Frauentag';
}
// Fronleichnam — BW, BY, HE, NW, RP, SL, SN (teilweise), TH (teilweise)
if (['BW', 'BY', 'HE', 'NW', 'RP', 'SL'].includes(bl)) {
holidays[fmt(addDays(easter, 60))] = 'Fronleichnam';
}
// Mariä Himmelfahrt (15. Aug) — SL, BY (teilweise)
if (['SL'].includes(bl)) {
holidays[`${year}-08-15`] = 'Mariä Himmelfahrt';
}
// Weltkindertag (20. Sep) — TH
if (bl === 'TH') {
holidays[`${year}-09-20`] = 'Weltkindertag';
}
// Reformationstag (31. Okt) — BB, HB, HH, MV, NI, SN, ST, SH, TH
if (['BB', 'HB', 'HH', 'MV', 'NI', 'SN', 'ST', 'SH', 'TH'].includes(bl)) {
holidays[`${year}-10-31`] = 'Reformationstag';
}
// Allerheiligen (1. Nov) — BW, BY, NW, RP, SL
if (['BW', 'BY', 'NW', 'RP', 'SL'].includes(bl)) {
holidays[`${year}-11-01`] = 'Allerheiligen';
}
// Buß- und Bettag — SN (Mittwoch vor dem 23. November)
if (bl === 'SN') {
const nov23 = new Date(year, 10, 23);
let bbt = new Date(nov23);
while (bbt.getDay() !== 3) bbt.setDate(bbt.getDate() - 1);
holidays[fmt(bbt)] = 'Buß- und Bettag';
}
return holidays;
}
export function isWeekend(dateStr) {
const d = new Date(dateStr + 'T00:00:00');
const day = d.getDay();
return day === 0 || day === 6;
}
export function getWeekday(dateStr) {
const d = new Date(dateStr + 'T00:00:00');
return ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][d.getDay()];
}
export function getWeekdayFull(dateStr) {
const d = new Date(dateStr + 'T00:00:00');
return ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'][d.getDay()];
}
export function daysInMonth(year, month) {
return new Date(year, month, 0).getDate();
}
export function formatDate(dateStr) {
const d = new Date(dateStr + 'T00:00:00');
return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' });
}
export { BUNDESLAENDER };
+3 -3
View File
@@ -39,8 +39,8 @@ export default function Modal({
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center px-4 modal-backdrop"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 20 }}
className="fixed inset-0 z-50 flex items-start sm:items-center justify-center px-4 modal-backdrop"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 20, overflow: 'hidden' }}
onMouseDown={e => { mouseDownTarget.current = e.target }}
onClick={e => {
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) onClose()
@@ -50,7 +50,7 @@ export default function Modal({
<div
className={`
rounded-2xl shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
flex flex-col max-h-[90vh]
flex flex-col max-h-[calc(100vh-90px)]
animate-in fade-in zoom-in-95 duration-200
`}
style={{