Per-assignment times, participant avatar fix, UI improvements

- Times are now per-assignment instead of per-place, so the same place
  on different days can have different times
- Migration 26 adds assignment_time/assignment_end_time columns
- New endpoint PUT /assignments/:id/time for updating assignment times
- Time picker removed from place creation (only shown when editing)
- End-before-start validation disables save button
- Time collision warning shows overlapping activities on the same day
- Fix participant avatars using avatar_url instead of avatar filename
- Rename "Add Place" to "Add Place/Activity" (DE + EN)
- Improve README update instructions with docker inspect tip
This commit is contained in:
Maurice
2026-03-25 16:47:10 +01:00
parent 66e2799870
commit 3bf49d4180
14 changed files with 191 additions and 56 deletions

View File

@@ -447,17 +447,8 @@ export default function PlaceFormModal({
)}
</div>
{/* Time & Reservation */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Visit Time</label>
<input
type="time"
value={formData.place_time}
onChange={e => update('place_time', e.target.value)}
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
</div>
{/* Reservation */}
<div className="grid grid-cols-1 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Reservation</label>
<select

View File

@@ -117,7 +117,7 @@ export default function AssignedPlaceItem({ assignment, dayId, onRemove, onEdit
<div className="flex flex-col gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
{onEdit && (
<button
onClick={() => onEdit(place)}
onClick={() => onEdit(place, assignment.id)}
className="p-1 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded transition-colors"
title="Edit place"
>

View File

@@ -651,7 +651,7 @@ export default function DayPlanSidebar({
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
onContextMenu={e => ctxMenu.open(e, [
onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank') },

View File

@@ -1,10 +1,10 @@
import React, { useState, useEffect, useRef } from 'react'
import React, { useState, useEffect, useRef, useMemo } from 'react'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { mapsApi } from '../../api/client'
import { useAuthStore } from '../../store/authStore'
import { useToast } from '../shared/Toast'
import { Search, Paperclip, X } from 'lucide-react'
import { Search, Paperclip, X, AlertTriangle } from 'lucide-react'
import { useTranslation } from '../../i18n'
import CustomTimePicker from '../shared/CustomTimePicker'
@@ -24,7 +24,7 @@ const DEFAULT_FORM = {
export default function PlaceFormModal({
isOpen, onClose, onSave, place, tripId, categories,
onCategoryCreated,
onCategoryCreated, assignmentId, dayAssignments = [],
}) {
const [form, setForm] = useState(DEFAULT_FORM)
const [mapsSearch, setMapsSearch] = useState('')
@@ -126,6 +126,8 @@ export default function PlaceFormModal({
}
}
const hasTimeError = place && form.place_time && form.end_time && form.place_time.length >= 5 && form.end_time.length >= 5 && form.end_time <= form.place_time
const handleSubmit = async (e) => {
e.preventDefault()
if (!form.name.trim()) {
@@ -293,23 +295,17 @@ export default function PlaceFormModal({
)}
</div>
{/* Time */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.startTime')}</label>
<CustomTimePicker
value={form.place_time}
onChange={v => handleChange('place_time', v)}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.endTime')}</label>
<CustomTimePicker
value={form.end_time}
onChange={v => handleChange('end_time', v)}
/>
</div>
</div>
{/* Time — only shown when editing, not when creating */}
{place && (
<TimeSection
form={form}
handleChange={handleChange}
assignmentId={assignmentId}
dayAssignments={dayAssignments}
hasTimeError={hasTimeError}
t={t}
/>
)}
{/* Website */}
<div>
@@ -364,7 +360,7 @@ export default function PlaceFormModal({
</button>
<button
type="submit"
disabled={isSaving}
disabled={isSaving || hasTimeError}
className="px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
>
{isSaving ? t('common.saving') : place ? t('common.update') : t('common.add')}
@@ -374,3 +370,62 @@ export default function PlaceFormModal({
</Modal>
)
}
function TimeSection({ form, handleChange, assignmentId, dayAssignments, hasTimeError, t }) {
const collisions = useMemo(() => {
if (!assignmentId || !form.place_time || form.place_time.length < 5) return []
// Find the day_id for the current assignment
const current = dayAssignments.find(a => a.id === assignmentId)
if (!current) return []
const myStart = form.place_time
const myEnd = form.end_time && form.end_time.length >= 5 ? form.end_time : null
return dayAssignments.filter(a => {
if (a.id === assignmentId) return false
if (a.day_id !== current.day_id) return false
const aStart = a.place?.place_time
const aEnd = a.place?.end_time
if (!aStart) return false
// Check overlap: two intervals overlap if start < otherEnd AND otherStart < end
const s1 = myStart, e1 = myEnd || myStart
const s2 = aStart, e2 = aEnd || aStart
return s1 < (e2 || '23:59') && s2 < (e1 || '23:59') && s1 !== e2 && s2 !== e1
})
}, [assignmentId, dayAssignments, form.place_time, form.end_time])
return (
<div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.startTime')}</label>
<CustomTimePicker
value={form.place_time}
onChange={v => handleChange('place_time', v)}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.endTime')}</label>
<CustomTimePicker
value={form.end_time}
onChange={v => handleChange('end_time', v)}
/>
</div>
</div>
{hasTimeError && (
<div className="flex items-center gap-1.5 mt-2 px-2.5 py-1.5 rounded-lg text-xs" style={{ background: 'var(--bg-warning, #fef3c7)', color: 'var(--text-warning, #92400e)' }}>
<AlertTriangle size={13} className="shrink-0" />
{t('places.endTimeBeforeStart')}
</div>
)}
{collisions.length > 0 && (
<div className="flex items-start gap-1.5 mt-2 px-2.5 py-1.5 rounded-lg text-xs" style={{ background: 'var(--bg-warning, #fef3c7)', color: 'var(--text-warning, #92400e)' }}>
<AlertTriangle size={13} className="shrink-0 mt-0.5" />
<span>
{t('places.timeCollision')}{' '}
{collisions.map(a => a.place?.name).filter(Boolean).join(', ')}
</span>
</div>
)}
</div>
)
}

View File

@@ -550,7 +550,7 @@ function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticip
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()}
{(member.avatar_url || member.avatar) ? <img src={member.avatar_url || `/uploads/avatars/${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>
@@ -590,7 +590,7 @@ function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticip
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()}
{(member.avatar_url || member.avatar) ? <img src={member.avatar_url || `/uploads/avatars/${member.avatar}`} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : member.username?.[0]?.toUpperCase()}
</div>
{member.username}
</button>