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:
@@ -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)}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
90
client/src/components/shared/ConfirmDialog.jsx
Normal file
90
client/src/components/shared/ConfirmDialog.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user