Fix double delete confirm, inline place name editing, preserve assignments on trip extend

- Replace double browser confirm() with single custom ConfirmDialog for place deletion
- Add inline name editing via double-click in PlaceInspector
- Rewrite generateDays() to preserve existing days/assignments when extending trips
- Use UTC date math to avoid timezone-related day count errors
- Add missing collab.chat.emptyDesc translation (en/de)
This commit is contained in:
Maurice
2026-03-26 22:08:44 +01:00
parent feb2a8a5f2
commit 35275e209d
8 changed files with 226 additions and 19 deletions

View File

@@ -674,7 +674,7 @@ export default function DayPlanSidebar({
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') },
{ divider: true },
onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => { if (confirm(t('trip.confirm.deletePlace'))) onDeletePlace(place.id) } },
onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
])}
onMouseEnter={() => setHoveredId(assignment.id)}
onMouseLeave={() => setHoveredId(null)}

View File

@@ -100,16 +100,39 @@ function formatFileSize(bytes) {
export default function PlaceInspector({
place, categories, days, selectedDayId, selectedAssignmentId, assignments, reservations = [],
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
files, onFileUpload, tripMembers = [], onSetParticipants,
files, onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace,
}) {
const { t, locale, language } = useTranslation()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const [hoursExpanded, setHoursExpanded] = useState(false)
const [filesExpanded, setFilesExpanded] = useState(false)
const [isUploading, setIsUploading] = useState(false)
const [editingName, setEditingName] = useState(false)
const [nameValue, setNameValue] = useState('')
const nameInputRef = useRef(null)
const fileInputRef = useRef(null)
const googleDetails = useGoogleDetails(place?.google_place_id, language)
const startNameEdit = () => {
if (!onUpdatePlace) return
setNameValue(place.name || '')
setEditingName(true)
setTimeout(() => nameInputRef.current?.focus(), 0)
}
const commitNameEdit = () => {
if (!editingName) return
const trimmed = nameValue.trim()
setEditingName(false)
if (!trimmed || trimmed === place.name) return
onUpdatePlace(place.id, { name: trimmed })
}
const handleNameKeyDown = (e) => {
if (e.key === 'Enter') { e.preventDefault(); commitNameEdit() }
if (e.key === 'Escape') setEditingName(false)
}
if (!place) return null
const category = categories?.find(c => c.id === place.category_id)
@@ -192,7 +215,21 @@ export default function PlaceInspector({
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<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>
{editingName ? (
<input
ref={nameInputRef}
value={nameValue}
onChange={e => setNameValue(e.target.value)}
onBlur={commitNameEdit}
onKeyDown={handleNameKeyDown}
style={{ fontWeight: 600, fontSize: 15, color: 'var(--text-primary)', lineHeight: '1.3', background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)', borderRadius: 6, padding: '1px 6px', fontFamily: 'inherit', outline: 'none', width: '100%' }}
/>
) : (
<span
onDoubleClick={startNameEdit}
style={{ fontWeight: 600, fontSize: 15, color: 'var(--text-primary)', lineHeight: '1.3', cursor: onUpdatePlace ? 'text' : 'default' }}
>{place.name}</span>
)}
{category && (() => {
const CatIcon = getCategoryIcon(category.icon)
return (

View File

@@ -146,7 +146,7 @@ export default function PlacesSidebar({
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') },
{ divider: true },
onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => { if (confirm(t('trip.confirm.deletePlace'))) onDeletePlace(place.id) } },
onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
])}
style={{
display: 'flex', alignItems: 'center', gap: 10,

View File

@@ -0,0 +1,90 @@
import React, { useEffect, useCallback } from 'react'
import { AlertTriangle } from 'lucide-react'
import { useTranslation } from '../../i18n'
export default function ConfirmDialog({
isOpen,
onClose,
onConfirm,
title,
message,
confirmLabel,
cancelLabel,
danger = true,
}) {
const { t } = useTranslation()
const handleEsc = useCallback((e) => {
if (e.key === 'Escape') onClose()
}, [onClose])
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleEsc)
}
return () => document.removeEventListener('keydown', handleEsc)
}, [isOpen, handleEsc])
if (!isOpen) return null
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center px-4"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
onClick={onClose}
>
<div
className="rounded-2xl shadow-2xl w-full max-w-sm p-6"
style={{
animation: 'modalIn 0.2s ease-out forwards',
background: 'var(--bg-card)',
}}
onClick={e => e.stopPropagation()}
>
<div className="flex items-start gap-4">
{danger && (
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-red-600" />
</div>
)}
<div className="flex-1">
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>
{title || t('common.confirm')}
</h3>
<p className="mt-1 text-sm" style={{ color: 'var(--text-secondary)' }}>
{message}
</p>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors"
style={{
color: 'var(--text-secondary)',
border: '1px solid var(--border-secondary)',
}}
>
{cancelLabel || t('common.cancel')}
</button>
<button
onClick={() => { onConfirm(); onClose() }}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors text-white ${
danger ? 'bg-red-600 hover:bg-red-700' : 'bg-blue-600 hover:bg-blue-700'
}`}
>
{confirmLabel || t('common.delete')}
</button>
</div>
</div>
<style>{`
@keyframes modalIn {
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
`}</style>
</div>
)
}

View File

@@ -956,6 +956,7 @@ const de = {
'collab.chat.placeholder': 'Nachricht eingeben...',
'collab.chat.empty': 'Starte die Unterhaltung',
'collab.chat.emptyHint': 'Nachrichten werden mit allen Reiseteilnehmern geteilt',
'collab.chat.emptyDesc': 'Teile Ideen, Pläne und Updates mit deiner Reisegruppe',
'collab.chat.today': 'Heute',
'collab.chat.yesterday': 'Gestern',
'collab.chat.deletedMessage': 'hat eine Nachricht gelöscht',

View File

@@ -956,6 +956,7 @@ const en = {
'collab.chat.placeholder': 'Type a message...',
'collab.chat.empty': 'Start the conversation',
'collab.chat.emptyHint': 'Messages are shared with all trip members',
'collab.chat.emptyDesc': 'Share ideas, plans, and updates with your travel group',
'collab.chat.today': 'Today',
'collab.chat.yesterday': 'Yesterday',
'collab.chat.deletedMessage': 'deleted a message',

View File

@@ -24,6 +24,7 @@ import { useTranslation } from '../i18n'
import { joinTrip, leaveTrip, addListener, removeListener } from '../api/websocket'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi } from '../api/client'
import { calculateRoute } from '../components/Map/RouteCalculator'
import ConfirmDialog from '../components/shared/ConfirmDialog'
const MIN_SIDEBAR = 200
const MAX_SIDEBAR = 520
@@ -111,6 +112,7 @@ export default function TripPlannerPage() {
const [routeSegments, setRouteSegments] = useState([]) // { from, to, walkingText, drivingText }
const [fitKey, setFitKey] = useState(0)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(null) // 'left' | 'right' | null
const [deletePlaceId, setDeletePlaceId] = useState(null)
// Load trip + files (needed for place inspector file section)
useEffect(() => {
@@ -278,14 +280,18 @@ export default function TripPlannerPage() {
}
}, [editingPlace, editingAssignmentId, tripId, tripStore, toast])
const handleDeletePlace = useCallback(async (placeId) => {
if (!confirm(t('trip.confirm.deletePlace'))) return
const handleDeletePlace = useCallback((placeId) => {
setDeletePlaceId(placeId)
}, [])
const confirmDeletePlace = useCallback(async () => {
if (!deletePlaceId) return
try {
await tripStore.deletePlace(tripId, placeId)
if (selectedPlaceId === placeId) setSelectedPlaceId(null)
await tripStore.deletePlace(tripId, deletePlaceId)
if (selectedPlaceId === deletePlaceId) setSelectedPlaceId(null)
toast.success(t('trip.toast.placeDeleted'))
} catch (err) { toast.error(err.message) }
}, [tripId, tripStore, toast, selectedPlaceId])
}, [deletePlaceId, tripId, tripStore, toast, selectedPlaceId])
const handleAssignToDay = useCallback(async (placeId, dayId, position) => {
const target = dayId || selectedDayId
@@ -650,6 +656,7 @@ export default function TripPlannerPage() {
}))
} catch {}
}}
onUpdatePlace={async (placeId, data) => { try { await tripStore.updatePlace(tripId, placeId, data) } catch (err) { toast.error(err.message) } }}
/>
)}
@@ -729,6 +736,13 @@ export default function TripPlannerPage() {
<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)} />
<ConfirmDialog
isOpen={!!deletePlaceId}
onClose={() => setDeletePlaceId(null)}
onConfirm={confirmDeletePlace}
title={t('common.delete')}
message={t('trip.confirm.deletePlace')}
/>
</div>
)
}

View File

@@ -46,20 +46,84 @@ const TRIP_SELECT = `
`;
function generateDays(tripId, startDate, endDate) {
db.prepare('DELETE FROM days WHERE trip_id = ?').run(tripId);
const existing = db.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ?').all(tripId);
if (!startDate || !endDate) {
const insert = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)');
for (let i = 1; i <= 7; i++) insert.run(tripId, i);
// No dates — keep up to 7 dateless days, reuse existing ones
const datelessExisting = existing.filter(d => !d.date).sort((a, b) => a.day_number - b.day_number);
// Remove days with dates (they no longer apply)
const withDates = existing.filter(d => d.date);
if (withDates.length > 0) {
db.prepare(`DELETE FROM days WHERE trip_id = ? AND date IS NOT NULL`).run(tripId);
}
// Ensure exactly 7 dateless days
const needed = 7 - datelessExisting.length;
if (needed > 0) {
const insert = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)');
for (let i = 0; i < needed; i++) insert.run(tripId, datelessExisting.length + i + 1);
} else if (needed < 0) {
// Too many dateless days — remove extras (highest day_number first to preserve earlier assignments)
const toRemove = datelessExisting.slice(7);
const del = db.prepare('DELETE FROM days WHERE id = ?');
for (const d of toRemove) del.run(d.id);
}
// Renumber — use negative temp values first to avoid UNIQUE conflicts
const remaining = db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId);
const tmpUpd = db.prepare('UPDATE days SET day_number = ? WHERE id = ?');
remaining.forEach((d, i) => tmpUpd.run(-(i + 1), d.id));
remaining.forEach((d, i) => tmpUpd.run(i + 1, d.id));
return;
}
const start = new Date(startDate);
const end = new Date(endDate);
const numDays = Math.min(Math.floor((end - start) / 86400000) + 1, 90);
const insert = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)');
// Use pure string-based date math to avoid timezone/DST issues
const [sy, sm, sd] = startDate.split('-').map(Number);
const [ey, em, ed] = endDate.split('-').map(Number);
const startMs = Date.UTC(sy, sm - 1, sd);
const endMs = Date.UTC(ey, em - 1, ed);
const numDays = Math.min(Math.floor((endMs - startMs) / 86400000) + 1, 90);
// Build target dates
const targetDates = [];
for (let i = 0; i < numDays; i++) {
const d = new Date(start);
d.setDate(start.getDate() + i);
insert.run(tripId, i + 1, d.toISOString().split('T')[0]);
const d = new Date(startMs + i * 86400000);
const yyyy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
const dd = String(d.getUTCDate()).padStart(2, '0');
targetDates.push(`${yyyy}-${mm}-${dd}`);
}
// Index existing days by date
const existingByDate = new Map();
for (const d of existing) {
if (d.date) existingByDate.set(d.date, d);
}
const targetDateSet = new Set(targetDates);
// Delete days whose date is no longer in the new range
const toDelete = existing.filter(d => d.date && !targetDateSet.has(d.date));
// Also delete dateless days (they are replaced by dated ones)
const datelessToDelete = existing.filter(d => !d.date);
const del = db.prepare('DELETE FROM days WHERE id = ?');
for (const d of [...toDelete, ...datelessToDelete]) del.run(d.id);
// Move all kept days to negative day_numbers to avoid UNIQUE conflicts
const setTemp = db.prepare('UPDATE days SET day_number = ? WHERE id = ?');
const kept = existing.filter(d => d.date && targetDateSet.has(d.date));
for (let i = 0; i < kept.length; i++) setTemp.run(-(i + 1), kept[i].id);
// Now assign correct day_numbers and insert missing days
const insert = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)');
const update = db.prepare('UPDATE days SET day_number = ? WHERE id = ?');
for (let i = 0; i < targetDates.length; i++) {
const date = targetDates[i];
const ex = existingByDate.get(date);
if (ex) {
update.run(i + 1, ex.id);
} else {
insert.run(tripId, i + 1, date);
}
}
}