mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
2f6f921aef
Every row of the reorder dialog, and of the days sheet on a phone, now has a delete button. It first asks, and the question lists what goes with the day: planned places stay in the place list, notes and day texts are deleted, bookings stay without a day, a stay that checks in or out on the day is cancelled with its booking and expense (shown in the danger tone), and on a dated trip the later days and their bookings move one date earlier or the trip ends a day earlier. The server side moved from a bare DELETE into DayRemovalService, which REST, the delete_day tool and the plugin RPC all use. It closes the numbering, keeps the dates on their positions like a reorder, re-stamps the moved bookings, shifts the road trip boundaries, cancels touching stays through the accommodations domain, refuses the last day of a trip, and fans out day:deleted, day:reordered and trip:updated so collaborators renumber and see the new day count and end date. The trip query moved into a leaf file so the days domain can read it without a module cycle. On the client the delete is online only, runs optimistically through the days slice with the same reducer collaborators apply, and the ConfirmDialog gained a slot for extra content, an Escape that only closes itself, and the danger and accent tokens.
167 lines
7.3 KiB
TypeScript
167 lines
7.3 KiB
TypeScript
import { useState } from 'react'
|
|
import { GripVertical, ArrowUp, ArrowDown, Plus, Trash2 } from 'lucide-react'
|
|
import Modal from '../shared/Modal'
|
|
import Tooltip from '../shared/Tooltip'
|
|
import { useNetworkMode } from '../../hooks/useNetworkMode'
|
|
import { dayLabel } from '../../utils/dayLabel'
|
|
import { deleteDayBlockedReason } from '../../utils/dayDeleteImpact'
|
|
import type { Day } from '../../types'
|
|
|
|
interface DayReorderPopupProps {
|
|
isOpen: boolean
|
|
days: Day[]
|
|
t: (key: string, params?: Record<string, any>) => string
|
|
locale: string
|
|
onReorder: (orderedIds: number[]) => void
|
|
onAddDay: () => void
|
|
/** Asks to delete a day; the planner opens the question. Without it rows have no delete button. */
|
|
onDeleteDay?: (dayId: number) => void
|
|
onClose: () => void
|
|
}
|
|
|
|
/**
|
|
* Modal for moving whole days around: drag a row by its grip or use the up/down
|
|
* arrows, add a day at the end, or delete one. Day headers stay untouched, so
|
|
* this is the single surface for ordering. Reorders are applied optimistically
|
|
* by the store, so the list reflects each move immediately. A delete only asks:
|
|
* the planner shows what goes with the day before anything happens.
|
|
*/
|
|
export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay, onDeleteDay, onClose }: DayReorderPopupProps) {
|
|
const [dragIndex, setDragIndex] = useState<number | null>(null)
|
|
const [overIndex, setOverIndex] = useState<number | null>(null)
|
|
const { offline } = useNetworkMode()
|
|
|
|
const ordered = [...days].sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
|
|
const deleteBlocked = deleteDayBlockedReason(ordered.length, offline, t)
|
|
|
|
const move = (from: number, to: number) => {
|
|
if (to < 0 || to >= ordered.length || from === to) return
|
|
const ids = ordered.map(d => d.id)
|
|
const [moved] = ids.splice(from, 1)
|
|
ids.splice(to, 0, moved)
|
|
onReorder(ids)
|
|
}
|
|
|
|
const cellBtn = {
|
|
display: 'grid', placeItems: 'center', width: 28, height: 28,
|
|
border: '1px solid var(--border-faint)', borderRadius: 7,
|
|
background: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 0,
|
|
} as const
|
|
|
|
return (
|
|
<Modal
|
|
isOpen={isOpen}
|
|
onClose={onClose}
|
|
title={t('dayplan.reorderTitle')}
|
|
size="md"
|
|
footer={
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
|
<button type="button"
|
|
onClick={onClose}
|
|
style={{
|
|
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
|
|
border: '1px solid var(--border-primary)', background: 'none',
|
|
color: 'var(--text-muted)', cursor: 'pointer', fontFamily: 'inherit',
|
|
}}
|
|
>
|
|
{t('common.close')}
|
|
</button>
|
|
<button type="button"
|
|
onClick={onAddDay}
|
|
className="bg-accent text-accent-text"
|
|
style={{
|
|
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px',
|
|
borderRadius: 8, border: 'none', fontSize: 13, fontWeight: 500,
|
|
cursor: 'pointer', fontFamily: 'inherit',
|
|
}}
|
|
>
|
|
<Plus size={15} strokeWidth={2} />
|
|
{t('dayplan.addDay')}
|
|
</button>
|
|
</div>
|
|
}
|
|
>
|
|
<p style={{ margin: '0 0 14px', fontSize: 12.5, color: 'var(--text-faint)', lineHeight: 1.4 }}>
|
|
{t('dayplan.reorderHint')}
|
|
</p>
|
|
|
|
{/* The popup is a modal, so it portals out of the planner and has to opt
|
|
into the long-press drag itself (#1616). Without this a finger only
|
|
selects the row text. */}
|
|
<div data-touch-drag style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
{ordered.map((day, index) => (
|
|
<div
|
|
key={day.id}
|
|
draggable
|
|
onDragStart={() => setDragIndex(index)}
|
|
onDragEnd={() => { setDragIndex(null); setOverIndex(null) }}
|
|
onDragOver={e => { e.preventDefault(); if (overIndex !== index) setOverIndex(index) }}
|
|
onDrop={e => {
|
|
e.preventDefault()
|
|
if (dragIndex !== null && dragIndex !== index) move(dragIndex, index)
|
|
setDragIndex(null); setOverIndex(null)
|
|
}}
|
|
style={{
|
|
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px',
|
|
borderRadius: 9,
|
|
border: '1px solid var(--border-faint)',
|
|
background: overIndex === index && dragIndex !== null && dragIndex !== index ? 'var(--bg-hover)' : 'var(--bg-card, white)',
|
|
opacity: dragIndex === index ? 0.5 : 1,
|
|
outline: overIndex === index && dragIndex !== null && dragIndex !== index ? '2px dashed var(--border-primary)' : 'none',
|
|
outlineOffset: -2,
|
|
}}
|
|
>
|
|
<GripVertical size={15} strokeWidth={1.8} style={{ cursor: 'grab', color: 'var(--text-faint)', flexShrink: 0 }} />
|
|
<span style={{
|
|
flexShrink: 0, width: 24, height: 24, borderRadius: '50%',
|
|
background: 'var(--bg-hover)', color: 'var(--text-muted)',
|
|
display: 'grid', placeItems: 'center', fontSize: 11, fontWeight: 700,
|
|
}}>
|
|
{index + 1}
|
|
</span>
|
|
<span style={{ flex: 1, minWidth: 0, fontSize: 13.5, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{dayLabel(day, index, t, locale)}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => move(index, index - 1)}
|
|
disabled={index === 0}
|
|
aria-label={t('dayplan.moveUp')}
|
|
style={{ ...cellBtn, opacity: index === 0 ? 0.35 : 1, cursor: index === 0 ? 'default' : 'pointer' }}
|
|
>
|
|
<ArrowUp size={14} strokeWidth={2} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => move(index, index + 1)}
|
|
disabled={index === ordered.length - 1}
|
|
aria-label={t('dayplan.moveDown')}
|
|
style={{ ...cellBtn, opacity: index === ordered.length - 1 ? 0.35 : 1, cursor: index === ordered.length - 1 ? 'default' : 'pointer' }}
|
|
>
|
|
<ArrowDown size={14} strokeWidth={2} />
|
|
</button>
|
|
{onDeleteDay && (
|
|
<>
|
|
<span aria-hidden="true" className="mx-0.5 h-5 w-px flex-shrink-0 bg-edge-faint" />
|
|
<Tooltip label={deleteBlocked ?? t('dayplan.deleteDay')} placement="left">
|
|
<span className="inline-flex flex-shrink-0">
|
|
<button
|
|
type="button"
|
|
onClick={() => onDeleteDay(day.id)}
|
|
disabled={!!deleteBlocked}
|
|
aria-label={t('dayplan.deleteDay')}
|
|
className="grid h-7 w-7 place-items-center rounded-[7px] border border-edge-faint text-content-muted transition-colors hover:bg-danger-soft hover:text-danger focus-visible:bg-danger-soft focus-visible:text-danger disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-content-muted"
|
|
>
|
|
<Trash2 size={14} strokeWidth={2} />
|
|
</button>
|
|
</span>
|
|
</Tooltip>
|
|
</>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Modal>
|
|
)
|
|
}
|