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:
14
README.md
14
README.md
@@ -131,15 +131,23 @@ docker compose up -d
|
||||
|
||||
### Updating
|
||||
|
||||
**Docker Compose** (recommended):
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
**Docker Run** — use the same volume paths from your original `docker run` command:
|
||||
|
||||
```bash
|
||||
docker pull mauriceboe/nomad
|
||||
docker rm -f nomad
|
||||
docker run -d --name nomad -p 3000:3000 -v /your/data:/app/data -v /your/uploads:/app/uploads --restart unless-stopped mauriceboe/nomad
|
||||
docker run -d --name nomad -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/nomad
|
||||
```
|
||||
|
||||
Or with Docker Compose: `docker compose pull && docker compose up -d`
|
||||
> **Tip:** Not sure which paths you used? Run `docker inspect nomad --format '{{json .Mounts}}'` before removing the container.
|
||||
|
||||
Your data is persisted in the mounted `data` and `uploads` volumes.
|
||||
Your data is persisted in the mounted `data` and `uploads` volumes — updates never touch your existing data.
|
||||
|
||||
### Reverse Proxy (recommended)
|
||||
|
||||
|
||||
@@ -97,6 +97,7 @@ export const assignmentsApi = {
|
||||
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),
|
||||
updateTime: (tripId, id, times) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data),
|
||||
}
|
||||
|
||||
export const packingApi = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -504,7 +504,7 @@ const de = {
|
||||
'dayplan.pdfError': 'Fehler beim PDF-Export',
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Ort hinzufügen',
|
||||
'places.addPlace': 'Ort/Aktivität hinzufügen',
|
||||
'places.assignToDay': 'Zu welchem Tag hinzufügen?',
|
||||
'places.all': 'Alle',
|
||||
'places.unplanned': 'Ungeplant',
|
||||
@@ -529,6 +529,8 @@ const de = {
|
||||
'places.formTime': 'Uhrzeit',
|
||||
'places.startTime': 'Start',
|
||||
'places.endTime': 'Ende',
|
||||
'places.endTimeBeforeStart': 'Endzeit liegt vor der Startzeit',
|
||||
'places.timeCollision': 'Zeitliche Überschneidung mit:',
|
||||
'places.formWebsite': 'Website',
|
||||
'places.formNotesPlaceholder': 'Persönliche Notizen...',
|
||||
'places.formReservation': 'Reservierung',
|
||||
@@ -864,8 +866,8 @@ const de = {
|
||||
'planner.placeN': '{n} Orte',
|
||||
'planner.addNote': 'Notiz hinzufügen',
|
||||
'planner.noEntries': 'Keine Einträge für diesen Tag',
|
||||
'planner.addPlace': 'Ort hinzufügen',
|
||||
'planner.addPlaceShort': '+ Ort hinzufügen',
|
||||
'planner.addPlace': 'Ort/Aktivität hinzufügen',
|
||||
'planner.addPlaceShort': '+ Ort/Aktivität hinzufügen',
|
||||
'planner.resPending': 'Reservierung ausstehend · ',
|
||||
'planner.resConfirmed': 'Reservierung bestätigt · ',
|
||||
'planner.notePlaceholder': 'Notiz\u2026',
|
||||
|
||||
@@ -504,7 +504,7 @@ const en = {
|
||||
'dayplan.pdfError': 'Failed to export PDF',
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Add Place',
|
||||
'places.addPlace': 'Add Place/Activity',
|
||||
'places.assignToDay': 'Add to which day?',
|
||||
'places.all': 'All',
|
||||
'places.unplanned': 'Unplanned',
|
||||
@@ -529,6 +529,8 @@ const en = {
|
||||
'places.formTime': 'Time',
|
||||
'places.startTime': 'Start',
|
||||
'places.endTime': 'End',
|
||||
'places.endTimeBeforeStart': 'End time is before start time',
|
||||
'places.timeCollision': 'Time overlap with:',
|
||||
'places.formWebsite': 'Website',
|
||||
'places.formNotesPlaceholder': 'Personal notes...',
|
||||
'places.formReservation': 'Reservation',
|
||||
@@ -864,8 +866,8 @@ const en = {
|
||||
'planner.placeN': '{n} places',
|
||||
'planner.addNote': 'Add note',
|
||||
'planner.noEntries': 'No entries for this day',
|
||||
'planner.addPlace': 'Add place',
|
||||
'planner.addPlaceShort': '+ Add place',
|
||||
'planner.addPlace': 'Add place/activity',
|
||||
'planner.addPlaceShort': '+ Add place/activity',
|
||||
'planner.resPending': 'Reservation pending · ',
|
||||
'planner.resConfirmed': 'Reservation confirmed · ',
|
||||
'planner.notePlaceholder': 'Note\u2026',
|
||||
|
||||
@@ -96,6 +96,7 @@ export default function TripPlannerPage() {
|
||||
}, [])
|
||||
const [showPlaceForm, setShowPlaceForm] = useState(false)
|
||||
const [editingPlace, setEditingPlace] = useState(null)
|
||||
const [editingAssignmentId, setEditingAssignmentId] = useState(null)
|
||||
const [showTripForm, setShowTripForm] = useState(false)
|
||||
const [showMembersModal, setShowMembersModal] = useState(false)
|
||||
const [showReservationModal, setShowReservationModal] = useState(false)
|
||||
@@ -210,7 +211,14 @@ export default function TripPlannerPage() {
|
||||
const pendingFiles = data._pendingFiles
|
||||
delete data._pendingFiles
|
||||
if (editingPlace) {
|
||||
await tripStore.updatePlace(tripId, editingPlace.id, data)
|
||||
// Always strip time fields from place update — time is per-assignment only
|
||||
const { place_time, end_time, ...placeData } = data
|
||||
await tripStore.updatePlace(tripId, editingPlace.id, placeData)
|
||||
// If editing from assignment context, save time per-assignment
|
||||
if (editingAssignmentId) {
|
||||
await assignmentsApi.updateTime(tripId, editingAssignmentId, { place_time: place_time || null, end_time: end_time || null })
|
||||
await tripStore.refreshDays(tripId)
|
||||
}
|
||||
// Upload pending files with place_id
|
||||
if (pendingFiles?.length > 0) {
|
||||
for (const file of pendingFiles) {
|
||||
@@ -233,7 +241,7 @@ export default function TripPlannerPage() {
|
||||
}
|
||||
toast.success(t('trip.toast.placeAdded'))
|
||||
}
|
||||
}, [editingPlace, tripId, tripStore, toast])
|
||||
}, [editingPlace, editingAssignmentId, tripId, tripStore, toast])
|
||||
|
||||
const handleDeletePlace = useCallback(async (placeId) => {
|
||||
if (!confirm(t('trip.confirm.deletePlace'))) return
|
||||
@@ -476,7 +484,7 @@ export default function TripPlannerPage() {
|
||||
onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true) }}
|
||||
onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null) }}
|
||||
onRemoveAssignment={handleRemoveAssignment}
|
||||
onEditPlace={(place) => { setEditingPlace(place); setShowPlaceForm(true) }}
|
||||
onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }}
|
||||
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
||||
accommodations={tripAccommodations}
|
||||
/>
|
||||
@@ -535,7 +543,7 @@ export default function TripPlannerPage() {
|
||||
onPlaceClick={handlePlaceClick}
|
||||
onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true) }}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onEditPlace={(place) => { setEditingPlace(place); setShowPlaceForm(true) }}
|
||||
onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true) }}
|
||||
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
||||
/>
|
||||
</div>
|
||||
@@ -588,7 +596,18 @@ export default function TripPlannerPage() {
|
||||
assignments={assignments}
|
||||
reservations={reservations}
|
||||
onClose={() => setSelectedPlaceId(null)}
|
||||
onEdit={() => { setEditingPlace(selectedPlace); setShowPlaceForm(true) }}
|
||||
onEdit={() => {
|
||||
// When editing from assignment context, use assignment-level times
|
||||
if (selectedAssignmentId) {
|
||||
const assignmentObj = Object.values(assignments).flat().find(a => a.id === selectedAssignmentId)
|
||||
const placeWithAssignmentTimes = assignmentObj?.place ? { ...selectedPlace, place_time: assignmentObj.place.place_time, end_time: assignmentObj.place.end_time } : selectedPlace
|
||||
setEditingPlace(placeWithAssignmentTimes)
|
||||
} else {
|
||||
setEditingPlace(selectedPlace)
|
||||
}
|
||||
setEditingAssignmentId(selectedAssignmentId || null)
|
||||
setShowPlaceForm(true)
|
||||
}}
|
||||
onDelete={() => handleDeletePlace(selectedPlace.id)}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onRemoveAssignment={handleRemoveAssignment}
|
||||
@@ -683,7 +702,7 @@ export default function TripPlannerPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null) }} onSave={handleSavePlace} place={editingPlace} tripId={tripId} categories={categories} onCategoryCreated={cat => tripStore.addCategory?.(cat)} />
|
||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null) }} onSave={handleSavePlace} place={editingPlace} assignmentId={editingAssignmentId} dayAssignments={editingAssignmentId ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripStore.addCategory?.(cat)} />
|
||||
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripStore.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
|
||||
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
|
||||
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={(fd) => tripStore.addFile(tripId, fd)} onFileDelete={(id) => tripStore.deleteFile(tripId, id)} />
|
||||
|
||||
@@ -275,6 +275,21 @@ export const useTripStore = create((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
refreshDays: async (tripId) => {
|
||||
try {
|
||||
const daysData = await daysApi.list(tripId)
|
||||
const assignmentsMap = {}
|
||||
const dayNotesMap = {}
|
||||
for (const day of daysData.days) {
|
||||
assignmentsMap[String(day.id)] = day.assignments || []
|
||||
dayNotesMap[String(day.id)] = day.notes_items || []
|
||||
}
|
||||
set({ days: daysData.days, assignments: assignmentsMap, dayNotes: dayNotesMap })
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh days:', err)
|
||||
}
|
||||
},
|
||||
|
||||
refreshPlaces: async (tripId) => {
|
||||
try {
|
||||
const data = await placesApi.list(tripId)
|
||||
@@ -302,7 +317,7 @@ export const useTripStore = create((set, get) => ({
|
||||
assignments: Object.fromEntries(
|
||||
Object.entries(state.assignments).map(([dayId, items]) => [
|
||||
dayId,
|
||||
items.map(a => a.place?.id === placeId ? { ...a, place: data.place } : a)
|
||||
items.map(a => a.place?.id === placeId ? { ...a, place: { ...data.place, place_time: a.place.place_time, end_time: a.place.end_time } } : a)
|
||||
])
|
||||
),
|
||||
}))
|
||||
|
||||
@@ -556,6 +556,19 @@ function initDb() {
|
||||
_db.prepare("INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('collab', 'Collab', 'Notes, polls, and live chat for trip collaboration', 'trip', 'Users', 0, 6)").run();
|
||||
} catch {}
|
||||
},
|
||||
// 26: Per-assignment times (instead of shared place times)
|
||||
() => {
|
||||
try { _db.exec('ALTER TABLE day_assignments ADD COLUMN assignment_time TEXT'); } catch {}
|
||||
try { _db.exec('ALTER TABLE day_assignments ADD COLUMN assignment_end_time TEXT'); } catch {}
|
||||
// Copy existing place times to assignments as initial values
|
||||
try {
|
||||
_db.exec(`
|
||||
UPDATE day_assignments SET
|
||||
assignment_time = (SELECT place_time FROM places WHERE places.id = day_assignments.place_id),
|
||||
assignment_end_time = (SELECT end_time FROM places WHERE places.id = day_assignments.place_id)
|
||||
`);
|
||||
} catch {}
|
||||
},
|
||||
// Future migrations go here (append only, never reorder)
|
||||
];
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ function getAssignmentWithPlace(assignmentId) {
|
||||
const a = db.prepare(`
|
||||
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
|
||||
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
|
||||
p.place_time, p.end_time, p.duration_minutes, p.notes as place_notes,
|
||||
COALESCE(da.assignment_time, p.place_time) as place_time,
|
||||
COALESCE(da.assignment_end_time, p.end_time) as end_time,
|
||||
p.duration_minutes, p.notes as place_notes,
|
||||
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
|
||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM day_assignments da
|
||||
@@ -87,7 +89,9 @@ router.get('/trips/:tripId/days/:dayId/assignments', authenticate, (req, res) =>
|
||||
const assignments = db.prepare(`
|
||||
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
|
||||
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
|
||||
p.place_time, p.end_time, p.duration_minutes, p.notes as place_notes,
|
||||
COALESCE(da.assignment_time, p.place_time) as place_time,
|
||||
COALESCE(da.assignment_end_time, p.end_time) as end_time,
|
||||
p.duration_minutes, p.notes as place_notes,
|
||||
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
|
||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM day_assignments da
|
||||
@@ -277,6 +281,27 @@ router.get('/trips/:tripId/assignments/:id/participants', authenticate, (req, re
|
||||
res.json({ participants });
|
||||
});
|
||||
|
||||
// PUT /api/trips/:tripId/assignments/:id/time — update per-assignment time
|
||||
router.put('/trips/:tripId/assignments/:id/time', 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 assignment = db.prepare(`
|
||||
SELECT da.* FROM day_assignments da
|
||||
JOIN days d ON da.day_id = d.id
|
||||
WHERE da.id = ? AND d.trip_id = ?
|
||||
`).get(id, tripId);
|
||||
if (!assignment) return res.status(404).json({ error: 'Assignment not found' });
|
||||
|
||||
const { place_time, end_time } = req.body;
|
||||
db.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?')
|
||||
.run(place_time ?? null, end_time ?? null, id);
|
||||
|
||||
const updated = getAssignmentWithPlace(id);
|
||||
res.json({ assignment: updated });
|
||||
broadcast(Number(tripId), 'assignment:updated', { assignment: updated }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -13,7 +13,9 @@ function getAssignmentsForDay(dayId) {
|
||||
const assignments = db.prepare(`
|
||||
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
|
||||
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
|
||||
p.place_time, p.end_time, p.duration_minutes, p.notes as place_notes,
|
||||
COALESCE(da.assignment_time, p.place_time) as place_time,
|
||||
COALESCE(da.assignment_end_time, p.end_time) as end_time,
|
||||
p.duration_minutes, p.notes as place_notes,
|
||||
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
|
||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM day_assignments da
|
||||
@@ -89,7 +91,9 @@ router.get('/', authenticate, (req, res) => {
|
||||
const allAssignments = db.prepare(`
|
||||
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
|
||||
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
|
||||
p.place_time, p.end_time, p.duration_minutes, p.notes as place_notes,
|
||||
COALESCE(da.assignment_time, p.place_time) as place_time,
|
||||
COALESCE(da.assignment_end_time, p.end_time) as end_time,
|
||||
p.duration_minutes, p.notes as place_notes,
|
||||
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
|
||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM day_assignments da
|
||||
|
||||
Reference in New Issue
Block a user