mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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={{
|
||||
|
||||
Reference in New Issue
Block a user