mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-07-09 15:05:59 +00:00
Vacay drag-to-paint, "Everyone" button, live exchange rates
- Vacay: click-and-drag to paint/erase vacation days across calendar - Vacay: "Everyone" button sets days for all persons (2+ only) - Budget: live currency conversion via frankfurter.app (cached 1h) - Budget: conversion widget in total card with selectable target currency - Day planner: remove transport mode buttons from day view
This commit is contained in:
@@ -166,6 +166,10 @@ export const reservationsApi = {
|
|||||||
delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
|
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 = {
|
export const weatherApi = {
|
||||||
get: (lat, lng, date) => apiClient.get('/weather', { params: { lat, lng, date, units: 'metric' } }).then(r => r.data),
|
get: (lat, lng, date) => apiClient.get('/weather', { params: { lat, lng, date, units: 'metric' } }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { useTranslation } from '../../i18n'
|
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 CustomSelect from '../shared/CustomSelect'
|
||||||
|
import { exchangeApi } from '../../api/client'
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
const CURRENCIES = ['EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK', 'TRY', 'THB', 'AUD', 'CAD']
|
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 { t, locale } = useTranslation()
|
||||||
const [newCategoryName, setNewCategoryName] = useState('')
|
const [newCategoryName, setNewCategoryName] = useState('')
|
||||||
const currency = trip?.currency || 'EUR'
|
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)
|
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 })}
|
{Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>{SYMBOLS[currency] || currency} {currency}</div>
|
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>{SYMBOLS[currency] || currency} {currency}</div>
|
||||||
|
|
||||||
|
{/* 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 ? (
|
||||||
|
<div style={{ marginTop: 16, paddingTop: 14, borderTop: '1px solid rgba(255,255,255,0.1)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
|
||||||
|
<ArrowRightLeft size={12} style={{ color: 'rgba(255,255,255,0.4)' }} />
|
||||||
|
<span style={{ fontSize: 10, color: 'rgba(255,255,255,0.4)', fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.5 }}>{t('budget.converted')}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
|
||||||
|
<span style={{ fontSize: 20, fontWeight: 700 }}>
|
||||||
|
{Number(converted).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={convertTo}
|
||||||
|
onChange={e => { setConvertTo(e.target.value); localStorage.setItem('budget_convert_to', e.target.value) }}
|
||||||
|
style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 6, color: 'rgba(255,255,255,0.7)', fontSize: 12, fontWeight: 600, padding: '2px 4px', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||||
|
>
|
||||||
|
{CURRENCIES.filter(c => c !== currency).map(c => (
|
||||||
|
<option key={c} value={c}>{c}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'rgba(255,255,255,0.3)', marginTop: 4 }}>
|
||||||
|
1 {currency} = {((toRate / fromRate) || 0).toFixed(4)} {convertTo}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pieSegments.length > 0 && (
|
{pieSegments.length > 0 && (
|
||||||
|
|||||||
@@ -855,17 +855,6 @@ export default function DayPlanSidebar({
|
|||||||
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
|
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
|
||||||
{isSelected && getDayAssignments(day.id).length >= 2 && (
|
{isSelected && getDayAssignments(day.id).length >= 2 && (
|
||||||
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
|
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||||
<div style={{ display: 'flex', background: 'var(--bg-hover)', borderRadius: 8, padding: 2, gap: 2 }}>
|
|
||||||
{TRANSPORT_MODES.map(m => (
|
|
||||||
<button key={m.value} onClick={() => setTransportMode(m.value)} style={{
|
|
||||||
flex: 1, padding: '4px 0', fontSize: 11, fontWeight: transportMode === m.value ? 600 : 400,
|
|
||||||
background: transportMode === m.value ? 'var(--bg-card)' : 'transparent',
|
|
||||||
border: 'none', borderRadius: 6, cursor: 'pointer', color: transportMode === m.value ? 'var(--text-primary)' : 'var(--text-muted)',
|
|
||||||
boxShadow: transportMode === m.value ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
}}>{m.label}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{routeInfo && (
|
{routeInfo && (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, color: 'var(--text-secondary)', background: 'var(--bg-hover)', borderRadius: 8, padding: '5px 10px' }}>
|
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, color: 'var(--text-secondary)', background: 'var(--bg-hover)', borderRadius: 8, padding: '5px 10px' }}>
|
||||||
|
|||||||
@@ -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 { useVacayStore } from '../../store/vacayStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { isWeekend } from './holidays'
|
import { isWeekend } from './holidays'
|
||||||
@@ -28,22 +28,75 @@ export default function VacayCalendar() {
|
|||||||
const blockWeekends = plan?.block_weekends !== false
|
const blockWeekends = plan?.block_weekends !== false
|
||||||
const companyHolidaysEnabled = plan?.company_holidays_enabled !== 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) => {
|
const handleCellClick = useCallback(async (dateStr) => {
|
||||||
|
// Already handled by mousedown for mouse users, this is fallback for touch
|
||||||
|
if (isDragging.current) return
|
||||||
if (companyMode) {
|
if (companyMode) {
|
||||||
if (!companyHolidaysEnabled) return
|
if (!companyHolidaysEnabled) return
|
||||||
await toggleCompanyHoliday(dateStr)
|
await toggleCompanyHoliday(dateStr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (holidays[dateStr]) return
|
if (isDayBlocked(dateStr)) return
|
||||||
if (blockWeekends && isWeekend(dateStr)) return
|
|
||||||
if (companyHolidaysEnabled && companyHolidaySet.has(dateStr)) return
|
|
||||||
await toggleEntry(dateStr, selectedUserId || undefined)
|
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)
|
const selectedUser = users.find(u => u.id === selectedUserId)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} style={{ userSelect: 'none' }}>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
<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) => (
|
{Array.from({ length: 12 }, (_, i) => (
|
||||||
<VacayMonthCard
|
<VacayMonthCard
|
||||||
@@ -55,6 +108,8 @@ export default function VacayCalendar() {
|
|||||||
companyHolidaysEnabled={companyHolidaysEnabled}
|
companyHolidaysEnabled={companyHolidaysEnabled}
|
||||||
entryMap={entryMap}
|
entryMap={entryMap}
|
||||||
onCellClick={handleCellClick}
|
onCellClick={handleCellClick}
|
||||||
|
onCellMouseDown={handleCellMouseDown}
|
||||||
|
onCellMouseEnter={handleCellMouseEnter}
|
||||||
companyMode={companyMode}
|
companyMode={companyMode}
|
||||||
blockWeekends={blockWeekends}
|
blockWeekends={blockWeekends}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli',
|
|||||||
|
|
||||||
export default function VacayMonthCard({
|
export default function VacayMonthCard({
|
||||||
year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap,
|
year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap,
|
||||||
onCellClick, companyMode, blockWeekends
|
onCellClick, onCellMouseDown, onCellMouseEnter, companyMode, blockWeekends
|
||||||
}) {
|
}) {
|
||||||
const { language } = useTranslation()
|
const { language } = useTranslation()
|
||||||
const weekdays = language === 'de' ? WEEKDAYS_DE : WEEKDAYS_EN
|
const weekdays = language === 'de' ? WEEKDAYS_DE : WEEKDAYS_EN
|
||||||
@@ -69,9 +69,13 @@ export default function VacayMonthCard({
|
|||||||
borderRight: '1px solid var(--border-secondary)',
|
borderRight: '1px solid var(--border-secondary)',
|
||||||
cursor: isBlocked ? 'default' : 'pointer',
|
cursor: isBlocked ? 'default' : 'pointer',
|
||||||
}}
|
}}
|
||||||
onClick={() => onCellClick(dateStr)}
|
onMouseDown={(e) => { e.preventDefault(); onCellMouseDown?.(dateStr) }}
|
||||||
onMouseEnter={e => { if (!isBlocked) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
onMouseEnter={(e) => {
|
||||||
|
onCellMouseEnter?.(dateStr)
|
||||||
|
if (!isBlocked) e.currentTarget.style.background = 'var(--bg-hover)'
|
||||||
|
}}
|
||||||
onMouseLeave={e => { e.currentTarget.style.background = weekend ? 'var(--bg-secondary)' : 'transparent' }}
|
onMouseLeave={e => { e.currentTarget.style.background = weekend ? 'var(--bg-secondary)' : 'transparent' }}
|
||||||
|
onTouchStart={() => onCellClick(dateStr)}
|
||||||
>
|
>
|
||||||
{holiday && <div className="absolute inset-0.5 rounded" style={{ background: 'rgba(239,68,68,0.12)' }} />}
|
{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)' }} />}
|
{isCompany && <div className="absolute inset-0.5 rounded" style={{ background: 'rgba(245,158,11,0.15)' }} />}
|
||||||
|
|||||||
@@ -72,16 +72,36 @@ export default function VacayPersons() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{users.length >= 2 && (
|
||||||
|
<div
|
||||||
|
onClick={() => 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',
|
||||||
|
}}>
|
||||||
|
<div className="w-3.5 h-3.5 rounded-full shrink-0 flex items-center justify-center" style={{ background: 'var(--text-muted)' }}>
|
||||||
|
<span style={{ fontSize: 8, fontWeight: 700, color: 'var(--bg-card)', lineHeight: 1 }}>A</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium flex-1 truncate" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{t('vacay.everyone')}
|
||||||
|
</span>
|
||||||
|
{selectedUserId === 'all' && (
|
||||||
|
<Check size={12} style={{ color: 'var(--text-primary)' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{users.map(u => {
|
{users.map(u => {
|
||||||
const isSelected = selectedUserId === u.id
|
const isSelected = selectedUserId === u.id
|
||||||
return (
|
return (
|
||||||
<div key={u.id}
|
<div key={u.id}
|
||||||
onClick={() => { 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"
|
className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg group transition-all"
|
||||||
style={{
|
style={{
|
||||||
background: isSelected ? 'var(--bg-hover)' : 'transparent',
|
background: isSelected ? 'var(--bg-hover)' : 'transparent',
|
||||||
border: isSelected ? '1px solid var(--border-primary)' : '1px solid transparent',
|
border: isSelected ? '1px solid var(--border-primary)' : '1px solid transparent',
|
||||||
cursor: isFused ? 'pointer' : 'default',
|
cursor: (isFused || users.length >= 2) ? 'pointer' : 'default',
|
||||||
}}>
|
}}>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setColorEditUserId(u.id); setShowColorPicker(true) }}
|
onClick={(e) => { e.stopPropagation(); setColorEditUserId(u.id); setShowColorPicker(true) }}
|
||||||
@@ -93,7 +113,7 @@ export default function VacayPersons() {
|
|||||||
{u.username}
|
{u.username}
|
||||||
{u.id === currentUser?.id && <span style={{ color: 'var(--text-faint)' }}> ({t('vacay.you')})</span>}
|
{u.id === currentUser?.id && <span style={{ color: 'var(--text-faint)' }}> ({t('vacay.you')})</span>}
|
||||||
</span>
|
</span>
|
||||||
{isSelected && isFused && (
|
{isSelected && (isFused || users.length >= 2) && (
|
||||||
<Check size={12} style={{ color: 'var(--text-primary)' }} />
|
<Check size={12} style={{ color: 'var(--text-primary)' }} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -335,6 +335,7 @@ const de = {
|
|||||||
'vacay.dissolved': 'Kalender getrennt',
|
'vacay.dissolved': 'Kalender getrennt',
|
||||||
'vacay.fusedWith': 'Fusioniert mit',
|
'vacay.fusedWith': 'Fusioniert mit',
|
||||||
'vacay.you': 'du',
|
'vacay.you': 'du',
|
||||||
|
'vacay.everyone': 'Alle',
|
||||||
'vacay.noData': 'Keine Daten',
|
'vacay.noData': 'Keine Daten',
|
||||||
'vacay.changeColor': 'Farbe ändern',
|
'vacay.changeColor': 'Farbe ändern',
|
||||||
'vacay.inviteUser': 'Benutzer einladen',
|
'vacay.inviteUser': 'Benutzer einladen',
|
||||||
@@ -569,6 +570,7 @@ const de = {
|
|||||||
'budget.defaultCategory': 'Neue Kategorie',
|
'budget.defaultCategory': 'Neue Kategorie',
|
||||||
'budget.total': 'Gesamt',
|
'budget.total': 'Gesamt',
|
||||||
'budget.totalBudget': 'Gesamtbudget',
|
'budget.totalBudget': 'Gesamtbudget',
|
||||||
|
'budget.converted': 'Umgerechnet',
|
||||||
'budget.byCategory': 'Nach Kategorie',
|
'budget.byCategory': 'Nach Kategorie',
|
||||||
'budget.editTooltip': 'Klicken zum Bearbeiten',
|
'budget.editTooltip': 'Klicken zum Bearbeiten',
|
||||||
'budget.confirm.deleteCategory': 'Möchtest du die Kategorie "{name}" mit {count} Einträgen wirklich löschen?',
|
'budget.confirm.deleteCategory': 'Möchtest du die Kategorie "{name}" mit {count} Einträgen wirklich löschen?',
|
||||||
|
|||||||
@@ -335,6 +335,7 @@ const en = {
|
|||||||
'vacay.dissolved': 'Calendar separated',
|
'vacay.dissolved': 'Calendar separated',
|
||||||
'vacay.fusedWith': 'Fused with',
|
'vacay.fusedWith': 'Fused with',
|
||||||
'vacay.you': 'you',
|
'vacay.you': 'you',
|
||||||
|
'vacay.everyone': 'Everyone',
|
||||||
'vacay.noData': 'No data',
|
'vacay.noData': 'No data',
|
||||||
'vacay.changeColor': 'Change color',
|
'vacay.changeColor': 'Change color',
|
||||||
'vacay.inviteUser': 'Invite User',
|
'vacay.inviteUser': 'Invite User',
|
||||||
@@ -569,6 +570,7 @@ const en = {
|
|||||||
'budget.defaultCategory': 'New Category',
|
'budget.defaultCategory': 'New Category',
|
||||||
'budget.total': 'Total',
|
'budget.total': 'Total',
|
||||||
'budget.totalBudget': 'Total Budget',
|
'budget.totalBudget': 'Total Budget',
|
||||||
|
'budget.converted': 'Converted',
|
||||||
'budget.byCategory': 'By Category',
|
'budget.byCategory': 'By Category',
|
||||||
'budget.editTooltip': 'Click to edit',
|
'budget.editTooltip': 'Click to edit',
|
||||||
'budget.confirm.deleteCategory': 'Are you sure you want to delete the category "{name}" with {count} entries?',
|
'budget.confirm.deleteCategory': 'Are you sure you want to delete the category "{name}" with {count} entries?',
|
||||||
|
|||||||
@@ -107,6 +107,23 @@ app.use('/api/weather', weatherRoutes);
|
|||||||
app.use('/api/settings', settingsRoutes);
|
app.use('/api/settings', settingsRoutes);
|
||||||
app.use('/api/backup', backupRoutes);
|
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
|
// Serve static files in production
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
const publicPath = path.join(__dirname, '../public');
|
const publicPath = path.join(__dirname, '../public');
|
||||||
|
|||||||
@@ -453,10 +453,28 @@ router.post('/entries/toggle', (req, res) => {
|
|||||||
const { date, target_user_id } = req.body;
|
const { date, target_user_id } = req.body;
|
||||||
if (!date) return res.status(400).json({ error: 'date required' });
|
if (!date) return res.status(400).json({ error: 'date required' });
|
||||||
const planId = getActivePlanId(req.user.id);
|
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
|
// Allow toggling for another user if they are in the same plan
|
||||||
let userId = req.user.id;
|
let userId = req.user.id;
|
||||||
if (target_user_id && parseInt(target_user_id) !== req.user.id) {
|
if (target_user_id && parseInt(target_user_id) !== req.user.id) {
|
||||||
const planUsers = getPlanUsers(planId);
|
|
||||||
const tid = parseInt(target_user_id);
|
const tid = parseInt(target_user_id);
|
||||||
if (!planUsers.find(u => u.id === tid)) {
|
if (!planUsers.find(u => u.id === tid)) {
|
||||||
return res.status(403).json({ error: 'User not in plan' });
|
return res.status(403).json({ error: 'User not in plan' });
|
||||||
|
|||||||
Reference in New Issue
Block a user