Merge branch 'dev' into feat/auto-backup-schedule-and-timezone
This commit is contained in:
@@ -3,9 +3,10 @@ import { useTripStore } from '../../store/tripStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { packingApi, tripsApi, adminApi } from '../../api/client'
|
||||
import ReactDOM from 'react-dom'
|
||||
import {
|
||||
CheckSquare, Square, Trash2, Plus, ChevronDown, ChevronRight,
|
||||
X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage, UserPlus, Package, FolderPlus,
|
||||
X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage, UserPlus, Package, FolderPlus, Upload,
|
||||
} from 'lucide-react'
|
||||
import type { PackingItem } from '../../types'
|
||||
|
||||
@@ -727,6 +728,9 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
const [availableTemplates, setAvailableTemplates] = useState<{ id: number; name: string; item_count: number }[]>([])
|
||||
const [showTemplateDropdown, setShowTemplateDropdown] = useState(false)
|
||||
const [applyingTemplate, setApplyingTemplate] = useState(false)
|
||||
const [showImportModal, setShowImportModal] = useState(false)
|
||||
const [importText, setImportText] = useState('')
|
||||
const csvInputRef = useRef<HTMLInputElement>(null)
|
||||
const templateDropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -757,6 +761,44 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
}
|
||||
}
|
||||
|
||||
const parseImportLines = (text: string) => {
|
||||
return text.split('\n').map(line => line.trim()).filter(Boolean).map(line => {
|
||||
// Format: Category, Name, Weight (optional), Bag (optional), checked/unchecked (optional)
|
||||
const parts = line.split(/[,;\t]/).map(s => s.trim())
|
||||
if (parts.length >= 2) {
|
||||
const category = parts[0]
|
||||
const name = parts[1]
|
||||
const weight_grams = parts[2] || undefined
|
||||
const bag = parts[3] || undefined
|
||||
const checked = parts[4]?.toLowerCase() === 'checked' || parts[4] === '1'
|
||||
return { name, category, weight_grams, bag, checked }
|
||||
}
|
||||
// Single value = just a name
|
||||
return { name: parts[0], category: undefined, weight_grams: undefined, bag: undefined, checked: false }
|
||||
}).filter(i => i.name)
|
||||
}
|
||||
|
||||
const handleBulkImport = async () => {
|
||||
const parsed = parseImportLines(importText)
|
||||
if (parsed.length === 0) { toast.error(t('packing.importEmpty')); return }
|
||||
try {
|
||||
const result = await packingApi.bulkImport(tripId, parsed)
|
||||
toast.success(t('packing.importSuccess', { count: result.count }))
|
||||
setImportText('')
|
||||
setShowImportModal(false)
|
||||
window.location.reload()
|
||||
} catch { toast.error(t('packing.importError')) }
|
||||
}
|
||||
|
||||
const handleCsvFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
e.target.value = ''
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => { if (typeof reader.result === 'string') setImportText(reader.result) }
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
||||
|
||||
return (
|
||||
@@ -781,6 +823,13 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
<span className="sm:hidden">{t('packing.clearCheckedShort', { count: abgehakt })}</span>
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setShowImportModal(true)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
||||
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer',
|
||||
fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-muted)',
|
||||
}}>
|
||||
<Upload size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
|
||||
</button>
|
||||
{availableTemplates.length > 0 && (
|
||||
<div ref={templateDropdownRef} style={{ position: 'relative' }}>
|
||||
<button onClick={() => setShowTemplateDropdown(v => !v)} disabled={applyingTemplate} style={{
|
||||
@@ -1102,6 +1151,60 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
.assignee-chip:hover + .assignee-tooltip { opacity: 1 !important; }
|
||||
.assignee-chip:hover { opacity: 0.7; }
|
||||
`}</style>
|
||||
|
||||
{/* Bulk Import Modal */}
|
||||
{showImportModal && ReactDOM.createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 1000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
|
||||
}} onClick={() => setShowImportModal(false)}>
|
||||
<div style={{
|
||||
width: 420, maxHeight: '80vh', background: 'var(--bg-card)', borderRadius: 16,
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
|
||||
display: 'flex', flexDirection: 'column', gap: 14,
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{t('packing.importTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', lineHeight: 1.5 }}>{t('packing.importHint')}</div>
|
||||
<textarea
|
||||
value={importText}
|
||||
onChange={e => setImportText(e.target.value)}
|
||||
rows={10}
|
||||
placeholder={t('packing.importPlaceholder')}
|
||||
style={{
|
||||
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
padding: '10px 12px', fontSize: 13, fontFamily: 'monospace',
|
||||
outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)',
|
||||
background: 'var(--bg-input)', resize: 'vertical', lineHeight: 1.5,
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<input ref={csvInputRef} type="file" accept=".csv,.txt" style={{ display: 'none' }} onChange={handleCsvFile} />
|
||||
<button onClick={() => csvInputRef.current?.click()} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 10px',
|
||||
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||
fontSize: 11, color: 'var(--text-faint)', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Upload size={11} /> {t('packing.importCsv')}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={() => setShowImportModal(false)} style={{
|
||||
fontSize: 12, background: 'none', border: '1px solid var(--border-primary)',
|
||||
borderRadius: 8, padding: '6px 14px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit',
|
||||
}}>{t('common.cancel')}</button>
|
||||
<button onClick={handleBulkImport} disabled={!importText.trim()} style={{
|
||||
fontSize: 12, background: 'var(--accent)', color: 'var(--accent-text)',
|
||||
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600,
|
||||
fontFamily: 'inherit', opacity: importText.trim() ? 1 : 0.5,
|
||||
}}>{t('packing.importAction', { count: parseImportLines(importText).length })}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
const { t, language, locale } = useTranslation()
|
||||
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
|
||||
const fmtTime = (v) => formatTime12(v, is12h)
|
||||
const unit = isFahrenheit ? '°F' : '°C'
|
||||
const [weather, setWeather] = useState(null)
|
||||
@@ -368,7 +369,12 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{linked.title}</div>
|
||||
<div style={{ fontSize: 9, color: 'var(--text-faint)', display: 'flex', gap: 6, marginTop: 1 }}>
|
||||
<span>{confirmed ? t('reservations.confirmed') : t('reservations.pending')}</span>
|
||||
{linked.confirmation_number && <span>#{linked.confirmation_number}</span>}
|
||||
{linked.confirmation_number && <span
|
||||
onMouseEnter={e => { if (blurCodes) e.currentTarget.style.filter = 'none' }}
|
||||
onMouseLeave={e => { if (blurCodes) e.currentTarget.style.filter = 'blur(4px)' }}
|
||||
onClick={e => { if (blurCodes) { const el = e.currentTarget; el.style.filter = el.style.filter === 'none' ? 'blur(4px)' : 'none' } }}
|
||||
style={{ filter: blurCodes ? 'blur(4px)' : 'none', transition: 'filter 0.2s', cursor: blurCodes ? 'pointer' : 'default' }}
|
||||
>#{linked.confirmation_number}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1449,7 +1449,7 @@ export default function DayPlanSidebar({
|
||||
if (meta.platform) detailFields.push({ label: t('reservations.meta.platform'), value: meta.platform })
|
||||
if (meta.seat) detailFields.push({ label: t('reservations.meta.seat'), value: meta.seat })
|
||||
}
|
||||
if (res.confirmation_number) detailFields.push({ label: t('reservations.confirmationCode'), value: res.confirmation_number })
|
||||
if (res.confirmation_number) detailFields.push({ label: t('reservations.confirmationCode'), value: res.confirmation_number, sensitive: true })
|
||||
if (res.location) detailFields.push({ label: t('reservations.locationAddress'), value: res.location })
|
||||
|
||||
return (
|
||||
@@ -1486,12 +1486,25 @@ export default function DayPlanSidebar({
|
||||
{/* Detail-Felder */}
|
||||
{detailFields.length > 0 && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
{detailFields.map((f, i) => (
|
||||
<div key={i} style={{ padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 8 }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{f.label}</div>
|
||||
<div style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-primary)', wordBreak: 'break-word' }}>{f.value}</div>
|
||||
</div>
|
||||
))}
|
||||
{detailFields.map((f, i) => {
|
||||
const shouldBlur = f.sensitive && useSettingsStore.getState().settings.blur_booking_codes
|
||||
return (
|
||||
<div key={i} style={{ padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 8 }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{f.label}</div>
|
||||
<div
|
||||
onMouseEnter={e => { if (shouldBlur) e.currentTarget.style.filter = 'none' }}
|
||||
onMouseLeave={e => { if (shouldBlur) e.currentTarget.style.filter = 'blur(5px)' }}
|
||||
onClick={e => { if (shouldBlur) { const el = e.currentTarget; el.style.filter = el.style.filter === 'none' ? 'blur(5px)' : 'none' } }}
|
||||
style={{
|
||||
fontSize: 12, fontWeight: 500, color: 'var(--text-primary)', wordBreak: 'break-word',
|
||||
filter: shouldBlur ? 'blur(5px)' : 'none', transition: 'filter 0.2s',
|
||||
cursor: shouldBlur ? 'pointer' : 'default',
|
||||
userSelect: shouldBlur ? 'none' : 'auto',
|
||||
}}
|
||||
>{f.value}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation } from 'lucide-react'
|
||||
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload } from 'lucide-react'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||
import { placesApi } from '../../api/client'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||
|
||||
interface PlacesSidebarProps {
|
||||
tripId: number
|
||||
places: Place[]
|
||||
categories: Category[]
|
||||
assignments: AssignmentsMap
|
||||
@@ -26,11 +30,27 @@ interface PlacesSidebarProps {
|
||||
}
|
||||
|
||||
export default function PlacesSidebar({
|
||||
places, categories, assignments, selectedDayId, selectedPlaceId,
|
||||
tripId, places, categories, assignments, selectedDayId, selectedPlaceId,
|
||||
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange,
|
||||
}: PlacesSidebarProps) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const ctxMenu = useContextMenu()
|
||||
const gpxInputRef = useRef<HTMLInputElement>(null)
|
||||
const tripStore = useTripStore()
|
||||
|
||||
const handleGpxImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
e.target.value = ''
|
||||
try {
|
||||
const result = await placesApi.importGpx(tripId, file)
|
||||
await tripStore.loadTrip(tripId)
|
||||
toast.success(t('places.gpxImported', { count: result.count }))
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.error || t('places.gpxError'))
|
||||
}
|
||||
}
|
||||
const [search, setSearch] = useState('')
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [categoryFilter, setCategoryFilterLocal] = useState('')
|
||||
@@ -72,6 +92,19 @@ export default function PlacesSidebar({
|
||||
>
|
||||
<Plus size={14} strokeWidth={2} /> {t('places.addPlace')}
|
||||
</button>
|
||||
<input ref={gpxInputRef} type="file" accept=".gpx" style={{ display: 'none' }} onChange={handleGpxImport} />
|
||||
<button
|
||||
onClick={() => gpxInputRef.current?.click()}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
width: '100%', padding: '5px 12px', borderRadius: 8, marginBottom: 10,
|
||||
border: '1px dashed var(--border-primary)', background: 'none',
|
||||
color: 'var(--text-faint)', fontSize: 11, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Upload size={11} strokeWidth={2} /> {t('places.importGpx')}
|
||||
</button>
|
||||
|
||||
{/* Filter-Tabs */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
|
||||
|
||||
@@ -63,6 +63,8 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
|
||||
const [codeRevealed, setCodeRevealed] = useState(false)
|
||||
const typeInfo = getType(r.type)
|
||||
const TypeIcon = typeInfo.Icon
|
||||
const confirmed = r.status === 'confirmed'
|
||||
@@ -136,7 +138,19 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
{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
|
||||
onMouseEnter={() => blurCodes && setCodeRevealed(true)}
|
||||
onMouseLeave={() => blurCodes && setCodeRevealed(false)}
|
||||
onClick={() => blurCodes && setCodeRevealed(v => !v)}
|
||||
style={{
|
||||
fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1,
|
||||
filter: blurCodes && !codeRevealed ? 'blur(5px)' : 'none',
|
||||
cursor: blurCodes ? 'pointer' : 'default',
|
||||
transition: 'filter 0.2s',
|
||||
}}
|
||||
>
|
||||
{r.confirmation_number}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user