diff --git a/client/src/api/client.js b/client/src/api/client.js index a820ef6..cfcad6d 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -166,6 +166,10 @@ export const reservationsApi = { delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data), } +export const exchangeApi = { + getRates: () => apiClient.get('/exchange-rates').then(r => r.data), +} + export const weatherApi = { get: (lat, lng, date) => apiClient.get('/weather', { params: { lat, lng, date, units: 'metric' } }).then(r => r.data), } diff --git a/client/src/components/Budget/BudgetPanel.jsx b/client/src/components/Budget/BudgetPanel.jsx index c8898f0..f410a4e 100644 --- a/client/src/components/Budget/BudgetPanel.jsx +++ b/client/src/components/Budget/BudgetPanel.jsx @@ -1,8 +1,9 @@ import React, { useState, useEffect, useRef, useMemo } from 'react' import { useTripStore } from '../../store/tripStore' import { useTranslation } from '../../i18n' -import { Plus, Trash2, Calculator, Wallet } from 'lucide-react' +import { Plus, Trash2, Calculator, Wallet, ArrowRightLeft } from 'lucide-react' import CustomSelect from '../shared/CustomSelect' +import { exchangeApi } from '../../api/client' // ── Helpers ────────────────────────────────────────────────────────────────── const CURRENCIES = ['EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK', 'TRY', 'THB', 'AUD', 'CAD'] @@ -153,6 +154,15 @@ export default function BudgetPanel({ tripId }) { const { t, locale } = useTranslation() const [newCategoryName, setNewCategoryName] = useState('') const currency = trip?.currency || 'EUR' + const [rates, setRates] = useState(null) + const [convertTo, setConvertTo] = useState(() => { + const saved = localStorage.getItem('budget_convert_to') + return saved || (currency === 'EUR' ? 'USD' : 'EUR') + }) + + useEffect(() => { + exchangeApi.getRates().then(setRates).catch(() => {}) + }, []) const fmt = (v, cur) => fmtNum(v, locale, cur) @@ -351,6 +361,38 @@ export default function BudgetPanel({ tripId }) { {Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
{SYMBOLS[currency] || currency} {currency}
+ + {/* Live exchange rate conversion */} + {rates && (() => { + const fromRate = currency === 'EUR' ? 1 : rates.rates?.[currency] + const toRate = convertTo === 'EUR' ? 1 : rates.rates?.[convertTo] + const converted = fromRate && toRate ? (grandTotal / fromRate) * toRate : null + return converted != null ? ( +
+
+ + {t('budget.converted')} +
+
+ + {Number(converted).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + + +
+
+ 1 {currency} = {((toRate / fromRate) || 0).toFixed(4)} {convertTo} +
+
+ ) : null + })()} {pieSegments.length > 0 && ( diff --git a/client/src/components/Planner/DayPlanSidebar.jsx b/client/src/components/Planner/DayPlanSidebar.jsx index 7283c49..603547b 100644 --- a/client/src/components/Planner/DayPlanSidebar.jsx +++ b/client/src/components/Planner/DayPlanSidebar.jsx @@ -855,17 +855,6 @@ export default function DayPlanSidebar({ {/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */} {isSelected && getDayAssignments(day.id).length >= 2 && (
-
- {TRANSPORT_MODES.map(m => ( - - ))} -
{routeInfo && (
diff --git a/client/src/components/Vacay/VacayCalendar.jsx b/client/src/components/Vacay/VacayCalendar.jsx index 283bbb5..d150d09 100644 --- a/client/src/components/Vacay/VacayCalendar.jsx +++ b/client/src/components/Vacay/VacayCalendar.jsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState, useCallback } from 'react' +import React, { useMemo, useState, useCallback, useRef } from 'react' import { useVacayStore } from '../../store/vacayStore' import { useTranslation } from '../../i18n' import { isWeekend } from './holidays' @@ -28,22 +28,75 @@ export default function VacayCalendar() { const blockWeekends = plan?.block_weekends !== false const companyHolidaysEnabled = plan?.company_holidays_enabled !== false + // Drag-to-paint state + const isDragging = useRef(false) + const dragAction = useRef(null) // 'add' or 'remove' + const dragProcessed = useRef(new Set()) + + const isDayBlocked = useCallback((dateStr) => { + if (holidays[dateStr]) return true + if (blockWeekends && isWeekend(dateStr)) return true + if (companyHolidaysEnabled && companyHolidaySet.has(dateStr) && !companyMode) return true + return false + }, [holidays, blockWeekends, companyHolidaySet, companyHolidaysEnabled, companyMode]) + + const handleCellMouseDown = useCallback((dateStr) => { + if (isDayBlocked(dateStr) && !companyMode) return + isDragging.current = true + dragProcessed.current = new Set([dateStr]) + + if (companyMode) { + dragAction.current = companyHolidaySet.has(dateStr) ? 'remove' : 'add' + toggleCompanyHoliday(dateStr) + } else { + const hasEntry = (entryMap[dateStr] || []).some(e => e.user_id === (selectedUserId || undefined)) + dragAction.current = hasEntry ? 'remove' : 'add' + toggleEntry(dateStr, selectedUserId || undefined) + } + }, [companyMode, isDayBlocked, toggleEntry, toggleCompanyHoliday, entryMap, companyHolidaySet, selectedUserId]) + + const handleCellMouseEnter = useCallback((dateStr) => { + if (!isDragging.current) return + if (dragProcessed.current.has(dateStr)) return + if (isDayBlocked(dateStr) && !companyMode) return + dragProcessed.current.add(dateStr) + + if (companyMode) { + const isSet = companyHolidaySet.has(dateStr) + if ((dragAction.current === 'add' && !isSet) || (dragAction.current === 'remove' && isSet)) { + toggleCompanyHoliday(dateStr) + } + } else { + const hasEntry = (entryMap[dateStr] || []).some(e => e.user_id === (selectedUserId || undefined)) + if ((dragAction.current === 'add' && !hasEntry) || (dragAction.current === 'remove' && hasEntry)) { + toggleEntry(dateStr, selectedUserId || undefined) + } + } + }, [companyMode, isDayBlocked, toggleEntry, toggleCompanyHoliday, entryMap, companyHolidaySet, selectedUserId]) + + const handleMouseUp = useCallback(() => { + isDragging.current = false + dragAction.current = null + dragProcessed.current.clear() + }, []) + + // Also handle click for single taps (touch/accessibility) const handleCellClick = useCallback(async (dateStr) => { + // Already handled by mousedown for mouse users, this is fallback for touch + if (isDragging.current) return if (companyMode) { if (!companyHolidaysEnabled) return await toggleCompanyHoliday(dateStr) return } - if (holidays[dateStr]) return - if (blockWeekends && isWeekend(dateStr)) return - if (companyHolidaysEnabled && companyHolidaySet.has(dateStr)) return + if (isDayBlocked(dateStr)) return await toggleEntry(dateStr, selectedUserId || undefined) - }, [companyMode, toggleEntry, toggleCompanyHoliday, holidays, companyHolidaySet, blockWeekends, companyHolidaysEnabled, selectedUserId]) + }, [companyMode, toggleEntry, toggleCompanyHoliday, companyHolidaysEnabled, isDayBlocked, selectedUserId]) const selectedUser = users.find(u => u.id === selectedUserId) return ( -
+
{Array.from({ length: 12 }, (_, i) => ( diff --git a/client/src/components/Vacay/VacayMonthCard.jsx b/client/src/components/Vacay/VacayMonthCard.jsx index b3d4e21..5ccba2f 100644 --- a/client/src/components/Vacay/VacayMonthCard.jsx +++ b/client/src/components/Vacay/VacayMonthCard.jsx @@ -9,7 +9,7 @@ const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', export default function VacayMonthCard({ year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap, - onCellClick, companyMode, blockWeekends + onCellClick, onCellMouseDown, onCellMouseEnter, companyMode, blockWeekends }) { const { language } = useTranslation() const weekdays = language === 'de' ? WEEKDAYS_DE : WEEKDAYS_EN @@ -69,9 +69,13 @@ export default function VacayMonthCard({ borderRight: '1px solid var(--border-secondary)', cursor: isBlocked ? 'default' : 'pointer', }} - onClick={() => onCellClick(dateStr)} - onMouseEnter={e => { if (!isBlocked) e.currentTarget.style.background = 'var(--bg-hover)' }} + onMouseDown={(e) => { e.preventDefault(); onCellMouseDown?.(dateStr) }} + onMouseEnter={(e) => { + onCellMouseEnter?.(dateStr) + if (!isBlocked) e.currentTarget.style.background = 'var(--bg-hover)' + }} onMouseLeave={e => { e.currentTarget.style.background = weekend ? 'var(--bg-secondary)' : 'transparent' }} + onTouchStart={() => onCellClick(dateStr)} > {holiday &&
} {isCompany &&
} diff --git a/client/src/components/Vacay/VacayPersons.jsx b/client/src/components/Vacay/VacayPersons.jsx index b02e08b..1e30b47 100644 --- a/client/src/components/Vacay/VacayPersons.jsx +++ b/client/src/components/Vacay/VacayPersons.jsx @@ -72,16 +72,36 @@ export default function VacayPersons() {
+ {users.length >= 2 && ( +
setSelectedUserId('all')} + className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg group transition-all" + style={{ + background: selectedUserId === 'all' ? 'var(--bg-hover)' : 'transparent', + border: selectedUserId === 'all' ? '1px solid var(--border-primary)' : '1px solid transparent', + cursor: 'pointer', + }}> +
+ A +
+ + {t('vacay.everyone')} + + {selectedUserId === 'all' && ( + + )} +
+ )} {users.map(u => { const isSelected = selectedUserId === u.id return (
{ if (isFused) setSelectedUserId(u.id) }} + onClick={() => { if (isFused || users.length >= 2) 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', + cursor: (isFused || users.length >= 2) ? 'pointer' : 'default', }}>
diff --git a/client/src/i18n/translations/de.js b/client/src/i18n/translations/de.js index 99e3542..8d916fc 100644 --- a/client/src/i18n/translations/de.js +++ b/client/src/i18n/translations/de.js @@ -335,6 +335,7 @@ const de = { 'vacay.dissolved': 'Kalender getrennt', 'vacay.fusedWith': 'Fusioniert mit', 'vacay.you': 'du', + 'vacay.everyone': 'Alle', 'vacay.noData': 'Keine Daten', 'vacay.changeColor': 'Farbe ändern', 'vacay.inviteUser': 'Benutzer einladen', @@ -569,6 +570,7 @@ const de = { 'budget.defaultCategory': 'Neue Kategorie', 'budget.total': 'Gesamt', 'budget.totalBudget': 'Gesamtbudget', + 'budget.converted': 'Umgerechnet', 'budget.byCategory': 'Nach Kategorie', 'budget.editTooltip': 'Klicken zum Bearbeiten', 'budget.confirm.deleteCategory': 'Möchtest du die Kategorie "{name}" mit {count} Einträgen wirklich löschen?', diff --git a/client/src/i18n/translations/en.js b/client/src/i18n/translations/en.js index 1d9faeb..ee24ba9 100644 --- a/client/src/i18n/translations/en.js +++ b/client/src/i18n/translations/en.js @@ -335,6 +335,7 @@ const en = { 'vacay.dissolved': 'Calendar separated', 'vacay.fusedWith': 'Fused with', 'vacay.you': 'you', + 'vacay.everyone': 'Everyone', 'vacay.noData': 'No data', 'vacay.changeColor': 'Change color', 'vacay.inviteUser': 'Invite User', @@ -569,6 +570,7 @@ const en = { 'budget.defaultCategory': 'New Category', 'budget.total': 'Total', 'budget.totalBudget': 'Total Budget', + 'budget.converted': 'Converted', 'budget.byCategory': 'By Category', 'budget.editTooltip': 'Click to edit', 'budget.confirm.deleteCategory': 'Are you sure you want to delete the category "{name}" with {count} entries?', diff --git a/server/src/index.js b/server/src/index.js index 26685fc..410dd56 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -107,6 +107,23 @@ app.use('/api/weather', weatherRoutes); app.use('/api/settings', settingsRoutes); app.use('/api/backup', backupRoutes); +// Exchange rates (cached 1h, authenticated) +const { authenticate: rateAuth } = require('./middleware/auth'); +let _rateCache = { data: null, ts: 0 }; +app.get('/api/exchange-rates', rateAuth, async (req, res) => { + const now = Date.now(); + if (_rateCache.data && now - _rateCache.ts < 3600000) return res.json(_rateCache.data); + try { + const r = await fetch('https://api.frankfurter.app/latest?from=EUR'); + if (!r.ok) return res.status(502).json({ error: 'Failed to fetch rates' }); + const data = await r.json(); + _rateCache = { data, ts: now }; + res.json(data); + } catch { + res.status(502).json({ error: 'Failed to fetch rates' }); + } +}); + // Serve static files in production if (process.env.NODE_ENV === 'production') { const publicPath = path.join(__dirname, '../public'); diff --git a/server/src/routes/vacay.js b/server/src/routes/vacay.js index 592d74b..9e5449c 100644 --- a/server/src/routes/vacay.js +++ b/server/src/routes/vacay.js @@ -453,10 +453,28 @@ router.post('/entries/toggle', (req, res) => { const { date, target_user_id } = req.body; if (!date) return res.status(400).json({ error: 'date required' }); const planId = getActivePlanId(req.user.id); + const planUsers = getPlanUsers(planId); + + // Toggle for all users in plan + if (target_user_id === 'all') { + const actions = []; + for (const u of planUsers) { + const existing = db.prepare('SELECT id FROM vacay_entries WHERE user_id = ? AND date = ? AND plan_id = ?').get(u.id, date, planId); + if (existing) { + db.prepare('DELETE FROM vacay_entries WHERE id = ?').run(existing.id); + actions.push({ user_id: u.id, action: 'removed' }); + } else { + db.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(planId, u.id, date, ''); + actions.push({ user_id: u.id, action: 'added' }); + } + } + notifyPlanUsers(planId, req.user.id); + return res.json({ action: 'toggled_all', actions }); + } + // Allow toggling for another user if they are in the same plan let userId = req.user.id; if (target_user_id && parseInt(target_user_id) !== req.user.id) { - const planUsers = getPlanUsers(planId); const tid = parseInt(target_user_id); if (!planUsers.find(u => u.id === tid)) { return res.status(403).json({ error: 'User not in plan' });