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:
Maurice
2026-03-23 21:11:20 +01:00
parent 88dca41ef7
commit faa8c84655
10 changed files with 178 additions and 25 deletions
+61 -6
View File
@@ -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 (
<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">
{Array.from({ length: 12 }, (_, i) => (
<VacayMonthCard
@@ -55,6 +108,8 @@ export default function VacayCalendar() {
companyHolidaysEnabled={companyHolidaysEnabled}
entryMap={entryMap}
onCellClick={handleCellClick}
onCellMouseDown={handleCellMouseDown}
onCellMouseEnter={handleCellMouseEnter}
companyMode={companyMode}
blockWeekends={blockWeekends}
/>
@@ -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 && <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)' }} />}
+23 -3
View File
@@ -72,16 +72,36 @@ export default function VacayPersons() {
</div>
<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 => {
const isSelected = selectedUserId === u.id
return (
<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"
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',
}}>
<button
onClick={(e) => { e.stopPropagation(); setColorEditUserId(u.id); setShowColorPicker(true) }}
@@ -93,7 +113,7 @@ export default function VacayPersons() {
{u.username}
{u.id === currentUser?.id && <span style={{ color: 'var(--text-faint)' }}> ({t('vacay.you')})</span>}
</span>
{isSelected && isFused && (
{isSelected && (isFused || users.length >= 2) && (
<Check size={12} style={{ color: 'var(--text-primary)' }} />
)}
</div>