2026-03-19 06:58:08 +08:00
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 CustomSelect from '../shared/CustomSelect'
// ── Helpers ──────────────────────────────────────────────────────────────────
const CURRENCIES = [ 'EUR' , 'USD' , 'GBP' , 'JPY' , 'CHF' , 'CZK' , 'PLN' , 'SEK' , 'NOK' , 'DKK' , 'TRY' , 'THB' , 'AUD' , 'CAD' ]
const SYMBOLS = { EUR : '€' , USD : '$' , GBP : '£' , JPY : '¥' , CHF : 'CHF' , CZK : 'Kč' , PLN : 'zł' , SEK : 'kr' , NOK : 'kr' , DKK : 'kr' , TRY : '₺' , THB : '฿' , AUD : 'A$' , CAD : 'C$' }
const PIE _COLORS = [ '#6366f1' , '#ec4899' , '#f59e0b' , '#10b981' , '#3b82f6' , '#8b5cf6' , '#ef4444' , '#14b8a6' , '#f97316' , '#06b6d4' , '#84cc16' , '#a855f7' ]
const fmtNum = ( v , locale , cur ) => {
if ( v == null || isNaN ( v ) ) return '-'
return Number ( v ) . toLocaleString ( locale , { minimumFractionDigits : 2 , maximumFractionDigits : 2 } ) + ' ' + ( SYMBOLS [ cur ] || cur )
}
const calcPP = ( p , n ) => ( n > 0 ? p / n : null )
const calcPD = ( p , d ) => ( d > 0 ? p / d : null )
const calcPPD = ( p , n , d ) => ( n > 0 && d > 0 ? p / ( n * d ) : null )
// ── Inline Edit Cell ─────────────────────────────────────────────────────────
function InlineEditCell ( { value , onSave , type = 'text' , style = { } , placeholder = '' , decimals = 2 , locale , editTooltip } ) {
const [ editing , setEditing ] = useState ( false )
const [ editValue , setEditValue ] = useState ( value ? ? '' )
const inputRef = useRef ( null )
useEffect ( ( ) => { if ( editing && inputRef . current ) { inputRef . current . focus ( ) ; inputRef . current . select ( ) } } , [ editing ] )
const save = ( ) => {
setEditing ( false )
let v = editValue
if ( type === 'number' ) { const p = parseFloat ( String ( editValue ) . replace ( ',' , '.' ) ) ; v = isNaN ( p ) ? null : p }
if ( v !== value ) onSave ( v )
}
if ( editing ) {
return < input ref = { inputRef } type = "text" inputMode = { type === 'number' ? 'decimal' : 'text' } value = { editValue }
onChange = { e => setEditValue ( e . target . value ) } onBlur = { save }
onKeyDown = { e => { if ( e . key === 'Enter' ) save ( ) ; if ( e . key === 'Escape' ) { setEditValue ( value ? ? '' ) ; setEditing ( false ) } } }
v2.1.0 — Real-time collaboration, performance & security overhaul
Real-Time Collaboration (WebSocket):
- WebSocket server with JWT auth and trip-based rooms
- Live sync for all CRUD operations (places, assignments, days, notes, budget, packing, reservations, files)
- Socket-based exclusion to prevent duplicate updates
- Auto-reconnect with exponential backoff
- Assignment move sync between days
Performance:
- 16 database indexes on all foreign key columns
- N+1 query fix in places, assignments and days endpoints
- Marker clustering (react-leaflet-cluster) with configurable radius
- List virtualization (react-window) for places sidebar
- useMemo for filtered places
- SQLite WAL mode + busy_timeout for concurrent writes
- Weather API: server-side cache (1h forecast, 15min current) + client sessionStorage
- Google Places photos: persisted to DB after first fetch
- Google Details: 3-tier cache (memory → sessionStorage → API)
Security:
- CORS auto-configuration (production: same-origin, dev: open)
- API keys removed from /auth/me response
- Admin-only endpoint for reading API keys
- Path traversal prevention in cover image deletion
- JWT secret persisted to file (survives restarts)
- Avatar upload file extension whitelist
- API key fallback: normal users use admin's key without exposure
- Case-insensitive email login
Dark Mode:
- Fixed hardcoded colors across PackingList, Budget, ReservationModal, ReservationsPanel
- Mobile map buttons and sidebar sheets respect dark mode
- Cluster markers always dark
UI/UX:
- Redesigned login page with animated planes, stars and feature cards
- Admin: create user functionality with CustomSelect
- Mobile: day-picker popup for assigning places to days
- Mobile: touch-friendly reorder buttons (32px targets)
- Mobile: responsive text (shorter labels on small screens)
- Packing list: index-based category colors
- i18n: translated date picker placeholder, fixed German labels
- Default map tile: CartoDB Light
2026-03-19 19:44:22 +08:00
style = { { width : '100%' , border : '1px solid var(--accent)' , borderRadius : 4 , padding : '4px 6px' , fontSize : 13 , outline : 'none' , background : 'var(--bg-input)' , color : 'var(--text-primary)' , fontFamily : 'inherit' , ... style } }
2026-03-19 06:58:08 +08:00
placeholder = { placeholder } / >
}
const display = type === 'number' && value != null
? Number ( value ) . toLocaleString ( locale , { minimumFractionDigits : decimals , maximumFractionDigits : decimals } )
: ( value || '' )
return (
< div onClick = { ( ) => { setEditValue ( value ? ? '' ) ; setEditing ( true ) } } title = { editTooltip }
style = { { cursor : 'pointer' , padding : '4px 6px' , borderRadius : 4 , minHeight : 28 , display : 'flex' , alignItems : 'center' ,
justifyContent : style ? . textAlign === 'center' ? 'center' : 'flex-start' , transition : 'background 0.15s' ,
color : display ? 'var(--text-primary)' : 'var(--text-faint)' , fontSize : 13 , ... style } }
v2.1.0 — Real-time collaboration, performance & security overhaul
Real-Time Collaboration (WebSocket):
- WebSocket server with JWT auth and trip-based rooms
- Live sync for all CRUD operations (places, assignments, days, notes, budget, packing, reservations, files)
- Socket-based exclusion to prevent duplicate updates
- Auto-reconnect with exponential backoff
- Assignment move sync between days
Performance:
- 16 database indexes on all foreign key columns
- N+1 query fix in places, assignments and days endpoints
- Marker clustering (react-leaflet-cluster) with configurable radius
- List virtualization (react-window) for places sidebar
- useMemo for filtered places
- SQLite WAL mode + busy_timeout for concurrent writes
- Weather API: server-side cache (1h forecast, 15min current) + client sessionStorage
- Google Places photos: persisted to DB after first fetch
- Google Details: 3-tier cache (memory → sessionStorage → API)
Security:
- CORS auto-configuration (production: same-origin, dev: open)
- API keys removed from /auth/me response
- Admin-only endpoint for reading API keys
- Path traversal prevention in cover image deletion
- JWT secret persisted to file (survives restarts)
- Avatar upload file extension whitelist
- API key fallback: normal users use admin's key without exposure
- Case-insensitive email login
Dark Mode:
- Fixed hardcoded colors across PackingList, Budget, ReservationModal, ReservationsPanel
- Mobile map buttons and sidebar sheets respect dark mode
- Cluster markers always dark
UI/UX:
- Redesigned login page with animated planes, stars and feature cards
- Admin: create user functionality with CustomSelect
- Mobile: day-picker popup for assigning places to days
- Mobile: touch-friendly reorder buttons (32px targets)
- Mobile: responsive text (shorter labels on small screens)
- Packing list: index-based category colors
- i18n: translated date picker placeholder, fixed German labels
- Default map tile: CartoDB Light
2026-03-19 19:44:22 +08:00
onMouseEnter = { e => e . currentTarget . style . background = 'var(--bg-hover)' }
2026-03-19 06:58:08 +08:00
onMouseLeave = { e => e . currentTarget . style . background = 'transparent' } >
{ display || placeholder || '-' }
< / div >
)
}
// ── Add Item Row ─────────────────────────────────────────────────────────────
function AddItemRow ( { onAdd , t } ) {
const [ name , setName ] = useState ( '' )
const [ price , setPrice ] = useState ( '' )
const [ persons , setPersons ] = useState ( '' )
const [ days , setDays ] = useState ( '' )
const [ note , setNote ] = useState ( '' )
const nameRef = useRef ( null )
const handleAdd = ( ) => {
if ( ! name . trim ( ) ) return
onAdd ( { name : name . trim ( ) , total _price : parseFloat ( String ( price ) . replace ( ',' , '.' ) ) || 0 , persons : parseInt ( persons ) || null , days : parseInt ( days ) || null , note : note . trim ( ) || null } )
setName ( '' ) ; setPrice ( '' ) ; setPersons ( '' ) ; setDays ( '' ) ; setNote ( '' )
setTimeout ( ( ) => nameRef . current ? . focus ( ) , 50 )
}
const inp = { border : '1px solid var(--border-primary)' , borderRadius : 4 , padding : '4px 6px' , fontSize : 13 , outline : 'none' , fontFamily : 'inherit' , width : '100%' , background : 'var(--bg-input)' , color : 'var(--text-primary)' }
return (
< tr style = { { background : 'var(--bg-secondary)' } } >
< td style = { { padding : '4px 6px' } } >
< input ref = { nameRef } value = { name } onChange = { e => setName ( e . target . value ) } onKeyDown = { e => e . key === 'Enter' && handleAdd ( ) }
placeholder = { t ( 'budget.newEntry' ) } style = { inp } / >
< / td >
< td style = { { padding : '4px 6px' } } >
< input value = { price } onChange = { e => setPrice ( e . target . value ) } onKeyDown = { e => e . key === 'Enter' && handleAdd ( ) }
placeholder = "0,00" inputMode = "decimal" style = { { ... inp , textAlign : 'center' } } / >
< / td >
< td className = "hidden sm:table-cell" style = { { padding : '4px 6px' , textAlign : 'center' } } >
< input value = { persons } onChange = { e => setPersons ( e . target . value ) } onKeyDown = { e => e . key === 'Enter' && handleAdd ( ) }
placeholder = "-" inputMode = "numeric" style = { { ... inp , textAlign : 'center' , maxWidth : 50 , margin : '0 auto' } } / >
< / td >
< td className = "hidden sm:table-cell" style = { { padding : '4px 6px' , textAlign : 'center' } } >
< input value = { days } onChange = { e => setDays ( e . target . value ) } onKeyDown = { e => e . key === 'Enter' && handleAdd ( ) }
placeholder = "-" inputMode = "numeric" style = { { ... inp , textAlign : 'center' , maxWidth : 50 , margin : '0 auto' } } / >
< / td >
< td className = "hidden md:table-cell" style = { { padding : '4px 6px' , color : 'var(--text-faint)' , fontSize : 12 , textAlign : 'center' } } > - < / td >
< td className = "hidden md:table-cell" style = { { padding : '4px 6px' , color : 'var(--text-faint)' , fontSize : 12 , textAlign : 'center' } } > - < / td >
< td className = "hidden lg:table-cell" style = { { padding : '4px 6px' , color : 'var(--text-faint)' , fontSize : 12 , textAlign : 'center' } } > - < / td >
< td className = "hidden sm:table-cell" style = { { padding : '4px 6px' } } >
< input value = { note } onChange = { e => setNote ( e . target . value ) } onKeyDown = { e => e . key === 'Enter' && handleAdd ( ) } placeholder = { t ( 'budget.table.note' ) } style = { inp } / >
< / td >
< td style = { { padding : '4px 6px' , textAlign : 'center' } } >
< button onClick = { handleAdd } disabled = { ! name . trim ( ) } title = { t ( 'reservations.add' ) }
v2.1.0 — Real-time collaboration, performance & security overhaul
Real-Time Collaboration (WebSocket):
- WebSocket server with JWT auth and trip-based rooms
- Live sync for all CRUD operations (places, assignments, days, notes, budget, packing, reservations, files)
- Socket-based exclusion to prevent duplicate updates
- Auto-reconnect with exponential backoff
- Assignment move sync between days
Performance:
- 16 database indexes on all foreign key columns
- N+1 query fix in places, assignments and days endpoints
- Marker clustering (react-leaflet-cluster) with configurable radius
- List virtualization (react-window) for places sidebar
- useMemo for filtered places
- SQLite WAL mode + busy_timeout for concurrent writes
- Weather API: server-side cache (1h forecast, 15min current) + client sessionStorage
- Google Places photos: persisted to DB after first fetch
- Google Details: 3-tier cache (memory → sessionStorage → API)
Security:
- CORS auto-configuration (production: same-origin, dev: open)
- API keys removed from /auth/me response
- Admin-only endpoint for reading API keys
- Path traversal prevention in cover image deletion
- JWT secret persisted to file (survives restarts)
- Avatar upload file extension whitelist
- API key fallback: normal users use admin's key without exposure
- Case-insensitive email login
Dark Mode:
- Fixed hardcoded colors across PackingList, Budget, ReservationModal, ReservationsPanel
- Mobile map buttons and sidebar sheets respect dark mode
- Cluster markers always dark
UI/UX:
- Redesigned login page with animated planes, stars and feature cards
- Admin: create user functionality with CustomSelect
- Mobile: day-picker popup for assigning places to days
- Mobile: touch-friendly reorder buttons (32px targets)
- Mobile: responsive text (shorter labels on small screens)
- Packing list: index-based category colors
- i18n: translated date picker placeholder, fixed German labels
- Default map tile: CartoDB Light
2026-03-19 19:44:22 +08:00
style = { { background : name . trim ( ) ? 'var(--text-primary)' : 'var(--border-primary)' , border : 'none' , borderRadius : 4 , color : 'var(--bg-primary)' ,
2026-03-19 06:58:08 +08:00
cursor : name . trim ( ) ? 'pointer' : 'default' , padding : '4px 8px' , display : 'inline-flex' , alignItems : 'center' } } >
< Plus size = { 14 } / >
< / button >
< / td >
< / tr >
)
}
// ── Pie Chart (pure CSS conic-gradient) ──────────────────────────────────────
function PieChart ( { segments , size = 200 , totalLabel } ) {
if ( ! segments . length ) return null
const total = segments . reduce ( ( s , x ) => s + x . value , 0 )
if ( total === 0 ) return null
let cumDeg = 0
const stops = segments . map ( seg => {
const start = cumDeg
const deg = ( seg . value / total ) * 360
cumDeg += deg
return ` ${ seg . color } ${ start } deg ${ start + deg } deg `
} ) . join ( ', ' )
return (
< div style = { { position : 'relative' , width : size , height : size , margin : '0 auto' } } >
< div style = { {
width : size , height : size , borderRadius : '50%' ,
background : ` conic-gradient( ${ stops } ) ` ,
boxShadow : '0 4px 24px rgba(0,0,0,0.08)' ,
} } / >
< div style = { {
position : 'absolute' , top : '50%' , left : '50%' ,
transform : 'translate(-50%, -50%)' ,
width : size * 0.55 , height : size * 0.55 ,
borderRadius : '50%' , background : 'var(--bg-card)' ,
display : 'flex' , flexDirection : 'column' , alignItems : 'center' , justifyContent : 'center' ,
boxShadow : 'inset 0 0 12px rgba(0,0,0,0.04)' ,
} } >
< Wallet size = { 18 } color = "var(--text-faint)" style = { { marginBottom : 2 } } / >
< span style = { { fontSize : 10 , color : 'var(--text-faint)' , fontWeight : 500 } } > { totalLabel } < / span >
< / div >
< / div >
)
}
// ── Main Component ───────────────────────────────────────────────────────────
export default function BudgetPanel ( { tripId } ) {
const { trip , budgetItems , addBudgetItem , updateBudgetItem , deleteBudgetItem , loadBudgetItems , updateTrip } = useTripStore ( )
const { t , locale } = useTranslation ( )
const [ newCategoryName , setNewCategoryName ] = useState ( '' )
const currency = trip ? . currency || 'EUR'
const fmt = ( v , cur ) => fmtNum ( v , locale , cur )
const setCurrency = ( cur ) => {
if ( tripId ) updateTrip ( tripId , { currency : cur } )
}
useEffect ( ( ) => { if ( tripId ) loadBudgetItems ( tripId ) } , [ tripId ] )
const grouped = useMemo ( ( ) => ( budgetItems || [ ] ) . reduce ( ( acc , item ) => {
const cat = item . category || 'Sonstiges'
if ( ! acc [ cat ] ) acc [ cat ] = [ ]
acc [ cat ] . push ( item )
return acc
} , { } ) , [ budgetItems ] )
const categoryNames = Object . keys ( grouped )
const grandTotal = ( budgetItems || [ ] ) . reduce ( ( s , i ) => s + ( i . total _price || 0 ) , 0 )
const pieSegments = useMemo ( ( ) =>
categoryNames . map ( ( cat , i ) => ( {
name : cat ,
value : grouped [ cat ] . reduce ( ( s , x ) => s + ( x . total _price || 0 ) , 0 ) ,
color : PIE _COLORS [ i % PIE _COLORS . length ] ,
} ) ) . filter ( s => s . value > 0 )
, [ grouped , categoryNames ] )
const handleAddItem = async ( category , data ) => { try { await addBudgetItem ( tripId , { ... data , category } ) } catch { } }
const handleUpdateField = async ( id , field , value ) => { try { await updateBudgetItem ( tripId , id , { [ field ] : value } ) } catch { } }
const handleDeleteItem = async ( id ) => { try { await deleteBudgetItem ( tripId , id ) } catch { } }
const handleDeleteCategory = async ( cat ) => {
const items = grouped [ cat ] || [ ]
for ( const item of items ) await deleteBudgetItem ( tripId , item . id )
}
const handleAddCategory = ( ) => {
if ( ! newCategoryName . trim ( ) ) return
addBudgetItem ( tripId , { name : t ( 'budget.defaultEntry' ) , category : newCategoryName . trim ( ) , total _price : 0 } )
2026-03-23 00:52:24 +08:00
setNewCategoryName ( '' )
2026-03-19 06:58:08 +08:00
}
const th = { padding : '6px 8px' , textAlign : 'center' , fontSize : 11 , fontWeight : 600 , color : 'var(--text-muted)' , textTransform : 'uppercase' , letterSpacing : '0.05em' , borderBottom : '2px solid var(--border-primary)' , whiteSpace : 'nowrap' , background : 'var(--bg-secondary)' }
const td = { padding : '2px 6px' , borderBottom : '1px solid var(--border-secondary)' , fontSize : 13 , verticalAlign : 'middle' , color : 'var(--text-primary)' }
// ── Empty State ──────────────────────────────────────────────────────────
if ( ! budgetItems || budgetItems . length === 0 ) {
return (
< div style = { { padding : 24 , maxWidth : 600 , margin : '60px auto' , textAlign : 'center' } } >
< div style = { { width : 64 , height : 64 , borderRadius : 16 , background : 'var(--bg-tertiary)' , display : 'flex' , alignItems : 'center' , justifyContent : 'center' , margin : '0 auto 20px' } } >
< Calculator size = { 28 } color = "#6b7280" / >
< / div >
< h2 style = { { fontSize : 20 , fontWeight : 700 , color : 'var(--text-primary)' , margin : '0 0 8px' } } > { t ( 'budget.emptyTitle' ) } < / h2 >
< p style = { { fontSize : 14 , color : 'var(--text-muted)' , margin : '0 0 24px' , lineHeight : 1.5 } } > { t ( 'budget.emptyText' ) } < / p >
v2.1.0 — Real-time collaboration, performance & security overhaul
Real-Time Collaboration (WebSocket):
- WebSocket server with JWT auth and trip-based rooms
- Live sync for all CRUD operations (places, assignments, days, notes, budget, packing, reservations, files)
- Socket-based exclusion to prevent duplicate updates
- Auto-reconnect with exponential backoff
- Assignment move sync between days
Performance:
- 16 database indexes on all foreign key columns
- N+1 query fix in places, assignments and days endpoints
- Marker clustering (react-leaflet-cluster) with configurable radius
- List virtualization (react-window) for places sidebar
- useMemo for filtered places
- SQLite WAL mode + busy_timeout for concurrent writes
- Weather API: server-side cache (1h forecast, 15min current) + client sessionStorage
- Google Places photos: persisted to DB after first fetch
- Google Details: 3-tier cache (memory → sessionStorage → API)
Security:
- CORS auto-configuration (production: same-origin, dev: open)
- API keys removed from /auth/me response
- Admin-only endpoint for reading API keys
- Path traversal prevention in cover image deletion
- JWT secret persisted to file (survives restarts)
- Avatar upload file extension whitelist
- API key fallback: normal users use admin's key without exposure
- Case-insensitive email login
Dark Mode:
- Fixed hardcoded colors across PackingList, Budget, ReservationModal, ReservationsPanel
- Mobile map buttons and sidebar sheets respect dark mode
- Cluster markers always dark
UI/UX:
- Redesigned login page with animated planes, stars and feature cards
- Admin: create user functionality with CustomSelect
- Mobile: day-picker popup for assigning places to days
- Mobile: touch-friendly reorder buttons (32px targets)
- Mobile: responsive text (shorter labels on small screens)
- Packing list: index-based category colors
- i18n: translated date picker placeholder, fixed German labels
- Default map tile: CartoDB Light
2026-03-19 19:44:22 +08:00
< div style = { { display : 'flex' , gap : 6 , justifyContent : 'center' , alignItems : 'stretch' , maxWidth : 320 , margin : '0 auto' } } >
2026-03-19 06:58:08 +08:00
< input value = { newCategoryName } onChange = { e => setNewCategoryName ( e . target . value ) }
onKeyDown = { e => e . key === 'Enter' && handleAddCategory ( ) }
placeholder = { t ( 'budget.emptyPlaceholder' ) }
v2.1.0 — Real-time collaboration, performance & security overhaul
Real-Time Collaboration (WebSocket):
- WebSocket server with JWT auth and trip-based rooms
- Live sync for all CRUD operations (places, assignments, days, notes, budget, packing, reservations, files)
- Socket-based exclusion to prevent duplicate updates
- Auto-reconnect with exponential backoff
- Assignment move sync between days
Performance:
- 16 database indexes on all foreign key columns
- N+1 query fix in places, assignments and days endpoints
- Marker clustering (react-leaflet-cluster) with configurable radius
- List virtualization (react-window) for places sidebar
- useMemo for filtered places
- SQLite WAL mode + busy_timeout for concurrent writes
- Weather API: server-side cache (1h forecast, 15min current) + client sessionStorage
- Google Places photos: persisted to DB after first fetch
- Google Details: 3-tier cache (memory → sessionStorage → API)
Security:
- CORS auto-configuration (production: same-origin, dev: open)
- API keys removed from /auth/me response
- Admin-only endpoint for reading API keys
- Path traversal prevention in cover image deletion
- JWT secret persisted to file (survives restarts)
- Avatar upload file extension whitelist
- API key fallback: normal users use admin's key without exposure
- Case-insensitive email login
Dark Mode:
- Fixed hardcoded colors across PackingList, Budget, ReservationModal, ReservationsPanel
- Mobile map buttons and sidebar sheets respect dark mode
- Cluster markers always dark
UI/UX:
- Redesigned login page with animated planes, stars and feature cards
- Admin: create user functionality with CustomSelect
- Mobile: day-picker popup for assigning places to days
- Mobile: touch-friendly reorder buttons (32px targets)
- Mobile: responsive text (shorter labels on small screens)
- Packing list: index-based category colors
- i18n: translated date picker placeholder, fixed German labels
- Default map tile: CartoDB Light
2026-03-19 19:44:22 +08:00
style = { { flex : 1 , padding : '9px 14px' , borderRadius : 10 , border : '1px solid var(--border-primary)' , fontSize : 13 , fontFamily : 'inherit' , outline : 'none' , background : 'var(--bg-input)' , color : 'var(--text-primary)' , minWidth : 0 } } / >
2026-03-19 06:58:08 +08:00
< button onClick = { handleAddCategory } disabled = { ! newCategoryName . trim ( ) }
v2.1.0 — Real-time collaboration, performance & security overhaul
Real-Time Collaboration (WebSocket):
- WebSocket server with JWT auth and trip-based rooms
- Live sync for all CRUD operations (places, assignments, days, notes, budget, packing, reservations, files)
- Socket-based exclusion to prevent duplicate updates
- Auto-reconnect with exponential backoff
- Assignment move sync between days
Performance:
- 16 database indexes on all foreign key columns
- N+1 query fix in places, assignments and days endpoints
- Marker clustering (react-leaflet-cluster) with configurable radius
- List virtualization (react-window) for places sidebar
- useMemo for filtered places
- SQLite WAL mode + busy_timeout for concurrent writes
- Weather API: server-side cache (1h forecast, 15min current) + client sessionStorage
- Google Places photos: persisted to DB after first fetch
- Google Details: 3-tier cache (memory → sessionStorage → API)
Security:
- CORS auto-configuration (production: same-origin, dev: open)
- API keys removed from /auth/me response
- Admin-only endpoint for reading API keys
- Path traversal prevention in cover image deletion
- JWT secret persisted to file (survives restarts)
- Avatar upload file extension whitelist
- API key fallback: normal users use admin's key without exposure
- Case-insensitive email login
Dark Mode:
- Fixed hardcoded colors across PackingList, Budget, ReservationModal, ReservationsPanel
- Mobile map buttons and sidebar sheets respect dark mode
- Cluster markers always dark
UI/UX:
- Redesigned login page with animated planes, stars and feature cards
- Admin: create user functionality with CustomSelect
- Mobile: day-picker popup for assigning places to days
- Mobile: touch-friendly reorder buttons (32px targets)
- Mobile: responsive text (shorter labels on small screens)
- Packing list: index-based category colors
- i18n: translated date picker placeholder, fixed German labels
- Default map tile: CartoDB Light
2026-03-19 19:44:22 +08:00
style = { { background : 'var(--accent)' , color : 'var(--accent-text)' , border : 'none' , borderRadius : 10 , padding : '0 12px' , cursor : 'pointer' , display : 'flex' , alignItems : 'center' , opacity : newCategoryName . trim ( ) ? 1 : 0.5 , flexShrink : 0 } } >
< Plus size = { 16 } / >
2026-03-19 06:58:08 +08:00
< / button >
< / div >
< / div >
)
}
// ── Main Layout ──────────────────────────────────────────────────────────
return (
< div style = { { fontFamily : "'Poppins', -apple-system, BlinkMacSystemFont, system-ui, sans-serif" } } >
< div style = { { display : 'flex' , alignItems : 'center' , justifyContent : 'space-between' , padding : '16px 16px 12px' , flexWrap : 'wrap' , gap : 8 } } >
< div style = { { display : 'flex' , alignItems : 'center' , gap : 10 } } >
< Calculator size = { 20 } color = "var(--text-primary)" / >
< h2 style = { { fontSize : 18 , fontWeight : 700 , color : 'var(--text-primary)' , margin : 0 } } > { t ( 'budget.title' ) } < / h2 >
< / div >
< / div >
< div style = { { display : 'flex' , gap : 20 , padding : '0 16px 40px' , alignItems : 'flex-start' , flexWrap : 'wrap' } } >
< div style = { { flex : 1 , minWidth : 0 } } >
{ categoryNames . map ( ( cat , ci ) => {
const items = grouped [ cat ]
const subtotal = items . reduce ( ( s , x ) => s + ( x . total _price || 0 ) , 0 )
const color = PIE _COLORS [ ci % PIE _COLORS . length ]
return (
< div key = { cat } style = { { marginBottom : 16 } } >
< div style = { { display : 'flex' , alignItems : 'center' , justifyContent : 'space-between' , background : '#000000' , color : '#fff' , borderRadius : '10px 10px 0 0' , padding : '9px 14px' } } >
< div style = { { display : 'flex' , alignItems : 'center' , gap : 8 } } >
< div style = { { width : 10 , height : 10 , borderRadius : 3 , background : color , flexShrink : 0 } } / >
< span style = { { fontWeight : 600 , fontSize : 13 } } > { cat } < / span >
< / div >
< div style = { { display : 'flex' , alignItems : 'center' , gap : 10 } } >
< span style = { { fontSize : 13 , fontWeight : 500 , opacity : 0.9 } } > { fmt ( subtotal , currency ) } < / span >
< button onClick = { ( ) => handleDeleteCategory ( cat ) } title = { t ( 'budget.deleteCategory' ) }
style = { { background : 'rgba(255,255,255,0.1)' , border : 'none' , borderRadius : 4 , color : '#fff' , cursor : 'pointer' , padding : '3px 6px' , display : 'flex' , alignItems : 'center' , opacity : 0.6 } }
onMouseEnter = { e => e . currentTarget . style . opacity = '1' } onMouseLeave = { e => e . currentTarget . style . opacity = '0.6' } >
< Trash2 size = { 13 } / >
< / button >
< / div >
< / div >
< div style = { { overflowX : 'auto' , border : '1px solid var(--border-primary)' , borderTop : 'none' , borderRadius : '0 0 10px 10px' } } >
< table style = { { width : '100%' , borderCollapse : 'collapse' } } >
< thead >
< tr >
< th style = { { ... th , textAlign : 'left' , minWidth : 100 } } > { t ( 'budget.table.name' ) } < / th >
< th style = { { ... th , minWidth : 80 } } > { t ( 'budget.table.total' ) } < / th >
< th className = "hidden sm:table-cell" style = { { ... th , minWidth : 50 } } > { t ( 'budget.table.persons' ) } < / th >
< th className = "hidden sm:table-cell" style = { { ... th , minWidth : 45 } } > { t ( 'budget.table.days' ) } < / th >
< th className = "hidden md:table-cell" style = { { ... th , minWidth : 90 } } > { t ( 'budget.table.perPerson' ) } < / th >
< th className = "hidden md:table-cell" style = { { ... th , minWidth : 80 } } > { t ( 'budget.table.perDay' ) } < / th >
< th className = "hidden lg:table-cell" style = { { ... th , minWidth : 95 } } > { t ( 'budget.table.perPersonDay' ) } < / th >
< th className = "hidden sm:table-cell" style = { { ... th , textAlign : 'left' , minWidth : 80 } } > { t ( 'budget.table.note' ) } < / th >
< th style = { { ... th , width : 36 } } > < / th >
< / tr >
< / thead >
< tbody >
{ items . map ( item => {
const pp = calcPP ( item . total _price , item . persons )
const pd = calcPD ( item . total _price , item . days )
const ppd = calcPPD ( item . total _price , item . persons , item . days )
return (
< tr key = { item . id } style = { { transition : 'background 0.1s' } }
onMouseEnter = { e => e . currentTarget . style . background = 'var(--bg-hover)' }
onMouseLeave = { e => e . currentTarget . style . background = 'transparent' } >
< td style = { td } > < InlineEditCell value = { item . name } onSave = { v => handleUpdateField ( item . id , 'name' , v ) } placeholder = { t ( 'budget.table.name' ) } locale = { locale } editTooltip = { t ( 'budget.editTooltip' ) } / > < / td >
< td style = { { ... td , textAlign : 'center' } } >
< InlineEditCell value = { item . total _price } type = "number" onSave = { v => handleUpdateField ( item . id , 'total_price' , v ) } style = { { textAlign : 'center' } } placeholder = "0,00" locale = { locale } editTooltip = { t ( 'budget.editTooltip' ) } / >
< / td >
< td className = "hidden sm:table-cell" style = { { ... td , textAlign : 'center' } } >
< InlineEditCell value = { item . persons } type = "number" decimals = { 0 } onSave = { v => handleUpdateField ( item . id , 'persons' , v != null ? parseInt ( v ) || null : null ) } style = { { textAlign : 'center' } } placeholder = "-" locale = { locale } editTooltip = { t ( 'budget.editTooltip' ) } / >
< / td >
< td className = "hidden sm:table-cell" style = { { ... td , textAlign : 'center' } } >
< InlineEditCell value = { item . days } type = "number" decimals = { 0 } onSave = { v => handleUpdateField ( item . id , 'days' , v != null ? parseInt ( v ) || null : null ) } style = { { textAlign : 'center' } } placeholder = "-" locale = { locale } editTooltip = { t ( 'budget.editTooltip' ) } / >
< / td >
< td className = "hidden md:table-cell" style = { { ... td , textAlign : 'center' , color : pp != null ? 'var(--text-secondary)' : 'var(--text-faint)' } } > { pp != null ? fmt ( pp , currency ) : '-' } < / td >
< td className = "hidden md:table-cell" style = { { ... td , textAlign : 'center' , color : pd != null ? 'var(--text-secondary)' : 'var(--text-faint)' } } > { pd != null ? fmt ( pd , currency ) : '-' } < / td >
< td className = "hidden lg:table-cell" style = { { ... td , textAlign : 'center' , color : ppd != null ? 'var(--text-secondary)' : 'var(--text-faint)' } } > { ppd != null ? fmt ( ppd , currency ) : '-' } < / td >
< td className = "hidden sm:table-cell" style = { td } > < InlineEditCell value = { item . note } onSave = { v => handleUpdateField ( item . id , 'note' , v ) } placeholder = { t ( 'budget.table.note' ) } locale = { locale } editTooltip = { t ( 'budget.editTooltip' ) } / > < / td >
< td style = { { ... td , textAlign : 'center' } } >
< button onClick = { ( ) => handleDeleteItem ( item . id ) } title = { t ( 'common.delete' ) }
style = { { background : 'none' , border : 'none' , cursor : 'pointer' , padding : 4 , color : 'var(--text-faint)' , borderRadius : 4 , display : 'inline-flex' , transition : 'color 0.15s' } }
onMouseEnter = { e => e . currentTarget . style . color = '#ef4444' } onMouseLeave = { e => e . currentTarget . style . color = '#d1d5db' } >
< Trash2 size = { 14 } / >
< / button >
< / td >
< / tr >
)
} ) }
< AddItemRow onAdd = { data => handleAddItem ( cat , data ) } t = { t } / >
< / tbody >
< / table >
< / div >
< / div >
)
} ) }
< / div >
< div className = "w-full md:w-[280px]" style = { { flexShrink : 0 , position : 'sticky' , top : 16 , alignSelf : 'flex-start' } } >
< div style = { { marginBottom : 12 } } >
< CustomSelect
value = { currency }
onChange = { setCurrency }
options = { CURRENCIES . map ( c => ( { value : c , label : ` ${ c } ( ${ SYMBOLS [ c ] || c } ) ` } ) ) }
searchable
/ >
< / div >
< div style = { { display : 'flex' , gap : 6 , marginBottom : 12 } } >
< input
value = { newCategoryName }
onChange = { e => setNewCategoryName ( e . target . value ) }
onKeyDown = { e => { if ( e . key === 'Enter' ) handleAddCategory ( ) } }
placeholder = { t ( 'budget.categoryName' ) }
style = { { flex : 1 , border : '1px solid var(--border-primary)' , borderRadius : 10 , padding : '9px 14px' , fontSize : 13 , outline : 'none' , fontFamily : 'inherit' , background : 'var(--bg-input)' , color : 'var(--text-primary)' } }
/ >
< button onClick = { handleAddCategory } disabled = { ! newCategoryName . trim ( ) }
style = { { background : 'var(--accent)' , color : 'var(--accent-text)' , border : 'none' , borderRadius : 10 , padding : '9px 12px' , cursor : 'pointer' , display : 'flex' , alignItems : 'center' , opacity : newCategoryName . trim ( ) ? 1 : 0.4 , flexShrink : 0 } } >
< Plus size = { 16 } / >
< / button >
< / div >
< div style = { {
background : 'linear-gradient(135deg, #000000 0%, #18181b 100%)' ,
borderRadius : 16 , padding : '24px 20px' , color : '#fff' , marginBottom : 16 ,
boxShadow : '0 8px 32px rgba(15,23,42,0.18)' ,
} } >
< div style = { { display : 'flex' , alignItems : 'center' , gap : 8 , marginBottom : 16 } } >
< div style = { { width : 36 , height : 36 , borderRadius : 10 , background : 'rgba(255,255,255,0.1)' , display : 'flex' , alignItems : 'center' , justifyContent : 'center' } } >
< Wallet size = { 18 } color = "rgba(255,255,255,0.8)" / >
< / div >
< div >
< div style = { { fontSize : 11 , color : 'rgba(255,255,255,0.5)' , fontWeight : 500 , letterSpacing : 0.5 } } > { t ( 'budget.totalBudget' ) } < / div >
< / div >
< / div >
< div style = { { fontSize : 28 , fontWeight : 700 , lineHeight : 1 , marginBottom : 4 } } >
{ Number ( grandTotal ) . toLocaleString ( locale , { minimumFractionDigits : 2 , maximumFractionDigits : 2 } ) }
< / div >
< div style = { { fontSize : 14 , color : 'rgba(255,255,255,0.5)' , fontWeight : 500 } } > { SYMBOLS [ currency ] || currency } { currency } < / div >
< / div >
{ pieSegments . length > 0 && (
< div style = { {
background : 'var(--bg-card)' , borderRadius : 16 , padding : '20px 16px' ,
border : '1px solid var(--border-primary)' ,
boxShadow : '0 2px 12px rgba(0,0,0,0.04)' ,
} } >
< div style = { { fontSize : 13 , fontWeight : 600 , color : 'var(--text-primary)' , marginBottom : 16 , textAlign : 'center' } } > { t ( 'budget.byCategory' ) } < / div >
< PieChart segments = { pieSegments } size = { 180 } totalLabel = { t ( 'budget.total' ) } / >
< div style = { { marginTop : 20 , display : 'flex' , flexDirection : 'column' , gap : 8 } } >
{ pieSegments . map ( seg => {
const pct = grandTotal > 0 ? ( ( seg . value / grandTotal ) * 100 ) . toFixed ( 1 ) : '0.0'
return (
< div key = { seg . name } style = { { display : 'flex' , alignItems : 'center' , gap : 8 } } >
< div style = { { width : 10 , height : 10 , borderRadius : 3 , background : seg . color , flexShrink : 0 } } / >
< span style = { { flex : 1 , fontSize : 12 , color : 'var(--text-secondary)' , fontWeight : 500 } } > { seg . name } < / span >
< span style = { { fontSize : 12 , color : 'var(--text-muted)' , fontWeight : 600 , whiteSpace : 'nowrap' } } > { pct } % < / span >
< / div >
)
} ) }
< / div >
< div style = { { marginTop : 12 , borderTop : '1px solid var(--border-secondary)' , paddingTop : 12 , display : 'flex' , flexDirection : 'column' , gap : 6 } } >
{ pieSegments . map ( seg => (
< div key = { seg . name } style = { { display : 'flex' , justifyContent : 'space-between' , alignItems : 'center' } } >
< span style = { { fontSize : 11 , color : 'var(--text-faint)' } } > { seg . name } < / span >
< span style = { { fontSize : 12 , color : 'var(--text-secondary)' , fontWeight : 600 } } > { fmt ( seg . value , currency ) } < / span >
< / div >
) ) }
< / div >
< / div >
) }
< / div >
< / div >
< / div >
)
}