mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 06:11:45 +00:00
v2.5.7: Reservation overhaul, Day Detail Panel, i18n, paste support, auto dark mode
BREAKING: Reservations have been completely rebuilt. Existing place-level reservations are no longer used. All reservations must be re-created via the Bookings tab. Your trips, places, and other data are unaffected. Reservation System (rebuilt from scratch): - Reservations now link to specific day assignments instead of places - Same place on different days can have independent reservations - New assignment picker in booking modal (grouped by day, searchable) - Removed day/place dropdowns from booking form - Reservation badges in day plan sidebar with type-specific icons - Reservation details in place inspector (only for selected assignment) - Reservation summary in day detail panel Day Detail Panel (new): - Opens on day click in the sidebar - Detailed weather: hourly forecast, precipitation, wind, sunrise/sunset - Historical climate averages for dates beyond 16 days - Accommodation management with check-in/check-out, confirmation number - Hotel assignment across multiple days with day range picker - Reservation overview for the day Places: - Places can now be assigned to the same day multiple times - Start time + end time fields (replaces single time field) - Map badges show multiple position numbers (e.g. "1 · 4") - Route optimization fixed for duplicate places - File attachments during place editing (not just creation) - Cover image upload during trip creation (not just editing) - Paste support (Ctrl+V) for images in trip, place, and file forms Internationalization: - 200+ hardcoded German strings translated to i18n (EN + DE) - Server error messages in English - Category seeds in English for new installations - All planner, register, photo, packing components translated UI/UX: - Auto dark mode (follows system preference, configurable in settings) - Navbar toggle switches light/dark (overrides auto) - Sidebar minimize buttons z-index fixed - Transport mode selector removed from day plan - CustomSelect supports grouped headers (isHeader option) - Optimistic updates for day notes (instant feedback) - Booking cards redesigned with type-colored headers and structured details Weather: - Wind speed in mph when using Fahrenheit setting - Weather description language matches app language Admin: - Weather info panel replaces OpenWeatherMap key input - "Recommended" badge styling updated
This commit is contained in:
@@ -1,160 +1,52 @@
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { CustomDateTimePicker } from '../shared/CustomDateTimePicker'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import {
|
||||
Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, MapPin,
|
||||
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, MapPinned, X, Users,
|
||||
ExternalLink, BookMarked, Lightbulb,
|
||||
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users,
|
||||
ExternalLink, BookMarked, Lightbulb, Link2, Clock,
|
||||
} from 'lucide-react'
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
|
||||
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel },
|
||||
{ value: 'restaurant', labelKey: 'reservations.type.restaurant', Icon: Utensils },
|
||||
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
|
||||
{ value: 'car', labelKey: 'reservations.type.car', Icon: Car },
|
||||
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship },
|
||||
{ value: 'event', labelKey: 'reservations.type.event', Icon: Ticket },
|
||||
{ value: 'tour', labelKey: 'reservations.type.tour', Icon: Users },
|
||||
{ value: 'other', labelKey: 'reservations.type.other', Icon: FileText },
|
||||
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane, color: '#3b82f6' },
|
||||
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel, color: '#8b5cf6' },
|
||||
{ value: 'restaurant', labelKey: 'reservations.type.restaurant', Icon: Utensils, color: '#ef4444' },
|
||||
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train, color: '#06b6d4' },
|
||||
{ value: 'car', labelKey: 'reservations.type.car', Icon: Car, color: '#6b7280' },
|
||||
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship, color: '#0ea5e9' },
|
||||
{ value: 'event', labelKey: 'reservations.type.event', Icon: Ticket, color: '#f59e0b' },
|
||||
{ value: 'tour', labelKey: 'reservations.type.tour', Icon: Users, color: '#10b981' },
|
||||
{ value: 'other', labelKey: 'reservations.type.other', Icon: FileText, color: '#6b7280' },
|
||||
]
|
||||
|
||||
function typeIcon(type) {
|
||||
return (TYPE_OPTIONS.find(t => t.value === type) || TYPE_OPTIONS[TYPE_OPTIONS.length - 1]).Icon
|
||||
}
|
||||
function typeLabelKey(type) {
|
||||
return (TYPE_OPTIONS.find(t => t.value === type) || TYPE_OPTIONS[TYPE_OPTIONS.length - 1]).labelKey
|
||||
function getType(type) {
|
||||
return TYPE_OPTIONS.find(t => t.value === type) || TYPE_OPTIONS[TYPE_OPTIONS.length - 1]
|
||||
}
|
||||
|
||||
function formatDateTimeWithLocale(str, locale, timeFormat) {
|
||||
if (!str) return null
|
||||
const d = new Date(str)
|
||||
if (isNaN(d)) return str
|
||||
const datePart = d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'long' })
|
||||
const h = d.getHours(), m = d.getMinutes()
|
||||
let timePart
|
||||
if (timeFormat === '12h') {
|
||||
const period = h >= 12 ? 'PM' : 'AM'
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||
timePart = `${h12}:${String(m).padStart(2, '0')} ${period}`
|
||||
} else {
|
||||
timePart = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
||||
if (locale?.startsWith('de')) timePart += ' Uhr'
|
||||
}
|
||||
return `${datePart} · ${timePart}`
|
||||
}
|
||||
|
||||
const inputStyle = {
|
||||
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
padding: '8px 12px', fontSize: 13.5, fontFamily: 'inherit',
|
||||
outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)', background: 'var(--bg-card)',
|
||||
}
|
||||
const labelStyle = { display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 5 }
|
||||
|
||||
function PlaceReservationEditModal({ item, tripId, onClose }) {
|
||||
const { updatePlace } = useTripStore()
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
const [form, setForm] = useState({
|
||||
reservation_status: item.status === 'confirmed' ? 'confirmed' : 'pending',
|
||||
reservation_datetime: item.reservation_time ? item.reservation_time.slice(0, 16) : '',
|
||||
place_time: item.place_time || '',
|
||||
reservation_notes: item.notes || '',
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const set = (f, v) => setForm(p => ({ ...p, [f]: v }))
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await updatePlace(tripId, item.placeId, {
|
||||
reservation_status: form.reservation_status,
|
||||
reservation_datetime: form.reservation_datetime || null,
|
||||
place_time: form.place_time || null,
|
||||
reservation_notes: form.reservation_notes || null,
|
||||
})
|
||||
toast.success(t('reservations.toast.updated'))
|
||||
onClose()
|
||||
} catch {
|
||||
toast.error(t('reservations.toast.saveError'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
function buildAssignmentLookup(days, assignments) {
|
||||
const map = {}
|
||||
for (const day of (days || [])) {
|
||||
const da = (assignments?.[String(day.id)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
for (const a of da) {
|
||||
if (!a.place) continue
|
||||
map[a.id] = { dayNumber: day.day_number, dayTitle: day.title, dayDate: day.date, placeName: a.place.name, startTime: a.place.place_time, endTime: a.place.end_time }
|
||||
}
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 9000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16,
|
||||
}} onClick={onClose}>
|
||||
<div style={{
|
||||
background: 'var(--bg-card)', borderRadius: 18, padding: 24, width: '100%', maxWidth: 420,
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('reservations.editTitle')}</h3>
|
||||
<p style={{ margin: '3px 0 0', fontSize: 12, color: 'var(--text-faint)' }}>{item.title}</p>
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background: 'var(--bg-tertiary)', border: 'none', borderRadius: '50%', width: 30, height: 30, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.status')}</label>
|
||||
<CustomSelect
|
||||
value={form.reservation_status}
|
||||
onChange={v => set('reservation_status', v)}
|
||||
options={[
|
||||
{ value: 'pending', label: t('reservations.pending') },
|
||||
{ value: 'confirmed', label: t('reservations.confirmed') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.datetime')}</label>
|
||||
<CustomDateTimePicker value={form.reservation_datetime} onChange={v => set('reservation_datetime', v)} />
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.notes')}</label>
|
||||
<textarea value={form.reservation_notes} onChange={e => set('reservation_notes', e.target.value)} rows={3} placeholder={t('reservations.notesPlaceholder')} style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border-secondary)' }}>
|
||||
<button onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-secondary)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={handleSave} disabled={saving} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--accent)', color: 'white', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: saving ? 0.6 : 1 }}>
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
return map
|
||||
}
|
||||
|
||||
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles }) {
|
||||
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup }) {
|
||||
const { toggleReservationStatus } = useTripStore()
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
const TypeIcon = typeIcon(r.type)
|
||||
const typeInfo = getType(r.type)
|
||||
const TypeIcon = typeInfo.Icon
|
||||
const confirmed = r.status === 'confirmed'
|
||||
const attachedFiles = files.filter(f => f.reservation_id === r.id)
|
||||
const linked = r.assignment_id ? assignmentLookup[r.assignment_id] : null
|
||||
|
||||
const handleToggle = async () => {
|
||||
try { await toggleReservationStatus(tripId, r.id) }
|
||||
@@ -165,205 +57,137 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
try { await onDelete(r.id) } catch { toast.error(t('reservations.toast.deleteError')) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ background: 'var(--bg-card)', borderRadius: 14, border: '1px solid var(--border-faint)', boxShadow: '0 1px 6px rgba(0,0,0,0.05)', overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'stretch' }}>
|
||||
<div style={{
|
||||
width: 44, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: confirmed ? 'rgba(22,163,74,0.1)' : 'rgba(161,98,7,0.1)',
|
||||
borderRight: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(161,98,7,0.2)'}`,
|
||||
}}>
|
||||
<TypeIcon size={16} style={{ color: confirmed ? '#16a34a' : '#a16207' }} />
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, padding: '11px 13px', minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13.5, color: 'var(--text-primary)', lineHeight: 1.3 }}>{r.title}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>{t(typeLabelKey(r.type))}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 3, flexShrink: 0 }}>
|
||||
<button onClick={handleToggle} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 3, padding: '3px 8px', borderRadius: 99,
|
||||
border: 'none', cursor: 'pointer', fontSize: 11, fontWeight: 500,
|
||||
background: confirmed ? 'rgba(22,163,74,0.12)' : 'rgba(161,98,7,0.12)',
|
||||
color: confirmed ? '#16a34a' : '#a16207',
|
||||
}}>
|
||||
{confirmed ? <><CheckCircle2 size={11} /> {t('reservations.confirmed')}</> : <><Circle size={11} /> {t('reservations.pending')}</>}
|
||||
</button>
|
||||
<button onClick={() => onEdit(r)} style={{ padding: 5, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}><Pencil size={12} /></button>
|
||||
<button onClick={handleDelete} style={{ padding: 5, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}><Trash2 size={12} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 7, display: 'flex', flexWrap: 'wrap', gap: '3px 10px' }}>
|
||||
{r.reservation_time && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11.5, color: 'var(--text-secondary)' }}>
|
||||
<Calendar size={10} style={{ color: 'var(--text-faint)' }} />{formatDateTimeWithLocale(r.reservation_time, locale, timeFormat)}
|
||||
</div>
|
||||
)}
|
||||
{r.location && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11.5, color: 'var(--text-secondary)' }}>
|
||||
<MapPin size={10} style={{ color: 'var(--text-faint)' }} />
|
||||
<span style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.location}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 5, display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{r.confirmation_number && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 10.5, color: '#16a34a', background: 'rgba(22,163,74,0.1)', border: '1px solid rgba(22,163,74,0.2)', borderRadius: 99, padding: '1px 7px', fontWeight: 600 }}>
|
||||
<Hash size={8} />{r.confirmation_number}
|
||||
</span>
|
||||
)}
|
||||
{r.day_number != null && <span style={{ fontSize: 10.5, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', borderRadius: 99, padding: '1px 7px' }}>{t('dayplan.dayN', { n: r.day_number })}</span>}
|
||||
{r.place_name && <span style={{ fontSize: 10.5, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', borderRadius: 99, padding: '1px 7px' }}>{r.place_name}</span>}
|
||||
</div>
|
||||
|
||||
{r.notes && <p style={{ margin: '7px 0 0', fontSize: 11.5, color: 'var(--text-muted)', lineHeight: 1.5, borderTop: '1px solid var(--border-secondary)', paddingTop: 7 }}>{r.notes}</p>}
|
||||
|
||||
{/* Attached files — read-only, upload only via edit modal */}
|
||||
{attachedFiles.length > 0 && (
|
||||
<div style={{ marginTop: 8, borderTop: '1px solid var(--border-secondary)', paddingTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{attachedFiles.map(f => (
|
||||
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<FileText size={11} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11.5, color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
<a href={f.url} target="_blank" rel="noreferrer" style={{ display: 'flex', color: 'var(--text-faint)', flexShrink: 0 }} title={t('common.open')}>
|
||||
<ExternalLink size={11} />
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={onNavigateToFiles} style={{ alignSelf: 'flex-start', fontSize: 11, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', padding: 0, textDecoration: 'underline', fontFamily: 'inherit' }}>
|
||||
{t('reservations.showFiles')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PlaceReservationCard({ item, tripId, files = [], onNavigateToFiles }) {
|
||||
const { updatePlace } = useTripStore()
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
const [editing, setEditing] = useState(false)
|
||||
const confirmed = item.status === 'confirmed'
|
||||
const placeFiles = files.filter(f => f.place_id === item.placeId)
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm(t('reservations.confirm.remove', { name: item.title }))) return
|
||||
try {
|
||||
await updatePlace(tripId, item.placeId, {
|
||||
reservation_status: 'none',
|
||||
reservation_datetime: null,
|
||||
place_time: null,
|
||||
reservation_notes: null,
|
||||
})
|
||||
toast.success(t('reservations.toast.removed'))
|
||||
} catch { toast.error(t('reservations.toast.deleteError')) }
|
||||
const fmtDate = (str) => {
|
||||
const d = new Date(str)
|
||||
return d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
}
|
||||
const fmtTime = (str) => {
|
||||
const d = new Date(str)
|
||||
return d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{editing && <PlaceReservationEditModal item={item} tripId={tripId} onClose={() => setEditing(false)} />}
|
||||
<div style={{ background: 'var(--bg-card)', borderRadius: 14, border: '1px solid var(--border-faint)', boxShadow: '0 1px 6px rgba(0,0,0,0.05)', overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'stretch' }}>
|
||||
<div style={{
|
||||
width: 44, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: confirmed ? 'rgba(22,163,74,0.1)' : 'rgba(161,98,7,0.1)',
|
||||
borderRight: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(161,98,7,0.2)'}`,
|
||||
}}>
|
||||
<MapPinned size={16} style={{ color: confirmed ? '#16a34a' : '#a16207' }} />
|
||||
</div>
|
||||
<div style={{ borderRadius: 12, overflow: 'hidden', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(217,119,6,0.2)'}` }}>
|
||||
{/* Header bar */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)' }}>
|
||||
<div style={{ width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: confirmed ? '#16a34a' : '#d97706' }} />
|
||||
<button onClick={handleToggle} style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706', background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit' }}>
|
||||
{confirmed ? t('reservations.confirmed') : t('reservations.pending')}
|
||||
</button>
|
||||
<div style={{ width: 1, height: 10, background: 'var(--border-faint)' }} />
|
||||
<TypeIcon size={11} style={{ color: typeInfo.color, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{t(typeInfo.labelKey)}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
|
||||
<button onClick={() => onEdit(r)} title={t('common.edit')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
<button onClick={handleDelete} title={t('common.delete')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, padding: '11px 13px', minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13.5, color: 'var(--text-primary)', lineHeight: 1.3 }}>{item.title}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 2, flexWrap: 'nowrap', overflow: 'hidden' }}>
|
||||
<span className="hidden sm:inline" style={{ fontSize: 10.5, color: 'var(--text-faint)', flexShrink: 0 }}>{t('reservations.fromPlan')}</span>
|
||||
{item.dayLabel && <span style={{ fontSize: 10.5, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', borderRadius: 99, padding: '0 6px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.dayLabel}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 3, flexShrink: 0 }}>
|
||||
<span style={{
|
||||
display: 'flex', alignItems: 'center', gap: 3, padding: '3px 8px', borderRadius: 99,
|
||||
fontSize: 11, fontWeight: 500,
|
||||
background: confirmed ? 'rgba(22,163,74,0.12)' : 'rgba(161,98,7,0.12)',
|
||||
color: confirmed ? '#16a34a' : '#a16207',
|
||||
}}>
|
||||
{confirmed ? <><CheckCircle2 size={11} /> {t('reservations.confirmed')}</> : <><Circle size={11} /> {t('reservations.pending')}</>}
|
||||
</span>
|
||||
<button onClick={() => setEditing(true)} style={{ padding: 5, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}><Pencil size={12} /></button>
|
||||
<button onClick={handleDelete} style={{ padding: 5, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}><Trash2 size={12} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 7, display: 'flex', flexWrap: 'wrap', gap: '3px 10px' }}>
|
||||
{item.reservation_time && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11.5, color: 'var(--text-secondary)' }}>
|
||||
<Calendar size={10} style={{ color: 'var(--text-faint)' }} />{formatDateTimeWithLocale(item.reservation_time, locale, timeFormat)}
|
||||
{/* Details */}
|
||||
{(r.reservation_time || r.confirmation_number || r.location || linked) && (
|
||||
<div style={{ padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{/* Row 1: Date, Time, Code */}
|
||||
{(r.reservation_time || r.confirmation_number) && (
|
||||
<div style={{ display: 'flex', gap: 0, borderRadius: 8, overflow: 'hidden', background: 'var(--bg-secondary)', boxShadow: '0 1px 6px rgba(0,0,0,0.08)' }}>
|
||||
{r.reservation_time && (
|
||||
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: '1px solid var(--border-faint)' }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.date')}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{fmtDate(r.reservation_time)}</div>
|
||||
</div>
|
||||
)}
|
||||
{item.place_time && !item.reservation_time && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11.5, color: 'var(--text-secondary)' }}>
|
||||
<Calendar size={10} style={{ color: 'var(--text-faint)' }} />{item.place_time}
|
||||
{r.reservation_time && (
|
||||
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: '1px solid var(--border-faint)' }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.time')}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{fmtTime(r.reservation_time)}</div>
|
||||
</div>
|
||||
)}
|
||||
{item.location && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11.5, color: 'var(--text-secondary)' }}>
|
||||
<MapPin size={10} style={{ color: 'var(--text-faint)' }} />
|
||||
<span style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.location}</span>
|
||||
{r.confirmation_number && (
|
||||
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.confirmationCode')}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{r.confirmation_number}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.notes && <p style={{ margin: '7px 0 0', fontSize: 11.5, color: 'var(--text-muted)', lineHeight: 1.5, borderTop: '1px solid var(--border-secondary)', paddingTop: 7 }}>{item.notes}</p>}
|
||||
|
||||
{/* Files attached to the place */}
|
||||
{placeFiles.length > 0 && (
|
||||
<div style={{ marginTop: 8, borderTop: '1px solid var(--border-secondary)', paddingTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{placeFiles.map(f => (
|
||||
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<FileText size={11} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11.5, color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
<a href={f.url} target="_blank" rel="noreferrer" style={{ display: 'flex', color: 'var(--text-faint)', flexShrink: 0 }} title={t('common.open')}>
|
||||
<ExternalLink size={11} />
|
||||
</a>
|
||||
)}
|
||||
{/* Row 2: Location + Assignment */}
|
||||
{(r.location || linked) && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: r.location && linked ? '1fr 1fr' : '1fr', gap: 8, paddingTop: 6, borderTop: '1px solid var(--border-faint)' }}>
|
||||
{r.location && (
|
||||
<div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.locationAddress')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
<MapPin size={10} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.location}</span>
|
||||
</div>
|
||||
))}
|
||||
{onNavigateToFiles && (
|
||||
<button onClick={onNavigateToFiles} style={{ alignSelf: 'flex-start', fontSize: 11, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', padding: 0, textDecoration: 'underline', fontFamily: 'inherit' }}>
|
||||
{t('reservations.showFiles')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{linked && (
|
||||
<div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.linkAssignment')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
<Link2 size={10} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{linked.dayTitle || t('dayplan.dayN', { n: linked.dayNumber })} — {linked.placeName}
|
||||
{linked.startTime ? ` · ${linked.startTime}${linked.endTime ? ' – ' + linked.endTime : ''}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{r.notes && (
|
||||
<div style={{ padding: '0 12px 8px' }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.notes')}</div>
|
||||
<div style={{ padding: '5px 8px', borderRadius: 7, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)', lineHeight: 1.5 }}>
|
||||
{r.notes}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Files */}
|
||||
{attachedFiles.length > 0 && (
|
||||
<div style={{ padding: '0 12px 8px' }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('files.title')}</div>
|
||||
<div style={{ padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{attachedFiles.map(f => (
|
||||
<a key={f.id} href={f.url} target="_blank" rel="noreferrer" style={{ display: 'flex', alignItems: 'center', gap: 4, textDecoration: 'none', cursor: 'pointer' }}>
|
||||
<FileText size={9} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 10, color: 'var(--text-muted)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ title, count, children, defaultOpen = true, accent }) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
return (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<button onClick={() => setOpen(o => !o)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: '4px 0', marginBottom: 10,
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: '4px 0', marginBottom: 8, fontFamily: 'inherit',
|
||||
}}>
|
||||
{open ? <ChevronDown size={15} style={{ color: 'var(--text-faint)' }} /> : <ChevronRight size={15} style={{ color: 'var(--text-faint)' }} />}
|
||||
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)' }}>{title}</span>
|
||||
{open ? <ChevronDown size={14} style={{ color: 'var(--text-faint)' }} /> : <ChevronRight size={14} style={{ color: 'var(--text-faint)' }} />}
|
||||
<span style={{ fontWeight: 700, fontSize: 12, color: 'var(--text-primary)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{title}</span>
|
||||
<span style={{
|
||||
fontSize: 11, fontWeight: 600, padding: '1px 8px', borderRadius: 99,
|
||||
background: accent === 'green' ? 'rgba(22,163,74,0.12)' : 'var(--bg-tertiary)',
|
||||
color: accent === 'green' ? '#16a34a' : 'var(--text-muted)',
|
||||
fontSize: 10, fontWeight: 700, padding: '1px 7px', borderRadius: 99,
|
||||
background: accent === 'green' ? 'rgba(22,163,74,0.1)' : 'var(--bg-tertiary)',
|
||||
color: accent === 'green' ? '#16a34a' : 'var(--text-faint)',
|
||||
}}>{count}</span>
|
||||
</button>
|
||||
{open && <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{children}</div>}
|
||||
@@ -375,98 +199,66 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
||||
const { t, locale } = useTranslation()
|
||||
const [showHint, setShowHint] = useState(() => !localStorage.getItem('hideReservationHint'))
|
||||
|
||||
const placeReservations = useMemo(() => {
|
||||
const result = []
|
||||
for (const day of (days || [])) {
|
||||
const da = (assignments?.[String(day.id)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
for (const assignment of da) {
|
||||
const place = assignment.place
|
||||
if (!place || !place.reservation_status || place.reservation_status === 'none') continue
|
||||
const dayLabel = day.title
|
||||
? day.title
|
||||
: day.date
|
||||
? `${t('dayplan.dayN', { n: day.day_number })} · ${new Date(day.date + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })}`
|
||||
: t('dayplan.dayN', { n: day.day_number })
|
||||
result.push({
|
||||
_placeRes: true,
|
||||
id: `place_${day.id}_${place.id}`,
|
||||
placeId: place.id,
|
||||
title: place.name,
|
||||
status: place.reservation_status === 'confirmed' ? 'confirmed' : 'pending',
|
||||
reservation_time: place.reservation_datetime || null,
|
||||
place_time: place.place_time || null,
|
||||
location: place.address || null,
|
||||
notes: place.reservation_notes || null,
|
||||
dayLabel,
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}, [days, assignments, locale])
|
||||
const assignmentLookup = useMemo(() => buildAssignmentLookup(days, assignments), [days, assignments])
|
||||
|
||||
const allPending = [...reservations.filter(r => r.status !== 'confirmed'), ...placeReservations.filter(r => r.status !== 'confirmed')]
|
||||
const allConfirmed = [...reservations.filter(r => r.status === 'confirmed'), ...placeReservations.filter(r => r.status === 'confirmed')]
|
||||
const total = allPending.length + allConfirmed.length
|
||||
|
||||
function renderCard(r) {
|
||||
if (r._placeRes) return <PlaceReservationCard key={r.id} item={r} tripId={tripId} files={files} onNavigateToFiles={onNavigateToFiles} />
|
||||
return <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} />
|
||||
}
|
||||
const allPending = reservations.filter(r => r.status !== 'confirmed')
|
||||
const allConfirmed = reservations.filter(r => r.status === 'confirmed')
|
||||
const total = reservations.length
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
{/* Header */}
|
||||
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid var(--border-faint)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('reservations.title')}</h2>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'var(--text-faint)' }}>
|
||||
{total === 0 ? t('reservations.empty') : t('reservations.summary', { confirmed: allConfirmed.length, pending: allPending.length })}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onAdd} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '7px 14px', borderRadius: 99,
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 99,
|
||||
border: 'none', background: 'var(--accent)', color: 'var(--accent-text)',
|
||||
fontSize: 12.5, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Plus size={13} /> <span className="hidden sm:inline">{t('reservations.addManual')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Hinweis — einmalig wegklickbar */}
|
||||
{/* Hint */}
|
||||
{showHint && (
|
||||
<div style={{ margin: '12px 24px 8px', padding: '8px 12px', borderRadius: 10, background: 'var(--bg-hover)', display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
||||
<Lightbulb size={13} style={{ flexShrink: 0, marginTop: 1, color: 'var(--text-faint)' }} />
|
||||
<p style={{ fontSize: 11.5, color: 'var(--text-muted)', margin: 0, lineHeight: 1.5, flex: 1 }}>
|
||||
{t('reservations.placeHint')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => { setShowHint(false); localStorage.setItem('hideReservationHint', '1') }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0 4px', color: 'var(--text-faint)', fontSize: 16, lineHeight: 1, flexShrink: 0 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}
|
||||
>×</button>
|
||||
<div style={{ margin: '12px 24px 4px', padding: '8px 12px', borderRadius: 10, background: 'var(--bg-hover)', display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
||||
<Lightbulb size={12} style={{ flexShrink: 0, marginTop: 1, color: 'var(--text-faint)' }} />
|
||||
<p style={{ fontSize: 11, color: 'var(--text-muted)', margin: 0, lineHeight: 1.5, flex: 1 }}>{t('reservations.placeHint')}</p>
|
||||
<button onClick={() => { setShowHint(false); localStorage.setItem('hideReservationHint', '1') }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0 4px', color: 'var(--text-faint)', fontSize: 14, lineHeight: 1, flexShrink: 0 }}>×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '20px 24px' }}>
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 24px' }}>
|
||||
{total === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
|
||||
<BookMarked size={40} style={{ marginBottom: 12, color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
|
||||
<BookMarked size={36} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
|
||||
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('reservations.empty')}</p>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-faint)', margin: 0 }}>{t('reservations.emptyHint')}</p>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: 0 }}>{t('reservations.emptyHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<>
|
||||
{allPending.length > 0 && (
|
||||
<Section title={t('reservations.pending')} count={allPending.length} defaultOpen={true} accent="gray">
|
||||
{allPending.map(renderCard)}
|
||||
<Section title={t('reservations.pending')} count={allPending.length} accent="gray">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} />)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
{allConfirmed.length > 0 && (
|
||||
<Section title={t('reservations.confirmed')} count={allConfirmed.length} defaultOpen={true} accent="green">
|
||||
{allConfirmed.map(renderCard)}
|
||||
<Section title={t('reservations.confirmed')} count={allConfirmed.length} accent="green">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} />)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user