mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-07-09 15:05:59 +00:00
Participants, context menus, budget rename, file types, UI polish
- Assignment participants: toggle who joins each activity - Chips with hover-to-remove (strikethrough effect) - Add button with dropdown for available members - Avatars in day plan sidebar - Side-by-side with reservation in place inspector - Right-click context menus for places, notes in day plan + places list - Budget categories can now be renamed (pencil icon inline edit) - Admin: configurable allowed file types (stored in app_settings) - File manager shows allowed types dynamically - Hotel picker: select place + save button (no auto-close) - Edit pencil opens full hotel popup with all options - Place inspector: opening hours + files side by side on desktop - Address clamped to 2 lines, coordinates hidden on mobile - Category shows icon only on mobile - Rating hidden on mobile in place inspector - Time validation: "10" becomes "10:00" - Climate weather: full hourly data from archive API - CustomSelect: grouped headers support (isHeader) - Various responsive fixes
This commit is contained in:
@@ -95,6 +95,8 @@ export const assignmentsApi = {
|
|||||||
reorder: (tripId, dayId, orderedIds) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds }).then(r => r.data),
|
reorder: (tripId, dayId, orderedIds) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds }).then(r => r.data),
|
||||||
move: (tripId, assignmentId, newDayId, orderIndex) => apiClient.put(`/trips/${tripId}/assignments/${assignmentId}/move`, { new_day_id: newDayId, order_index: orderIndex }).then(r => r.data),
|
move: (tripId, assignmentId, newDayId, orderIndex) => apiClient.put(`/trips/${tripId}/assignments/${assignmentId}/move`, { new_day_id: newDayId, order_index: orderIndex }).then(r => r.data),
|
||||||
update: (tripId, dayId, id, data) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/${id}`, data).then(r => r.data),
|
update: (tripId, dayId, id, data) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/${id}`, data).then(r => r.data),
|
||||||
|
getParticipants: (tripId, id) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data),
|
||||||
|
setParticipants: (tripId, id, userIds) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const packingApi = {
|
export const packingApi = {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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, Pencil } from 'lucide-react'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
@@ -152,6 +152,7 @@ export default function BudgetPanel({ tripId }) {
|
|||||||
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip } = useTripStore()
|
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip } = useTripStore()
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const [newCategoryName, setNewCategoryName] = useState('')
|
const [newCategoryName, setNewCategoryName] = useState('')
|
||||||
|
const [editingCat, setEditingCat] = useState(null) // { name, value }
|
||||||
const currency = trip?.currency || 'EUR'
|
const currency = trip?.currency || 'EUR'
|
||||||
|
|
||||||
const fmt = (v, cur) => fmtNum(v, locale, cur)
|
const fmt = (v, cur) => fmtNum(v, locale, cur)
|
||||||
@@ -187,6 +188,11 @@ export default function BudgetPanel({ tripId }) {
|
|||||||
const items = grouped[cat] || []
|
const items = grouped[cat] || []
|
||||||
for (const item of items) await deleteBudgetItem(tripId, item.id)
|
for (const item of items) await deleteBudgetItem(tripId, item.id)
|
||||||
}
|
}
|
||||||
|
const handleRenameCategory = async (oldName, newName) => {
|
||||||
|
if (!newName.trim() || newName.trim() === oldName) return
|
||||||
|
const items = grouped[oldName] || []
|
||||||
|
for (const item of items) await updateBudgetItem(tripId, item.id, { category: newName.trim() })
|
||||||
|
}
|
||||||
const handleAddCategory = () => {
|
const handleAddCategory = () => {
|
||||||
if (!newCategoryName.trim()) return
|
if (!newCategoryName.trim()) return
|
||||||
addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 })
|
addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 })
|
||||||
@@ -239,9 +245,27 @@ export default function BudgetPanel({ tripId }) {
|
|||||||
return (
|
return (
|
||||||
<div key={cat} style={{ marginBottom: 16 }}>
|
<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', 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={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 }} />
|
<div style={{ width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 }} />
|
||||||
<span style={{ fontWeight: 600, fontSize: 13 }}>{cat}</span>
|
{editingCat?.name === cat ? (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={editingCat.value}
|
||||||
|
onChange={e => setEditingCat({ ...editingCat, value: e.target.value })}
|
||||||
|
onBlur={() => { handleRenameCategory(cat, editingCat.value); setEditingCat(null) }}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') { handleRenameCategory(cat, editingCat.value); setEditingCat(null) } if (e.key === 'Escape') setEditingCat(null) }}
|
||||||
|
style={{ fontWeight: 600, fontSize: 13, background: 'rgba(255,255,255,0.15)', border: 'none', borderRadius: 4, color: '#fff', padding: '1px 6px', outline: 'none', fontFamily: 'inherit', width: '100%' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 13 }}>{cat}</span>
|
||||||
|
<button onClick={() => setEditingCat({ name: cat, value: cat })}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}>
|
||||||
|
<Pencil size={10} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
<span style={{ fontSize: 13, fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
|
<span style={{ fontSize: 13, fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ function SourceBadge({ icon: Icon, label }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, reservations = [], tripId }) {
|
export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, reservations = [], tripId, allowedFileTypes }) {
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [filterType, setFilterType] = useState('all')
|
const [filterType, setFilterType] = useState('all')
|
||||||
const [lightboxFile, setLightboxFile] = useState(null)
|
const [lightboxFile, setLightboxFile] = useState(null)
|
||||||
@@ -229,6 +229,9 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
|||||||
<>
|
<>
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', fontWeight: 500, margin: 0 }}>{t('files.dropzone')}</p>
|
<p style={{ fontSize: 13, color: 'var(--text-secondary)', fontWeight: 500, margin: 0 }}>{t('files.dropzone')}</p>
|
||||||
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', marginTop: 3 }}>{t('files.dropzoneHint')}</p>
|
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', marginTop: 3 }}>{t('files.dropzoneHint')}</p>
|
||||||
|
<p style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 6, opacity: 0.7 }}>
|
||||||
|
{(allowedFileTypes || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv').toUpperCase().split(',').join(', ')} · Max 50 MB
|
||||||
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
const [showHotelPicker, setShowHotelPicker] = useState(false)
|
const [showHotelPicker, setShowHotelPicker] = useState(false)
|
||||||
const [hotelDayRange, setHotelDayRange] = useState({ start: day?.id, end: day?.id })
|
const [hotelDayRange, setHotelDayRange] = useState({ start: day?.id, end: day?.id })
|
||||||
const [hotelCategoryFilter, setHotelCategoryFilter] = useState('')
|
const [hotelCategoryFilter, setHotelCategoryFilter] = useState('')
|
||||||
const [hotelForm, setHotelForm] = useState({ check_in: '', check_out: '', confirmation: '' })
|
const [hotelForm, setHotelForm] = useState({ check_in: '', check_out: '', confirmation: '', place_id: null })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!day?.date || !lat || !lng) { setWeather(null); return }
|
if (!day?.date || !lat || !lng) { setWeather(null); return }
|
||||||
@@ -71,10 +71,15 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
|
|
||||||
useEffect(() => { if (day) setHotelDayRange({ start: day.id, end: day.id }) }, [day?.id])
|
useEffect(() => { if (day) setHotelDayRange({ start: day.id, end: day.id }) }, [day?.id])
|
||||||
|
|
||||||
const handleSetAccommodation = async (placeId) => {
|
const handleSelectPlace = (placeId) => {
|
||||||
|
setHotelForm(f => ({ ...f, place_id: placeId }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveAccommodation = async () => {
|
||||||
|
if (!hotelForm.place_id) return
|
||||||
try {
|
try {
|
||||||
const data = await accommodationsApi.create(tripId, {
|
const data = await accommodationsApi.create(tripId, {
|
||||||
place_id: placeId,
|
place_id: hotelForm.place_id,
|
||||||
start_day_id: hotelDayRange.start,
|
start_day_id: hotelDayRange.start,
|
||||||
end_day_id: hotelDayRange.end,
|
end_day_id: hotelDayRange.end,
|
||||||
check_in: hotelForm.check_in || null,
|
check_in: hotelForm.check_in || null,
|
||||||
@@ -84,7 +89,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
setAccommodation(data.accommodation)
|
setAccommodation(data.accommodation)
|
||||||
setAccommodations(prev => [...prev, data.accommodation])
|
setAccommodations(prev => [...prev, data.accommodation])
|
||||||
setShowHotelPicker(false)
|
setShowHotelPicker(false)
|
||||||
setHotelForm({ check_in: '', check_out: '', confirmation: '' })
|
setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null })
|
||||||
onAccommodationChange?.()
|
onAccommodationChange?.()
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
@@ -309,7 +314,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button onClick={() => { setHotelForm({ check_in: accommodation.check_in || '', check_out: accommodation.check_out || '', confirmation: accommodation.confirmation || '' }); setShowHotelPicker('edit') }}
|
<button onClick={() => { setHotelForm({ check_in: accommodation.check_in || '', check_out: accommodation.check_out || '', confirmation: accommodation.confirmation || '', place_id: accommodation.place_id }); setHotelDayRange({ start: accommodation.start_day_id, end: accommodation.end_day_id }); setShowHotelPicker('edit') }}
|
||||||
style={{ padding: '0 8px', background: 'none', border: 'none', borderLeft: '1px solid var(--border-faint)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
|
style={{ padding: '0 8px', background: 'none', border: 'none', borderLeft: '1px solid var(--border-faint)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
|
||||||
<Pencil size={10} style={{ color: 'var(--text-faint)' }} />
|
<Pencil size={10} style={{ color: 'var(--text-faint)' }} />
|
||||||
</button>
|
</button>
|
||||||
@@ -343,8 +348,8 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Day Range (hidden in edit mode) */}
|
{/* Day Range */}
|
||||||
{showHotelPicker !== 'edit' && <div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border-faint)', background: 'var(--bg-secondary)' }}>
|
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border-faint)', background: 'var(--bg-secondary)' }}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.04em' }}>{t('day.hotelDayRange')}</div>
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.04em' }}>{t('day.hotelDayRange')}</div>
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
@@ -378,7 +383,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
{t('day.allDays')}
|
{t('day.allDays')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>}
|
</div>
|
||||||
|
|
||||||
{/* Check-in / Check-out / Confirmation */}
|
{/* Check-in / Check-out / Confirmation */}
|
||||||
<div style={{ padding: '10px 18px', borderBottom: '1px solid var(--border-faint)', display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
<div style={{ padding: '10px 18px', borderBottom: '1px solid var(--border-faint)', display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
@@ -397,23 +402,6 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Edit mode: save button instead of place list */}
|
|
||||||
{showHotelPicker === 'edit' ? (
|
|
||||||
<div style={{ padding: '14px 18px', display: 'flex', justifyContent: 'flex-end' }}>
|
|
||||||
<button onClick={async () => {
|
|
||||||
await updateAccommodationField('check_in', hotelForm.check_in)
|
|
||||||
await updateAccommodationField('check_out', hotelForm.check_out)
|
|
||||||
await updateAccommodationField('confirmation', hotelForm.confirmation)
|
|
||||||
setShowHotelPicker(false)
|
|
||||||
}} style={{
|
|
||||||
padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
|
||||||
background: 'var(--text-primary)', color: 'var(--bg-card)',
|
|
||||||
}}>
|
|
||||||
{t('common.save')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : <>
|
|
||||||
|
|
||||||
{/* Category Filter */}
|
{/* Category Filter */}
|
||||||
{categories.length > 0 && (
|
{categories.length > 0 && (
|
||||||
<div style={{ padding: '8px 18px', borderBottom: '1px solid var(--border-faint)', display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
<div style={{ padding: '8px 18px', borderBottom: '1px solid var(--border-faint)', display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||||
@@ -440,14 +428,17 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
return filtered.length === 0 ? (
|
return filtered.length === 0 ? (
|
||||||
<div style={{ padding: 20, textAlign: 'center', fontSize: 12, color: 'var(--text-faint)' }}>{t('day.noPlacesForHotel')}</div>
|
<div style={{ padding: 20, textAlign: 'center', fontSize: 12, color: 'var(--text-faint)' }}>{t('day.noPlacesForHotel')}</div>
|
||||||
) : filtered.map(p => (
|
) : filtered.map(p => (
|
||||||
<button key={p.id} onClick={() => handleSetAccommodation(p.id)} style={{
|
<button key={p.id} onClick={() => handleSelectPlace(p.id)} style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '10px 18px',
|
display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '10px 18px',
|
||||||
border: 'none', borderBottom: '1px solid var(--border-faint)', background: 'none',
|
border: 'none', borderBottom: '1px solid var(--border-faint)',
|
||||||
|
background: hotelForm.place_id === p.id ? 'var(--bg-hover)' : 'none',
|
||||||
cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left',
|
cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left',
|
||||||
transition: 'background 0.1s',
|
transition: 'background 0.1s',
|
||||||
|
outline: hotelForm.place_id === p.id ? '2px solid var(--accent)' : 'none',
|
||||||
|
outlineOffset: -2, borderRadius: hotelForm.place_id === p.id ? 8 : 0,
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
onMouseEnter={e => { if (hotelForm.place_id !== p.id) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
onMouseLeave={e => { if (hotelForm.place_id !== p.id) e.currentTarget.style.background = 'none' }}
|
||||||
>
|
>
|
||||||
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--bg-secondary)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--bg-secondary)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
{p.image_url ? (
|
{p.image_url ? (
|
||||||
@@ -464,7 +455,44 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
))
|
))
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</>}
|
|
||||||
|
{/* Save / Cancel */}
|
||||||
|
<div style={{ padding: '12px 18px', borderTop: '1px solid var(--border-faint)', display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||||
|
<button onClick={() => setShowHotelPicker(false)} style={{ padding: '7px 16px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button onClick={async () => {
|
||||||
|
if (showHotelPicker === 'edit' && accommodation) {
|
||||||
|
// Update existing
|
||||||
|
await accommodationsApi.update(tripId, accommodation.id, {
|
||||||
|
place_id: hotelForm.place_id,
|
||||||
|
start_day_id: hotelDayRange.start,
|
||||||
|
end_day_id: hotelDayRange.end,
|
||||||
|
check_in: hotelForm.check_in || null,
|
||||||
|
check_out: hotelForm.check_out || null,
|
||||||
|
confirmation: hotelForm.confirmation || null,
|
||||||
|
})
|
||||||
|
setShowHotelPicker(false)
|
||||||
|
setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null })
|
||||||
|
// Reload
|
||||||
|
accommodationsApi.list(tripId).then(d => {
|
||||||
|
setAccommodations(d.accommodations || [])
|
||||||
|
const acc = (d.accommodations || []).find(a => days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id))
|
||||||
|
setAccommodation(acc || null)
|
||||||
|
})
|
||||||
|
onAccommodationChange?.()
|
||||||
|
} else {
|
||||||
|
await handleSaveAccommodation()
|
||||||
|
}
|
||||||
|
}} disabled={!hotelForm.place_id} style={{
|
||||||
|
padding: '7px 20px', borderRadius: 8, border: 'none', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
background: hotelForm.place_id ? 'var(--text-primary)' : 'var(--bg-tertiary)',
|
||||||
|
color: hotelForm.place_id ? 'var(--bg-card)' : 'var(--text-faint)',
|
||||||
|
}}>
|
||||||
|
{t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
|
|||||||
@@ -752,6 +752,23 @@ export default function DayPlanSidebar({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
{assignment.participants?.length > 0 && (
|
||||||
|
<div style={{ marginTop: 3, display: 'flex', alignItems: 'center', gap: -4 }}>
|
||||||
|
{assignment.participants.slice(0, 5).map((p, pi) => (
|
||||||
|
<div key={p.user_id} style={{
|
||||||
|
width: 16, height: 16, borderRadius: '50%', background: 'var(--bg-tertiary)', border: '1.5px solid var(--bg-card)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 7, fontWeight: 700, color: 'var(--text-muted)',
|
||||||
|
marginLeft: pi > 0 ? -4 : 0, flexShrink: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{p.avatar ? <img src={p.avatar} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : p.username?.[0]?.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{assignment.participants.length > 5 && (
|
||||||
|
<span style={{ fontSize: 8, color: 'var(--text-faint)', marginLeft: 2 }}>+{assignment.participants.length - 5}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
<div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
||||||
<button onClick={moveUp} disabled={placeIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: placeIdx === 0 ? 'default' : 'pointer', color: placeIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
<button onClick={moveUp} disabled={placeIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: placeIdx === 0 ? 'default' : 'pointer', color: placeIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation } from 'lucide-react'
|
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users } from 'lucide-react'
|
||||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||||
import { mapsApi } from '../../api/client'
|
import { mapsApi } from '../../api/client'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
@@ -100,7 +100,7 @@ function formatFileSize(bytes) {
|
|||||||
export default function PlaceInspector({
|
export default function PlaceInspector({
|
||||||
place, categories, days, selectedDayId, selectedAssignmentId, assignments, reservations = [],
|
place, categories, days, selectedDayId, selectedAssignmentId, assignments, reservations = [],
|
||||||
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
|
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
|
||||||
files, onFileUpload,
|
files, onFileUpload, tripMembers = [], onSetParticipants,
|
||||||
}) {
|
}) {
|
||||||
const { t, locale, language } = useTranslation()
|
const { t, locale, language } = useTranslation()
|
||||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||||
@@ -190,6 +190,7 @@ export default function PlaceInspector({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||||
<span style={{ fontWeight: 600, fontSize: 15, color: 'var(--text-primary)', lineHeight: '1.3' }}>{place.name}</span>
|
<span style={{ fontWeight: 600, fontSize: 15, color: 'var(--text-primary)', lineHeight: '1.3' }}>{place.name}</span>
|
||||||
@@ -205,7 +206,7 @@ export default function PlaceInspector({
|
|||||||
padding: '2px 8px', borderRadius: 99,
|
padding: '2px 8px', borderRadius: 99,
|
||||||
}}>
|
}}>
|
||||||
<CatIcon size={10} />
|
<CatIcon size={10} />
|
||||||
{category.name}
|
<span className="hidden sm:inline">{category.name}</span>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
@@ -213,7 +214,7 @@ export default function PlaceInspector({
|
|||||||
{place.address && (
|
{place.address && (
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 4, marginTop: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 4, marginTop: 6 }}>
|
||||||
<MapPin size={11} color="var(--text-faint)" style={{ flexShrink: 0, marginTop: 2 }} />
|
<MapPin size={11} color="var(--text-faint)" style={{ flexShrink: 0, marginTop: 2 }} />
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.4' }}>{place.address}</span>
|
<span style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.4', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{place.address}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{place.place_time && (
|
{place.place_time && (
|
||||||
@@ -223,7 +224,7 @@ export default function PlaceInspector({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{place.lat && place.lng && (
|
{place.lat && place.lng && (
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 4, fontVariantNumeric: 'tabular-nums' }}>
|
<div className="hidden sm:block" style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 4, fontVariantNumeric: 'tabular-nums' }}>
|
||||||
{Number(place.lat).toFixed(6)}, {Number(place.lng).toFixed(6)}
|
{Number(place.lat).toFixed(6)}, {Number(place.lng).toFixed(6)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -241,8 +242,8 @@ export default function PlaceInspector({
|
|||||||
{/* Content — scrollable */}
|
{/* Content — scrollable */}
|
||||||
<div style={{ overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
<div style={{ overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
|
||||||
{/* Info-Chips */}
|
{/* Info-Chips — hidden on mobile, shown on desktop */}
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
|
<div className="hidden sm:flex" style={{ flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
|
||||||
{googleDetails?.rating && (() => {
|
{googleDetails?.rating && (() => {
|
||||||
const shortReview = (googleDetails.reviews || []).find(r => r.text && r.text.length > 5)
|
const shortReview = (googleDetails.reviews || []).find(r => r.text && r.text.length > 5)
|
||||||
return (
|
return (
|
||||||
@@ -281,64 +282,71 @@ export default function PlaceInspector({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Reservation for this specific assignment */}
|
{/* Reservation + Participants — side by side */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const res = selectedAssignmentId ? reservations.find(r => r.assignment_id === selectedAssignmentId) : null
|
const res = selectedAssignmentId ? reservations.find(r => r.assignment_id === selectedAssignmentId) : null
|
||||||
if (!res) return null
|
const assignment = selectedAssignmentId ? (assignments[String(selectedDayId)] || []).find(a => a.id === selectedAssignmentId) : null
|
||||||
const confirmed = res.status === 'confirmed'
|
const currentParticipants = assignment?.participants || []
|
||||||
const accentColor = confirmed ? '#16a34a' : '#d97706'
|
const participantIds = currentParticipants.map(p => p.user_id)
|
||||||
|
const allJoined = currentParticipants.length === 0
|
||||||
|
const showParticipants = selectedAssignmentId && tripMembers.length > 1
|
||||||
|
if (!res && !showParticipants) return null
|
||||||
return (
|
return (
|
||||||
<div style={{ borderRadius: 12, overflow: 'hidden', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(217,119,6,0.2)'}` }}>
|
<div className={`grid ${res && showParticipants ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1'} gap-2`}>
|
||||||
{/* Header bar */}
|
{/* Reservation */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', background: confirmed ? 'rgba(22,163,74,0.08)' : 'rgba(217,119,6,0.08)' }}>
|
{res && (() => {
|
||||||
<div style={{ width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: accentColor }} />
|
const confirmed = res.status === 'confirmed'
|
||||||
<span style={{ fontSize: 11, fontWeight: 700, color: accentColor }}>{confirmed ? t('reservations.confirmed') : t('reservations.pending')}</span>
|
return (
|
||||||
<span style={{ flex: 1 }} />
|
<div style={{ borderRadius: 12, overflow: 'hidden', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(217,119,6,0.2)'}` }}>
|
||||||
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)' }}>{res.title}</span>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', background: confirmed ? 'rgba(22,163,74,0.08)' : 'rgba(217,119,6,0.08)' }}>
|
||||||
</div>
|
<div style={{ width: 6, height: 6, borderRadius: '50%', flexShrink: 0, background: confirmed ? '#16a34a' : '#d97706' }} />
|
||||||
{/* Details grid */}
|
<span style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706' }}>{confirmed ? t('reservations.confirmed') : t('reservations.pending')}</span>
|
||||||
{(res.reservation_time || res.confirmation_number || res.location || res.notes) && (
|
<span style={{ flex: 1 }} />
|
||||||
<div style={{ padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{res.title}</span>
|
||||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
</div>
|
||||||
{res.reservation_time && (
|
<div style={{ padding: '6px 10px', display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||||
<div>
|
{res.reservation_time && (
|
||||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.date')}</div>
|
<div>
|
||||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>
|
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.date')}</div>
|
||||||
{new Date(res.reservation_time).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}
|
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date(res.reservation_time).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
{res.reservation_time && (
|
||||||
{res.reservation_time && (
|
<div>
|
||||||
<div>
|
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.time')}</div>
|
||||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.time')}</div>
|
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}</div>
|
||||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>
|
|
||||||
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
{res.confirmation_number && (
|
||||||
{res.confirmation_number && (
|
<div>
|
||||||
<div>
|
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.confirmationCode')}</div>
|
||||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.confirmationCode')}</div>
|
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{res.confirmation_number}</div>
|
||||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{res.confirmation_number}</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
{res.location && (
|
{res.notes && <div style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-faint)', lineHeight: 1.4 }}>{res.notes}</div>}
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.locationAddress')}</div>
|
|
||||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-muted)', marginTop: 1 }}>{res.location}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{res.notes && (
|
)
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', lineHeight: 1.4, borderTop: '1px solid var(--border-faint)', paddingTop: 5 }}>{res.notes}</div>
|
})()}
|
||||||
)}
|
|
||||||
</div>
|
{/* Participants */}
|
||||||
|
{showParticipants && (
|
||||||
|
<ParticipantsBox
|
||||||
|
tripMembers={tripMembers}
|
||||||
|
participantIds={participantIds}
|
||||||
|
allJoined={allJoined}
|
||||||
|
onSetParticipants={onSetParticipants}
|
||||||
|
selectedAssignmentId={selectedAssignmentId}
|
||||||
|
selectedDayId={selectedDayId}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Opening hours */}
|
{/* Opening hours + Files — side by side on desktop */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
{openingHours && openingHours.length > 0 && (
|
{openingHours && openingHours.length > 0 && (
|
||||||
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
|
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
|
||||||
<button
|
<button
|
||||||
@@ -410,6 +418,7 @@ export default function PlaceInspector({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -482,3 +491,116 @@ function ActionButton({ onClick, variant, icon, label }) {
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticipants, selectedAssignmentId, selectedDayId, t }) {
|
||||||
|
const [showAdd, setShowAdd] = React.useState(false)
|
||||||
|
const [hoveredId, setHoveredId] = React.useState(null)
|
||||||
|
|
||||||
|
// Active participants: if allJoined, show all members; otherwise show only those in participantIds
|
||||||
|
const activeMembers = allJoined ? tripMembers : tripMembers.filter(m => participantIds.includes(m.id))
|
||||||
|
const availableToAdd = allJoined ? [] : tripMembers.filter(m => !participantIds.includes(m.id))
|
||||||
|
|
||||||
|
const handleRemove = (userId) => {
|
||||||
|
if (!onSetParticipants) return
|
||||||
|
let newIds
|
||||||
|
if (allJoined) {
|
||||||
|
newIds = tripMembers.filter(m => m.id !== userId).map(m => m.id)
|
||||||
|
} else {
|
||||||
|
newIds = participantIds.filter(id => id !== userId)
|
||||||
|
}
|
||||||
|
if (newIds.length === tripMembers.length) newIds = []
|
||||||
|
onSetParticipants(selectedAssignmentId, selectedDayId, newIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = (userId) => {
|
||||||
|
if (!onSetParticipants) return
|
||||||
|
const newIds = [...participantIds, userId]
|
||||||
|
if (newIds.length === tripMembers.length) {
|
||||||
|
onSetParticipants(selectedAssignmentId, selectedDayId, [])
|
||||||
|
} else {
|
||||||
|
onSetParticipants(selectedAssignmentId, selectedDayId, newIds)
|
||||||
|
}
|
||||||
|
setShowAdd(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ borderRadius: 12, border: '1px solid var(--border-faint)', padding: '8px 10px' }}>
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 6, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<Users size={10} /> {t('inspector.participants')}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, alignItems: 'center' }}>
|
||||||
|
{activeMembers.map(member => {
|
||||||
|
const isHovered = hoveredId === member.id
|
||||||
|
const canRemove = activeMembers.length > 1
|
||||||
|
return (
|
||||||
|
<div key={member.id}
|
||||||
|
onMouseEnter={() => setHoveredId(member.id)}
|
||||||
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
|
onClick={() => { if (canRemove) handleRemove(member.id) }}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 4, padding: '2px 7px 2px 3px', borderRadius: 99,
|
||||||
|
border: `1.5px solid ${isHovered && canRemove ? 'rgba(239,68,68,0.4)' : 'var(--accent)'}`,
|
||||||
|
background: isHovered && canRemove ? 'rgba(239,68,68,0.06)' : 'var(--bg-hover)',
|
||||||
|
fontSize: 10, fontWeight: 500,
|
||||||
|
color: isHovered && canRemove ? '#ef4444' : 'var(--text-primary)',
|
||||||
|
cursor: canRemove ? 'pointer' : 'default',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 16, height: 16, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 7, fontWeight: 700,
|
||||||
|
color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{member.avatar ? <img src={member.avatar} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : member.username?.[0]?.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span style={{ textDecoration: isHovered && canRemove ? 'line-through' : 'none' }}>{member.username}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Add button */}
|
||||||
|
{availableToAdd.length > 0 && (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<button onClick={() => setShowAdd(!showAdd)} style={{
|
||||||
|
width: 22, height: 22, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
|
||||||
|
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: 'var(--text-faint)', fontSize: 12, transition: 'all 0.12s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-muted)'; e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||||
|
>+</button>
|
||||||
|
|
||||||
|
{showAdd && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 26, left: 0, zIndex: 100,
|
||||||
|
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 140,
|
||||||
|
}}>
|
||||||
|
{availableToAdd.map(member => (
|
||||||
|
<button key={member.id} onClick={() => handleAdd(member.id)} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '5px 8px',
|
||||||
|
borderRadius: 6, border: 'none', background: 'none', cursor: 'pointer',
|
||||||
|
fontFamily: 'inherit', fontSize: 11, color: 'var(--text-primary)', textAlign: 'left',
|
||||||
|
transition: 'background 0.1s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 8, fontWeight: 700,
|
||||||
|
color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{member.avatar ? <img src={member.avatar} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : member.username?.[0]?.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
{member.username}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
|
|||||||
const h = Math.min(23, Math.max(0, parseInt(s.slice(0, 2))))
|
const h = Math.min(23, Math.max(0, parseInt(s.slice(0, 2))))
|
||||||
const m = Math.min(59, Math.max(0, parseInt(s.slice(2))))
|
const m = Math.min(59, Math.max(0, parseInt(s.slice(2))))
|
||||||
onChange(String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0'))
|
onChange(String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0'))
|
||||||
|
} else if (/^\d{1,2}$/.test(clean)) {
|
||||||
|
const h = Math.min(23, Math.max(0, parseInt(clean)))
|
||||||
|
onChange(String(h).padStart(2, '0') + ':00')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -281,6 +281,12 @@ const de = {
|
|||||||
'admin.oidcIssuerHint': 'Die OpenID Connect Issuer URL des Anbieters. z.B. https://accounts.google.com',
|
'admin.oidcIssuerHint': 'Die OpenID Connect Issuer URL des Anbieters. z.B. https://accounts.google.com',
|
||||||
'admin.oidcSaved': 'OIDC-Konfiguration gespeichert',
|
'admin.oidcSaved': 'OIDC-Konfiguration gespeichert',
|
||||||
|
|
||||||
|
// File Types
|
||||||
|
'admin.fileTypes': 'Erlaubte Dateitypen',
|
||||||
|
'admin.fileTypesHint': 'Konfiguriere welche Dateitypen hochgeladen werden dürfen.',
|
||||||
|
'admin.fileTypesFormat': 'Kommagetrennte Endungen (z.B. jpg,png,pdf,doc). Verwende * um alle Typen zu erlauben.',
|
||||||
|
'admin.fileTypesSaved': 'Dateityp-Einstellungen gespeichert',
|
||||||
|
|
||||||
// Addons
|
// Addons
|
||||||
'admin.tabs.addons': 'Addons',
|
'admin.tabs.addons': 'Addons',
|
||||||
'admin.addons.title': 'Addons',
|
'admin.addons.title': 'Addons',
|
||||||
@@ -549,6 +555,7 @@ const de = {
|
|||||||
'inspector.website': 'Webseite öffnen',
|
'inspector.website': 'Webseite öffnen',
|
||||||
'inspector.addRes': 'Reservierung',
|
'inspector.addRes': 'Reservierung',
|
||||||
'inspector.editRes': 'Reservierung bearbeiten',
|
'inspector.editRes': 'Reservierung bearbeiten',
|
||||||
|
'inspector.participants': 'Teilnehmer',
|
||||||
|
|
||||||
// Reservations
|
// Reservations
|
||||||
'reservations.title': 'Buchungen',
|
'reservations.title': 'Buchungen',
|
||||||
@@ -641,6 +648,7 @@ const de = {
|
|||||||
'files.uploadError': 'Fehler beim Hochladen',
|
'files.uploadError': 'Fehler beim Hochladen',
|
||||||
'files.dropzone': 'Dateien hier ablegen',
|
'files.dropzone': 'Dateien hier ablegen',
|
||||||
'files.dropzoneHint': 'oder klicken zum Auswählen',
|
'files.dropzoneHint': 'oder klicken zum Auswählen',
|
||||||
|
'files.allowedTypes': 'Bilder, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB',
|
||||||
'files.uploading': 'Wird hochgeladen...',
|
'files.uploading': 'Wird hochgeladen...',
|
||||||
'files.filterAll': 'Alle',
|
'files.filterAll': 'Alle',
|
||||||
'files.filterPdf': 'PDFs',
|
'files.filterPdf': 'PDFs',
|
||||||
|
|||||||
@@ -281,6 +281,12 @@ const en = {
|
|||||||
'admin.oidcIssuerHint': 'The OpenID Connect Issuer URL of the provider. e.g. https://accounts.google.com',
|
'admin.oidcIssuerHint': 'The OpenID Connect Issuer URL of the provider. e.g. https://accounts.google.com',
|
||||||
'admin.oidcSaved': 'OIDC configuration saved',
|
'admin.oidcSaved': 'OIDC configuration saved',
|
||||||
|
|
||||||
|
// File Types
|
||||||
|
'admin.fileTypes': 'Allowed File Types',
|
||||||
|
'admin.fileTypesHint': 'Configure which file types users can upload.',
|
||||||
|
'admin.fileTypesFormat': 'Comma-separated extensions (e.g. jpg,png,pdf,doc). Use * to allow all types.',
|
||||||
|
'admin.fileTypesSaved': 'File type settings saved',
|
||||||
|
|
||||||
// Addons
|
// Addons
|
||||||
'admin.tabs.addons': 'Addons',
|
'admin.tabs.addons': 'Addons',
|
||||||
'admin.addons.title': 'Addons',
|
'admin.addons.title': 'Addons',
|
||||||
@@ -549,6 +555,7 @@ const en = {
|
|||||||
'inspector.website': 'Open Website',
|
'inspector.website': 'Open Website',
|
||||||
'inspector.addRes': 'Reservation',
|
'inspector.addRes': 'Reservation',
|
||||||
'inspector.editRes': 'Edit Reservation',
|
'inspector.editRes': 'Edit Reservation',
|
||||||
|
'inspector.participants': 'Participants',
|
||||||
|
|
||||||
// Reservations
|
// Reservations
|
||||||
'reservations.title': 'Bookings',
|
'reservations.title': 'Bookings',
|
||||||
@@ -641,6 +648,7 @@ const en = {
|
|||||||
'files.uploadError': 'Upload failed',
|
'files.uploadError': 'Upload failed',
|
||||||
'files.dropzone': 'Drop files here',
|
'files.dropzone': 'Drop files here',
|
||||||
'files.dropzoneHint': 'or click to browse',
|
'files.dropzoneHint': 'or click to browse',
|
||||||
|
'files.allowedTypes': 'Images, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB',
|
||||||
'files.uploading': 'Uploading...',
|
'files.uploading': 'Uploading...',
|
||||||
'files.filterAll': 'All',
|
'files.filterAll': 'All',
|
||||||
'files.filterPdf': 'PDFs',
|
'files.filterPdf': 'PDFs',
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ export default function AdminPage() {
|
|||||||
// Registration toggle
|
// Registration toggle
|
||||||
const [allowRegistration, setAllowRegistration] = useState(true)
|
const [allowRegistration, setAllowRegistration] = useState(true)
|
||||||
|
|
||||||
|
// File types
|
||||||
|
const [allowedFileTypes, setAllowedFileTypes] = useState('jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv')
|
||||||
|
const [savingFileTypes, setSavingFileTypes] = useState(false)
|
||||||
|
|
||||||
// API Keys
|
// API Keys
|
||||||
const [mapsKey, setMapsKey] = useState('')
|
const [mapsKey, setMapsKey] = useState('')
|
||||||
const [weatherKey, setWeatherKey] = useState('')
|
const [weatherKey, setWeatherKey] = useState('')
|
||||||
@@ -91,6 +95,7 @@ export default function AdminPage() {
|
|||||||
try {
|
try {
|
||||||
const config = await authApi.getAppConfig()
|
const config = await authApi.getAppConfig()
|
||||||
setAllowRegistration(config.allow_registration)
|
setAllowRegistration(config.allow_registration)
|
||||||
|
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -493,6 +498,39 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Allowed File Types */}
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-slate-100">
|
||||||
|
<h2 className="font-semibold text-slate-900">{t('admin.fileTypes')}</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">{t('admin.fileTypesHint')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={allowedFileTypes}
|
||||||
|
onChange={e => setAllowedFileTypes(e.target.value)}
|
||||||
|
placeholder="jpg,png,pdf,doc,docx,xls,xlsx,txt,csv"
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-400 mt-2">{t('admin.fileTypesFormat')}</p>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
setSavingFileTypes(true)
|
||||||
|
try {
|
||||||
|
await authApi.updateAppSettings({ allowed_file_types: allowedFileTypes })
|
||||||
|
toast.success(t('admin.fileTypesSaved'))
|
||||||
|
} catch { toast.error(t('common.error')) }
|
||||||
|
finally { setSavingFileTypes(false) }
|
||||||
|
}}
|
||||||
|
disabled={savingFileTypes}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400 mt-3"
|
||||||
|
>
|
||||||
|
{savingFileTypes ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
|
{t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* API Keys */}
|
{/* API Keys */}
|
||||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||||
<div className="px-6 py-4 border-b border-slate-100">
|
<div className="px-6 py-4 border-b border-slate-100">
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { useToast } from '../components/shared/Toast'
|
|||||||
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from 'lucide-react'
|
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from 'lucide-react'
|
||||||
import { useTranslation } from '../i18n'
|
import { useTranslation } from '../i18n'
|
||||||
import { joinTrip, leaveTrip, addListener, removeListener } from '../api/websocket'
|
import { joinTrip, leaveTrip, addListener, removeListener } from '../api/websocket'
|
||||||
import { addonsApi, accommodationsApi } from '../api/client'
|
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi } from '../api/client'
|
||||||
|
|
||||||
const MIN_SIDEBAR = 200
|
const MIN_SIDEBAR = 200
|
||||||
const MAX_SIDEBAR = 520
|
const MAX_SIDEBAR = 520
|
||||||
@@ -37,6 +37,8 @@ export default function TripPlannerPage() {
|
|||||||
|
|
||||||
const [enabledAddons, setEnabledAddons] = useState({ packing: true, budget: true, documents: true })
|
const [enabledAddons, setEnabledAddons] = useState({ packing: true, budget: true, documents: true })
|
||||||
const [tripAccommodations, setTripAccommodations] = useState([])
|
const [tripAccommodations, setTripAccommodations] = useState([])
|
||||||
|
const [allowedFileTypes, setAllowedFileTypes] = useState(null)
|
||||||
|
const [tripMembers, setTripMembers] = useState([])
|
||||||
|
|
||||||
const loadAccommodations = useCallback(() => {
|
const loadAccommodations = useCallback(() => {
|
||||||
if (tripId) accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
|
if (tripId) accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
|
||||||
@@ -48,6 +50,9 @@ export default function TripPlannerPage() {
|
|||||||
data.addons.forEach(a => { map[a.id] = true })
|
data.addons.forEach(a => { map[a.id] = true })
|
||||||
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents })
|
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents })
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
|
authApi.getAppConfig().then(config => {
|
||||||
|
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
|
||||||
|
}).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const TRIP_TABS = [
|
const TRIP_TABS = [
|
||||||
@@ -104,6 +109,11 @@ export default function TripPlannerPage() {
|
|||||||
tripStore.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') })
|
tripStore.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') })
|
||||||
tripStore.loadFiles(tripId)
|
tripStore.loadFiles(tripId)
|
||||||
loadAccommodations()
|
loadAccommodations()
|
||||||
|
tripsApi.getMembers(tripId).then(d => {
|
||||||
|
// Combine owner + members into one list
|
||||||
|
const all = [d.owner, ...(d.members || [])].filter(Boolean)
|
||||||
|
setTripMembers(all)
|
||||||
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
}, [tripId])
|
}, [tripId])
|
||||||
|
|
||||||
@@ -582,6 +592,20 @@ export default function TripPlannerPage() {
|
|||||||
onRemoveAssignment={handleRemoveAssignment}
|
onRemoveAssignment={handleRemoveAssignment}
|
||||||
files={files}
|
files={files}
|
||||||
onFileUpload={(fd) => tripStore.addFile(tripId, fd)}
|
onFileUpload={(fd) => tripStore.addFile(tripId, fd)}
|
||||||
|
tripMembers={tripMembers}
|
||||||
|
onSetParticipants={async (assignmentId, dayId, userIds) => {
|
||||||
|
try {
|
||||||
|
const data = await assignmentsApi.setParticipants(tripId, assignmentId, userIds)
|
||||||
|
useTripStore.setState(state => ({
|
||||||
|
assignments: {
|
||||||
|
...state.assignments,
|
||||||
|
[String(dayId)]: (state.assignments[String(dayId)] || []).map(a =>
|
||||||
|
a.id === assignmentId ? { ...a, participants: data.participants } : a
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
} catch {}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -645,6 +669,7 @@ export default function TripPlannerPage() {
|
|||||||
places={places}
|
places={places}
|
||||||
reservations={reservations}
|
reservations={reservations}
|
||||||
tripId={tripId}
|
tripId={tripId}
|
||||||
|
allowedFileTypes={allowedFileTypes}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -337,6 +337,14 @@ function initDb() {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_photos_trip_id ON photos(trip_id);
|
CREATE INDEX IF NOT EXISTS idx_photos_trip_id ON photos(trip_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||||
CREATE INDEX IF NOT EXISTS idx_day_accommodations_trip_id ON day_accommodations(trip_id);
|
CREATE INDEX IF NOT EXISTS idx_day_accommodations_trip_id ON day_accommodations(trip_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS assignment_participants (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(assignment_id, user_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_assignment_participants_assignment ON assignment_participants(assignment_id);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Versioned migrations — each runs exactly once
|
// Versioned migrations — each runs exactly once
|
||||||
@@ -438,6 +446,17 @@ function initDb() {
|
|||||||
() => {
|
() => {
|
||||||
try { _db.exec('ALTER TABLE reservations ADD COLUMN assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL'); } catch {}
|
try { _db.exec('ALTER TABLE reservations ADD COLUMN assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL'); } catch {}
|
||||||
},
|
},
|
||||||
|
// 24: Assignment participants (who's joining which activity)
|
||||||
|
() => {
|
||||||
|
_db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS assignment_participants (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(assignment_id, user_id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
},
|
||||||
// Future migrations go here (append only, never reorder)
|
// Future migrations go here (append only, never reorder)
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -30,11 +30,19 @@ function getAssignmentWithPlace(assignmentId) {
|
|||||||
WHERE pt.place_id = ?
|
WHERE pt.place_id = ?
|
||||||
`).all(a.place_id);
|
`).all(a.place_id);
|
||||||
|
|
||||||
|
const participants = db.prepare(`
|
||||||
|
SELECT ap.user_id, u.username, u.avatar
|
||||||
|
FROM assignment_participants ap
|
||||||
|
JOIN users u ON ap.user_id = u.id
|
||||||
|
WHERE ap.assignment_id = ?
|
||||||
|
`).all(a.id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: a.id,
|
id: a.id,
|
||||||
day_id: a.day_id,
|
day_id: a.day_id,
|
||||||
order_index: a.order_index,
|
order_index: a.order_index,
|
||||||
notes: a.notes,
|
notes: a.notes,
|
||||||
|
participants,
|
||||||
created_at: a.created_at,
|
created_at: a.created_at,
|
||||||
place: {
|
place: {
|
||||||
id: a.place_id,
|
id: a.place_id,
|
||||||
@@ -105,12 +113,25 @@ router.get('/trips/:tripId/days/:dayId/assignments', authenticate, (req, res) =>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load all participants for this day's assignments in one query
|
||||||
|
const assignmentIds = assignments.map(a => a.id)
|
||||||
|
const allParticipants = assignmentIds.length > 0
|
||||||
|
? db.prepare(`SELECT ap.assignment_id, ap.user_id, u.username, u.avatar FROM assignment_participants ap JOIN users u ON ap.user_id = u.id WHERE ap.assignment_id IN (${assignmentIds.map(() => '?').join(',')})`)
|
||||||
|
.all(...assignmentIds)
|
||||||
|
: []
|
||||||
|
const participantsByAssignment = {}
|
||||||
|
for (const p of allParticipants) {
|
||||||
|
if (!participantsByAssignment[p.assignment_id]) participantsByAssignment[p.assignment_id] = []
|
||||||
|
participantsByAssignment[p.assignment_id].push({ user_id: p.user_id, username: p.username, avatar: p.avatar })
|
||||||
|
}
|
||||||
|
|
||||||
const result = assignments.map(a => {
|
const result = assignments.map(a => {
|
||||||
return {
|
return {
|
||||||
id: a.id,
|
id: a.id,
|
||||||
day_id: a.day_id,
|
day_id: a.day_id,
|
||||||
order_index: a.order_index,
|
order_index: a.order_index,
|
||||||
notes: a.notes,
|
notes: a.notes,
|
||||||
|
participants: participantsByAssignment[a.id] || [],
|
||||||
created_at: a.created_at,
|
created_at: a.created_at,
|
||||||
place: {
|
place: {
|
||||||
id: a.place_id,
|
id: a.place_id,
|
||||||
@@ -241,4 +262,45 @@ router.put('/trips/:tripId/assignments/:id/move', authenticate, (req, res) => {
|
|||||||
broadcast(tripId, 'assignment:moved', { assignment: updated, oldDayId: Number(oldDayId), newDayId: Number(new_day_id) }, req.headers['x-socket-id']);
|
broadcast(tripId, 'assignment:moved', { assignment: updated, oldDayId: Number(oldDayId), newDayId: Number(new_day_id) }, req.headers['x-socket-id']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/trips/:tripId/assignments/:id/participants
|
||||||
|
router.get('/trips/:tripId/assignments/:id/participants', authenticate, (req, res) => {
|
||||||
|
const { tripId, id } = req.params;
|
||||||
|
if (!canAccessTrip(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
|
const participants = db.prepare(`
|
||||||
|
SELECT ap.user_id, u.username, u.avatar
|
||||||
|
FROM assignment_participants ap
|
||||||
|
JOIN users u ON ap.user_id = u.id
|
||||||
|
WHERE ap.assignment_id = ?
|
||||||
|
`).all(id);
|
||||||
|
|
||||||
|
res.json({ participants });
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/trips/:tripId/assignments/:id/participants — set participants (replace all)
|
||||||
|
router.put('/trips/:tripId/assignments/:id/participants', authenticate, (req, res) => {
|
||||||
|
const { tripId, id } = req.params;
|
||||||
|
if (!canAccessTrip(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
|
const { user_ids } = req.body; // array of user IDs, empty array = everyone
|
||||||
|
if (!Array.isArray(user_ids)) return res.status(400).json({ error: 'user_ids must be an array' });
|
||||||
|
|
||||||
|
// Delete existing and insert new
|
||||||
|
db.prepare('DELETE FROM assignment_participants WHERE assignment_id = ?').run(id);
|
||||||
|
if (user_ids.length > 0) {
|
||||||
|
const insert = db.prepare('INSERT OR IGNORE INTO assignment_participants (assignment_id, user_id) VALUES (?, ?)');
|
||||||
|
for (const userId of user_ids) insert.run(id, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const participants = db.prepare(`
|
||||||
|
SELECT ap.user_id, u.username, u.avatar
|
||||||
|
FROM assignment_participants ap
|
||||||
|
JOIN users u ON ap.user_id = u.id
|
||||||
|
WHERE ap.assignment_id = ?
|
||||||
|
`).all(id);
|
||||||
|
|
||||||
|
res.json({ participants });
|
||||||
|
broadcast(Number(tripId), 'assignment:participants', { assignmentId: Number(id), participants }, req.headers['x-socket-id']);
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ router.get('/app-config', (req, res) => {
|
|||||||
has_maps_key: hasGoogleKey,
|
has_maps_key: hasGoogleKey,
|
||||||
oidc_configured: oidcConfigured,
|
oidc_configured: oidcConfigured,
|
||||||
oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined,
|
oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined,
|
||||||
|
allowed_file_types: db.prepare("SELECT value FROM app_settings WHERE key = 'allowed_file_types'").get()?.value || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv',
|
||||||
demo_mode: isDemo,
|
demo_mode: isDemo,
|
||||||
demo_email: isDemo ? 'demo@nomad.app' : undefined,
|
demo_email: isDemo ? 'demo@nomad.app' : undefined,
|
||||||
demo_password: isDemo ? 'demo12345' : undefined,
|
demo_password: isDemo ? 'demo12345' : undefined,
|
||||||
@@ -368,10 +369,13 @@ router.put('/app-settings', authenticate, (req, res) => {
|
|||||||
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user.id);
|
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user.id);
|
||||||
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
|
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
|
||||||
|
|
||||||
const { allow_registration } = req.body;
|
const { allow_registration, allowed_file_types } = req.body;
|
||||||
if (allow_registration !== undefined) {
|
if (allow_registration !== undefined) {
|
||||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', ?)").run(String(allow_registration));
|
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', ?)").run(String(allow_registration));
|
||||||
}
|
}
|
||||||
|
if (allowed_file_types !== undefined) {
|
||||||
|
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', ?)").run(String(allowed_file_types));
|
||||||
|
}
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,18 @@ router.get('/', authenticate, (req, res) => {
|
|||||||
|
|
||||||
// Group assignments by day_id
|
// Group assignments by day_id
|
||||||
const assignmentsByDayId = {};
|
const assignmentsByDayId = {};
|
||||||
|
// Load all participants for all assignments
|
||||||
|
const allAssignmentIds = allAssignments.map(a => a.id)
|
||||||
|
const allParticipants = allAssignmentIds.length > 0
|
||||||
|
? db.prepare(`SELECT ap.assignment_id, ap.user_id, u.username, u.avatar FROM assignment_participants ap JOIN users u ON ap.user_id = u.id WHERE ap.assignment_id IN (${allAssignmentIds.map(() => '?').join(',')})`)
|
||||||
|
.all(...allAssignmentIds)
|
||||||
|
: []
|
||||||
|
const participantsByAssignment = {}
|
||||||
|
for (const p of allParticipants) {
|
||||||
|
if (!participantsByAssignment[p.assignment_id]) participantsByAssignment[p.assignment_id] = []
|
||||||
|
participantsByAssignment[p.assignment_id].push({ user_id: p.user_id, username: p.username, avatar: p.avatar })
|
||||||
|
}
|
||||||
|
|
||||||
for (const a of allAssignments) {
|
for (const a of allAssignments) {
|
||||||
if (!assignmentsByDayId[a.day_id]) assignmentsByDayId[a.day_id] = [];
|
if (!assignmentsByDayId[a.day_id]) assignmentsByDayId[a.day_id] = [];
|
||||||
assignmentsByDayId[a.day_id].push({
|
assignmentsByDayId[a.day_id].push({
|
||||||
@@ -124,6 +136,7 @@ router.get('/', authenticate, (req, res) => {
|
|||||||
day_id: a.day_id,
|
day_id: a.day_id,
|
||||||
order_index: a.order_index,
|
order_index: a.order_index,
|
||||||
notes: a.notes,
|
notes: a.notes,
|
||||||
|
participants: participantsByAssignment[a.id] || [],
|
||||||
created_at: a.created_at,
|
created_at: a.created_at,
|
||||||
place: {
|
place: {
|
||||||
id: a.place_id,
|
id: a.place_id,
|
||||||
|
|||||||
+14
-12
@@ -22,25 +22,27 @@ const storage = multer.diskStorage({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const DEFAULT_ALLOWED_EXTENSIONS = 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv';
|
||||||
|
const BLOCKED_EXTENSIONS = ['.svg', '.html', '.htm', '.xml'];
|
||||||
|
|
||||||
|
function getAllowedExtensions() {
|
||||||
|
try {
|
||||||
|
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'allowed_file_types'").get();
|
||||||
|
return row?.value || DEFAULT_ALLOWED_EXTENSIONS;
|
||||||
|
} catch { return DEFAULT_ALLOWED_EXTENSIONS; }
|
||||||
|
}
|
||||||
|
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage,
|
storage,
|
||||||
limits: { fileSize: 50 * 1024 * 1024 }, // 50MB
|
limits: { fileSize: 50 * 1024 * 1024 }, // 50MB
|
||||||
fileFilter: (req, file, cb) => {
|
fileFilter: (req, file, cb) => {
|
||||||
const allowed = [
|
|
||||||
'application/pdf',
|
|
||||||
'application/msword',
|
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
||||||
'application/vnd.ms-excel',
|
|
||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
||||||
'text/plain',
|
|
||||||
'text/csv',
|
|
||||||
];
|
|
||||||
const ext = path.extname(file.originalname).toLowerCase();
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
const blockedExts = ['.svg', '.html', '.htm', '.xml'];
|
if (BLOCKED_EXTENSIONS.includes(ext) || file.mimetype.includes('svg')) {
|
||||||
if (blockedExts.includes(ext) || file.mimetype.includes('svg')) {
|
|
||||||
return cb(new Error('File type not allowed'));
|
return cb(new Error('File type not allowed'));
|
||||||
}
|
}
|
||||||
if (allowed.includes(file.mimetype) || file.mimetype.startsWith('image/')) {
|
const allowed = getAllowedExtensions().split(',').map(e => e.trim().toLowerCase());
|
||||||
|
const fileExt = ext.replace('.', '');
|
||||||
|
if (allowed.includes(fileExt) || (allowed.includes('*') && !BLOCKED_EXTENSIONS.includes(ext))) {
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
} else {
|
} else {
|
||||||
cb(new Error('File type not allowed'));
|
cb(new Error('File type not allowed'));
|
||||||
|
|||||||
Reference in New Issue
Block a user