refactoring: TypeScript migration, security fixes,
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -9,7 +9,20 @@ const ICON_MAP = {
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase,
|
||||
}
|
||||
|
||||
function AddonIcon({ name, size = 20 }) {
|
||||
interface Addon {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface AddonIconProps {
|
||||
name: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
function AddonIcon({ name, size = 20 }: AddonIconProps) {
|
||||
const Icon = ICON_MAP[name] || Puzzle
|
||||
return <Icon size={size} />
|
||||
}
|
||||
@@ -31,7 +44,7 @@ export default function AddonManager() {
|
||||
try {
|
||||
const data = await adminApi.addons()
|
||||
setAddons(data.addons)
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
toast.error(t('admin.addons.toast.error'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -46,7 +59,7 @@ export default function AddonManager() {
|
||||
await adminApi.updateAddon(addon.id, { enabled: newEnabled })
|
||||
window.dispatchEvent(new Event('addons-changed'))
|
||||
toast.success(t('admin.addons.toast.updated'))
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
// Rollback
|
||||
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: !newEnabled } : a))
|
||||
toast.error(t('admin.addons.toast.error'))
|
||||
@@ -117,7 +130,13 @@ export default function AddonManager() {
|
||||
)
|
||||
}
|
||||
|
||||
function AddonRow({ addon, onToggle, t }) {
|
||||
interface AddonRowProps {
|
||||
addon: Addon
|
||||
onToggle: (addonId: string) => void
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
function AddonRow({ addon, onToggle, t }: AddonRowProps) {
|
||||
const isComingSoon = false
|
||||
return (
|
||||
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95" style={{ borderColor: 'var(--border-secondary)', opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { backupApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
|
||||
const INTERVAL_OPTIONS = [
|
||||
{ value: 'hourly', labelKey: 'backup.interval.hourly' },
|
||||
@@ -73,7 +74,7 @@ export default function BackupPanel() {
|
||||
}
|
||||
|
||||
const handleUploadRestore = (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
e.target.value = ''
|
||||
setRestoreConfirm({ type: 'upload', filename: file.name, file })
|
||||
@@ -90,8 +91,8 @@ export default function BackupPanel() {
|
||||
await backupApi.restore(filename)
|
||||
toast.success(t('backup.toast.restored'))
|
||||
setTimeout(() => window.location.reload(), 1500)
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('backup.toast.restoreError'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('backup.toast.restoreError')))
|
||||
setRestoringFile(null)
|
||||
}
|
||||
} else {
|
||||
@@ -100,8 +101,8 @@ export default function BackupPanel() {
|
||||
await backupApi.uploadRestore(file)
|
||||
toast.success(t('backup.toast.restored'))
|
||||
setTimeout(() => window.location.reload(), 1500)
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('backup.toast.uploadError'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('backup.toast.uploadError')))
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { categoriesApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Plus, Edit2, Trash2, Pipette } from 'lucide-react'
|
||||
import { CATEGORY_ICON_MAP, ICON_LABELS, getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#6366f1', '#8b5cf6', '#ec4899', '#ef4444', '#f97316',
|
||||
@@ -31,7 +32,7 @@ export default function CategoryManager() {
|
||||
try {
|
||||
const data = await categoriesApi.list()
|
||||
setCategories(data.categories || [])
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
toast.error(t('categories.toast.loadError'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -71,8 +72,8 @@ export default function CategoryManager() {
|
||||
toast.success(t('categories.toast.created'))
|
||||
}
|
||||
setForm({ name: '', color: '#6366f1', icon: 'MapPin' })
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('categories.toast.saveError'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('categories.toast.saveError')))
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
@@ -84,8 +85,8 @@ export default function CategoryManager() {
|
||||
await categoriesApi.delete(id)
|
||||
setCategories(prev => prev.filter(c => c.id !== id))
|
||||
toast.success(t('categories.toast.deleted'))
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('categories.toast.deleteError'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('categories.toast.deleteError')))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
@@ -22,8 +22,8 @@ export default function GitHubPanel() {
|
||||
const data = await res.json()
|
||||
setReleases(prev => append ? [...prev, ...data] : data)
|
||||
setHasMore(data.length === PER_PAGE)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { budgetApi } from '../../api/client'
|
||||
import type { BudgetItem, BudgetMember } from '../../types'
|
||||
|
||||
interface TripMember {
|
||||
id: number
|
||||
username: string
|
||||
avatar_url?: string | null
|
||||
}
|
||||
|
||||
interface PieSegment {
|
||||
label: string
|
||||
value: number
|
||||
color: string
|
||||
}
|
||||
|
||||
interface PerPersonSummaryEntry {
|
||||
user_id: number
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
total_assigned: number
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
const CURRENCIES = ['EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK', 'TRY', 'THB', 'AUD', 'CAD']
|
||||
@@ -60,7 +81,12 @@ function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder
|
||||
}
|
||||
|
||||
// ── Add Item Row ─────────────────────────────────────────────────────────────
|
||||
function AddItemRow({ onAdd, t }) {
|
||||
interface AddItemRowProps {
|
||||
onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null }) => void
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
function AddItemRow({ onAdd, t }: AddItemRowProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [price, setPrice] = useState('')
|
||||
const [persons, setPersons] = useState('')
|
||||
@@ -113,7 +139,13 @@ function AddItemRow({ onAdd, t }) {
|
||||
}
|
||||
|
||||
// ── Chip with custom tooltip ─────────────────────────────────────────────────
|
||||
function ChipWithTooltip({ label, avatarUrl, size = 20 }) {
|
||||
interface ChipWithTooltipProps {
|
||||
label: string
|
||||
avatarUrl: string | null
|
||||
size?: number
|
||||
}
|
||||
|
||||
function ChipWithTooltip({ label, avatarUrl, size = 20 }: ChipWithTooltipProps) {
|
||||
const [hover, setHover] = useState(false)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
const ref = useRef(null)
|
||||
@@ -156,7 +188,14 @@ function ChipWithTooltip({ label, avatarUrl, size = 20 }) {
|
||||
}
|
||||
|
||||
// ── Budget Member Chips (for Persons column) ────────────────────────────────
|
||||
function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, compact = true }) {
|
||||
interface BudgetMemberChipsProps {
|
||||
members?: BudgetMember[]
|
||||
tripMembers?: TripMember[]
|
||||
onSetMembers: (memberIds: number[]) => void
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, compact = true }: BudgetMemberChipsProps) {
|
||||
const chipSize = compact ? 20 : 30
|
||||
const btnSize = compact ? 18 : 28
|
||||
const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14)
|
||||
@@ -246,7 +285,14 @@ function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, compa
|
||||
}
|
||||
|
||||
// ── Per-Person Inline (inside total card) ────────────────────────────────────
|
||||
function PerPersonInline({ tripId, budgetItems, currency, locale }) {
|
||||
interface PerPersonInlineProps {
|
||||
tripId: number
|
||||
budgetItems: BudgetItem[]
|
||||
currency: string
|
||||
locale: string
|
||||
}
|
||||
|
||||
function PerPersonInline({ tripId, budgetItems, currency, locale }: PerPersonInlineProps) {
|
||||
const [data, setData] = useState(null)
|
||||
const fmt = (v) => fmtNum(v, locale, currency)
|
||||
|
||||
@@ -279,7 +325,13 @@ function PerPersonInline({ tripId, budgetItems, currency, locale }) {
|
||||
}
|
||||
|
||||
// ── Pie Chart (pure CSS conic-gradient) ──────────────────────────────────────
|
||||
function PieChart({ segments, size = 200, totalLabel }) {
|
||||
interface PieChartProps {
|
||||
segments: PieSegment[]
|
||||
size?: number
|
||||
totalLabel: string
|
||||
}
|
||||
|
||||
function PieChart({ segments, size = 200, totalLabel }: PieChartProps) {
|
||||
if (!segments.length) return null
|
||||
|
||||
const total = segments.reduce((s, x) => s + x.value, 0)
|
||||
@@ -316,7 +368,12 @@ function PieChart({ segments, size = 200, totalLabel }) {
|
||||
}
|
||||
|
||||
// ── Main Component ───────────────────────────────────────────────────────────
|
||||
export default function BudgetPanel({ tripId, tripMembers = [] }) {
|
||||
interface BudgetPanelProps {
|
||||
tripId: number
|
||||
tripMembers?: TripMember[]
|
||||
}
|
||||
|
||||
export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) {
|
||||
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers } = useTripStore()
|
||||
const { t, locale } = useTranslation()
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
@@ -355,12 +412,12 @@ export default function BudgetPanel({ tripId, tripMembers = [] }) {
|
||||
const handleDeleteItem = async (id) => { try { await deleteBudgetItem(tripId, id) } catch {} }
|
||||
const handleDeleteCategory = async (cat) => {
|
||||
const items = grouped[cat] || []
|
||||
for (const item of items) await deleteBudgetItem(tripId, item.id)
|
||||
for (const item of Array.from(items)) await deleteBudgetItem(tripId, item.id)
|
||||
}
|
||||
const handleRenameCategory = async (oldName, newName) => {
|
||||
if (!newName.trim() || newName.trim() === oldName) return
|
||||
const items = grouped[oldName] || []
|
||||
for (const item of items) await updateBudgetItem(tripId, item.id, { category: newName.trim() })
|
||||
for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() })
|
||||
}
|
||||
const handleAddCategory = () => {
|
||||
if (!newCategoryName.trim()) return
|
||||
@@ -5,6 +5,25 @@ import { collabApi } from '../../api/client'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { User } from '../../types'
|
||||
|
||||
interface ChatReaction {
|
||||
emoji: string
|
||||
count: number
|
||||
users: { id: number; username: string }[]
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
id: number
|
||||
trip_id: number
|
||||
user_id: number
|
||||
text: string
|
||||
reply_to_id: number | null
|
||||
reactions: ChatReaction[]
|
||||
created_at: string
|
||||
user?: { username: string; avatar_url: string | null }
|
||||
reply_to?: ChatMessage | null
|
||||
}
|
||||
|
||||
// ── Twemoji helper (Apple-style emojis via CDN) ──
|
||||
function emojiToCodepoint(emoji) {
|
||||
@@ -75,7 +94,14 @@ function shouldShowDateSeparator(msg, prevMsg) {
|
||||
}
|
||||
|
||||
/* ── Emoji Picker ── */
|
||||
function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }) {
|
||||
interface EmojiPickerProps {
|
||||
onSelect: (emoji: string) => void
|
||||
onClose: () => void
|
||||
anchorRef: React.RefObject<HTMLElement | null>
|
||||
containerRef: React.RefObject<HTMLElement | null>
|
||||
}
|
||||
|
||||
function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: EmojiPickerProps) {
|
||||
const [cat, setCat] = useState(Object.keys(EMOJI_CATEGORIES)[0])
|
||||
const ref = useRef(null)
|
||||
|
||||
@@ -142,7 +168,14 @@ function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }) {
|
||||
/* ── Reaction Quick Menu (right-click) ── */
|
||||
const QUICK_REACTIONS = ['❤️', '😂', '👍', '😮', '😢', '🔥', '👏', '🎉']
|
||||
|
||||
function ReactionMenu({ x, y, onReact, onClose }) {
|
||||
interface ReactionMenuProps {
|
||||
x: number
|
||||
y: number
|
||||
onReact: (emoji: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function ReactionMenu({ x, y, onReact, onClose }: ReactionMenuProps) {
|
||||
const ref = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -179,7 +212,11 @@ function ReactionMenu({ x, y, onReact, onClose }) {
|
||||
}
|
||||
|
||||
/* ── Message Text with clickable URLs ── */
|
||||
function MessageText({ text }) {
|
||||
interface MessageTextProps {
|
||||
text: string
|
||||
}
|
||||
|
||||
function MessageText({ text }: MessageTextProps) {
|
||||
const parts = text.split(URL_REGEX)
|
||||
const urls = text.match(URL_REGEX) || []
|
||||
const result = []
|
||||
@@ -198,7 +235,14 @@ function MessageText({ text }) {
|
||||
const URL_REGEX = /https?:\/\/[^\s<>"']+/g
|
||||
const previewCache = {}
|
||||
|
||||
function LinkPreview({ url, tripId, own, onLoad }) {
|
||||
interface LinkPreviewProps {
|
||||
url: string
|
||||
tripId: number
|
||||
own: boolean
|
||||
onLoad: (() => void) | undefined
|
||||
}
|
||||
|
||||
function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) {
|
||||
const [data, setData] = useState(previewCache[url] || null)
|
||||
const [loading, setLoading] = useState(!previewCache[url])
|
||||
|
||||
@@ -252,7 +296,13 @@ function LinkPreview({ url, tripId, own, onLoad }) {
|
||||
}
|
||||
|
||||
/* ── Reaction Badge with NOMAD tooltip ── */
|
||||
function ReactionBadge({ reaction, currentUserId, onReact }) {
|
||||
interface ReactionBadgeProps {
|
||||
reaction: ChatReaction
|
||||
currentUserId: number
|
||||
onReact: () => void
|
||||
}
|
||||
|
||||
function ReactionBadge({ reaction, currentUserId, onReact }: ReactionBadgeProps) {
|
||||
const [hover, setHover] = useState(false)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
const ref = useRef(null)
|
||||
@@ -295,7 +345,12 @@ function ReactionBadge({ reaction, currentUserId, onReact }) {
|
||||
}
|
||||
|
||||
/* ── Main Component ── */
|
||||
export default function CollabChat({ tripId, currentUser }) {
|
||||
interface CollabChatProps {
|
||||
tripId: number
|
||||
currentUser: User
|
||||
}
|
||||
|
||||
export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
|
||||
@@ -1,16 +1,56 @@
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { User } from '../../types'
|
||||
|
||||
interface NoteFile {
|
||||
id: number
|
||||
filename: string
|
||||
original_name: string
|
||||
mime_type: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
interface CollabNote {
|
||||
id: number
|
||||
trip_id: number
|
||||
title: string
|
||||
content: string
|
||||
category: string
|
||||
website: string | null
|
||||
pinned: boolean
|
||||
color: string | null
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
avatar: string | null
|
||||
user_id: number
|
||||
created_at: string
|
||||
author?: { username: string; avatar: string | null }
|
||||
user?: { username: string; avatar: string | null }
|
||||
files?: NoteFile[]
|
||||
}
|
||||
|
||||
interface NoteAuthor {
|
||||
username: string
|
||||
avatar?: string | null
|
||||
}
|
||||
|
||||
const FONT = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif"
|
||||
|
||||
// ── Website Thumbnail (fetches OG image) ────────────────────────────────────
|
||||
const ogCache = {}
|
||||
|
||||
function WebsiteThumbnail({ url, tripId, color }) {
|
||||
interface WebsiteThumbnailProps {
|
||||
url: string
|
||||
tripId: number
|
||||
color: string
|
||||
}
|
||||
|
||||
function WebsiteThumbnail({ url, tripId, color }: WebsiteThumbnailProps) {
|
||||
const [data, setData] = useState(ogCache[url] || null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
@@ -46,7 +86,12 @@ function WebsiteThumbnail({ url, tripId, color }) {
|
||||
}
|
||||
|
||||
// ── File Preview Portal ─────────────────────────────────────────────────────
|
||||
function FilePreviewPortal({ file, onClose }) {
|
||||
interface FilePreviewPortalProps {
|
||||
file: NoteFile | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
||||
if (!file) return null
|
||||
const url = file.url || `/uploads/${file.filename}`
|
||||
const isImage = file.mime_type?.startsWith('image/')
|
||||
@@ -120,7 +165,12 @@ const formatTimestamp = (ts, t, locale) => {
|
||||
}
|
||||
|
||||
// ── Avatar ──────────────────────────────────────────────────────────────────
|
||||
function UserAvatar({ user, size = 14 }) {
|
||||
interface UserAvatarProps {
|
||||
user: NoteAuthor | null
|
||||
size?: number
|
||||
}
|
||||
|
||||
function UserAvatar({ user, size = 14 }: UserAvatarProps) {
|
||||
if (!user) return null
|
||||
if (user.avatar) {
|
||||
return (
|
||||
@@ -161,7 +211,19 @@ function UserAvatar({ user, size = 14 }) {
|
||||
}
|
||||
|
||||
// ── New Note Modal (portal to body) ─────────────────────────────────────────
|
||||
function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, categoryColors, getCategoryColor, note, tripId, t }) {
|
||||
interface NoteFormModalProps {
|
||||
onClose: () => void
|
||||
onSubmit: (data: { title: string; content: string; category: string; website: string; files?: File[] }) => Promise<void>
|
||||
onDeleteFile: (noteId: number, fileId: number) => Promise<void>
|
||||
existingCategories: string[]
|
||||
categoryColors: Record<string, string>
|
||||
getCategoryColor: (category: string) => string
|
||||
note: CollabNote | null
|
||||
tripId: number
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, categoryColors, getCategoryColor, note, tripId, t }: NoteFormModalProps) {
|
||||
const isEdit = !!note
|
||||
const allCategories = [...new Set([...existingCategories, ...Object.keys(categoryColors || {})])].filter(Boolean)
|
||||
|
||||
@@ -236,7 +298,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
|
||||
onPaste={e => {
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
for (const item of items) {
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.type.startsWith('image/') || item.type === 'application/pdf') {
|
||||
e.preventDefault()
|
||||
const file = item.getAsFile()
|
||||
@@ -390,7 +452,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
|
||||
{t('collab.notes.attachFiles')}
|
||||
</div>
|
||||
<input id="note-file-input" ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { setPendingFiles(prev => [...prev, ...Array.from(e.target.files)]); e.target.value = '' }} />
|
||||
<input id="note-file-input" ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={e => { setPendingFiles(prev => [...prev, ...Array.from((e.target as HTMLInputElement).files)]); e.target.value = '' }} />
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{/* Existing attachments (edit mode) */}
|
||||
{existingAttachments.map(a => {
|
||||
@@ -448,7 +510,12 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
|
||||
)
|
||||
}
|
||||
|
||||
function EditableCatName({ name, onRename }) {
|
||||
interface EditableCatNameProps {
|
||||
name: string
|
||||
onRename: (newName: string) => void
|
||||
}
|
||||
|
||||
function EditableCatName({ name, onRename }: EditableCatNameProps) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [value, setValue] = useState(name)
|
||||
const inputRef = useRef(null)
|
||||
@@ -477,7 +544,16 @@ function EditableCatName({ name, onRename }) {
|
||||
}
|
||||
|
||||
// ── Category Settings Modal ──────────────────────────────────────────────────
|
||||
function CategorySettingsModal({ onClose, categories, categoryColors, onSave, onRenameCategory, t }) {
|
||||
interface CategorySettingsModalProps {
|
||||
onClose: () => void
|
||||
categories: string[]
|
||||
categoryColors: Record<string, string>
|
||||
onSave: (colors: Record<string, string>) => void
|
||||
onRenameCategory: (oldName: string, newName: string) => Promise<void>
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
function CategorySettingsModal({ onClose, categories, categoryColors, onSave, onRenameCategory, t }: CategorySettingsModalProps) {
|
||||
const [localColors, setLocalColors] = useState({ ...categoryColors })
|
||||
const [renames, setRenames] = useState({}) // { oldName: newName }
|
||||
const [newCatName, setNewCatName] = useState('')
|
||||
@@ -608,7 +684,19 @@ function CategorySettingsModal({ onClose, categories, categoryColors, onSave, on
|
||||
}
|
||||
|
||||
// ── Note Card ───────────────────────────────────────────────────────────────
|
||||
function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onPreviewFile, getCategoryColor, tripId, t }) {
|
||||
interface NoteCardProps {
|
||||
note: CollabNote
|
||||
currentUser: User
|
||||
onUpdate: (noteId: number, data: Partial<CollabNote>) => Promise<void>
|
||||
onDelete: (noteId: number) => Promise<void>
|
||||
onEdit: (note: CollabNote) => void
|
||||
onPreviewFile: (file: NoteFile) => void
|
||||
getCategoryColor: (category: string) => string
|
||||
tripId: number
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onPreviewFile, getCategoryColor, tripId, t }: NoteCardProps) {
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
const author = note.author || note.user || { username: note.username, avatar: note.avatar_url || (note.avatar ? `/uploads/avatars/${note.avatar}` : null) }
|
||||
@@ -773,7 +861,12 @@ function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onPreviewFile
|
||||
}
|
||||
|
||||
// ── Main Component ──────────────────────────────────────────────────────────
|
||||
export default function CollabNotes({ tripId, currentUser }) {
|
||||
interface CollabNotesProps {
|
||||
tripId: number
|
||||
currentUser: User
|
||||
}
|
||||
|
||||
export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
const { t } = useTranslation()
|
||||
const [notes, setNotes] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { MessageCircle, StickyNote, BarChart3, Sparkles } from 'lucide-react'
|
||||
@@ -23,7 +23,18 @@ const card = {
|
||||
overflow: 'hidden', minHeight: 0,
|
||||
}
|
||||
|
||||
export default function CollabPanel({ tripId, tripMembers = [] }) {
|
||||
interface TripMember {
|
||||
id: number
|
||||
username: string
|
||||
avatar_url?: string | null
|
||||
}
|
||||
|
||||
interface CollabPanelProps {
|
||||
tripId: number
|
||||
tripMembers?: TripMember[]
|
||||
}
|
||||
|
||||
export default function CollabPanel({ tripId, tripMembers = [] }: CollabPanelProps) {
|
||||
const { user } = useAuthStore()
|
||||
const { t } = useTranslation()
|
||||
const [mobileTab, setMobileTab] = useState('chat')
|
||||
@@ -4,6 +4,30 @@ import { collabApi } from '../../api/client'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import ReactDOM from 'react-dom'
|
||||
import type { User } from '../../types'
|
||||
|
||||
interface PollVoter {
|
||||
user_id: number
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
}
|
||||
|
||||
interface PollOption {
|
||||
id: number
|
||||
text: string
|
||||
voters: PollVoter[]
|
||||
}
|
||||
|
||||
interface Poll {
|
||||
id: number
|
||||
question: string
|
||||
options: PollOption[]
|
||||
multi_choice: boolean
|
||||
is_closed: boolean
|
||||
deadline: string | null
|
||||
created_by: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const FONT = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif"
|
||||
|
||||
@@ -29,7 +53,13 @@ function totalVotes(poll) {
|
||||
}
|
||||
|
||||
// ── Create Poll Modal ────────────────────────────────────────────────────────
|
||||
function CreatePollModal({ onClose, onCreate, t }) {
|
||||
interface CreatePollModalProps {
|
||||
onClose: () => void
|
||||
onCreate: (data: { question: string; options: string[]; multi_choice: boolean }) => Promise<void>
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
|
||||
const [question, setQuestion] = useState('')
|
||||
const [options, setOptions] = useState(['', ''])
|
||||
const [multiChoice, setMultiChoice] = useState(false)
|
||||
@@ -111,7 +141,12 @@ function CreatePollModal({ onClose, onCreate, t }) {
|
||||
}
|
||||
|
||||
// ── Voter Chip with custom tooltip ────────────────────────────────────────────
|
||||
function VoterChip({ voter, offset }) {
|
||||
interface VoterChipProps {
|
||||
voter: PollVoter
|
||||
offset: boolean
|
||||
}
|
||||
|
||||
function VoterChip({ voter, offset }: VoterChipProps) {
|
||||
const [hover, setHover] = useState(false)
|
||||
const ref = React.useRef(null)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
@@ -152,7 +187,16 @@ function VoterChip({ voter, offset }) {
|
||||
}
|
||||
|
||||
// ── Poll Card ────────────────────────────────────────────────────────────────
|
||||
function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }) {
|
||||
interface PollCardProps {
|
||||
poll: Poll
|
||||
currentUser: User
|
||||
onVote: (pollId: number, optionId: number) => Promise<void>
|
||||
onClose: (pollId: number) => Promise<void>
|
||||
onDelete: (pollId: number) => Promise<void>
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }: PollCardProps) {
|
||||
const total = totalVotes(poll)
|
||||
const isClosed = poll.is_closed || isExpired(poll.deadline)
|
||||
const remaining = timeRemaining(poll.deadline)
|
||||
@@ -286,7 +330,12 @@ function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }) {
|
||||
}
|
||||
|
||||
// ── Main Component ───────────────────────────────────────────────────────────
|
||||
export default function CollabPolls({ tripId, currentUser }) {
|
||||
interface CollabPollsProps {
|
||||
tripId: number
|
||||
currentUser: User
|
||||
}
|
||||
|
||||
export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
const { t } = useTranslation()
|
||||
const [polls, setPolls] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -26,7 +26,17 @@ function formatDayLabel(date, t, locale) {
|
||||
return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
export default function WhatsNextWidget({ tripMembers = [] }) {
|
||||
interface TripMember {
|
||||
id: number
|
||||
username: string
|
||||
avatar_url?: string | null
|
||||
}
|
||||
|
||||
interface WhatsNextWidgetProps {
|
||||
tripMembers?: TripMember[]
|
||||
}
|
||||
|
||||
export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetProps) {
|
||||
const { days, assignments } = useTripStore()
|
||||
const { t, locale } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { ArrowRightLeft, RefreshCw } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Clock, Plus, X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { Globe, MapPin, Plane } from 'lucide-react'
|
||||
import { authApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
|
||||
// Numeric ISO → country name lookup (countries-110m uses numeric IDs)
|
||||
const NUMERIC_TO_NAME = {"004":"Afghanistan","008":"Albania","012":"Algeria","024":"Angola","032":"Argentina","036":"Australia","040":"Austria","050":"Bangladesh","056":"Belgium","064":"Bhutan","068":"Bolivia","070":"Bosnia and Herzegovina","072":"Botswana","076":"Brazil","100":"Bulgaria","104":"Myanmar","108":"Burundi","112":"Belarus","116":"Cambodia","120":"Cameroon","124":"Canada","140":"Central African Republic","144":"Sri Lanka","148":"Chad","152":"Chile","156":"China","170":"Colombia","178":"Congo","180":"Democratic Republic of the Congo","188":"Costa Rica","191":"Croatia","192":"Cuba","196":"Cyprus","203":"Czech Republic","204":"Benin","208":"Denmark","214":"Dominican Republic","218":"Ecuador","818":"Egypt","222":"El Salvador","226":"Equatorial Guinea","232":"Eritrea","233":"Estonia","231":"Ethiopia","238":"Falkland Islands","246":"Finland","250":"France","266":"Gabon","270":"Gambia","268":"Georgia","276":"Germany","288":"Ghana","300":"Greece","320":"Guatemala","324":"Guinea","328":"Guyana","332":"Haiti","340":"Honduras","348":"Hungary","352":"Iceland","356":"India","360":"Indonesia","364":"Iran","368":"Iraq","372":"Ireland","376":"Israel","380":"Italy","384":"Ivory Coast","388":"Jamaica","392":"Japan","400":"Jordan","398":"Kazakhstan","404":"Kenya","408":"North Korea","410":"South Korea","414":"Kuwait","417":"Kyrgyzstan","418":"Laos","422":"Lebanon","426":"Lesotho","430":"Liberia","434":"Libya","440":"Lithuania","442":"Luxembourg","450":"Madagascar","454":"Malawi","458":"Malaysia","466":"Mali","478":"Mauritania","484":"Mexico","496":"Mongolia","498":"Moldova","504":"Morocco","508":"Mozambique","516":"Namibia","524":"Nepal","528":"Netherlands","540":"New Caledonia","554":"New Zealand","558":"Nicaragua","562":"Niger","566":"Nigeria","578":"Norway","512":"Oman","586":"Pakistan","591":"Panama","598":"Papua New Guinea","600":"Paraguay","604":"Peru","608":"Philippines","616":"Poland","620":"Portugal","630":"Puerto Rico","634":"Qatar","642":"Romania","643":"Russia","646":"Rwanda","682":"Saudi Arabia","686":"Senegal","688":"Serbia","694":"Sierra Leone","703":"Slovakia","705":"Slovenia","706":"Somalia","710":"South Africa","724":"Spain","729":"Sudan","740":"Suriname","748":"Swaziland","752":"Sweden","756":"Switzerland","760":"Syria","762":"Tajikistan","764":"Thailand","768":"Togo","780":"Trinidad and Tobago","788":"Tunisia","792":"Turkey","795":"Turkmenistan","800":"Uganda","804":"Ukraine","784":"United Arab Emirates","826":"United Kingdom","840":"United States of America","858":"Uruguay","860":"Uzbekistan","862":"Venezuela","704":"Vietnam","887":"Yemen","894":"Zambia","716":"Zimbabwe"}
|
||||
|
||||
// Our country names from addresses → match against GeoJSON names
|
||||
function isCountryMatch(geoName, visitedCountries) {
|
||||
if (!geoName) return false
|
||||
const lower = geoName.toLowerCase()
|
||||
return visitedCountries.some(c => {
|
||||
const cl = c.toLowerCase()
|
||||
return lower === cl || lower.includes(cl) || cl.includes(lower)
|
||||
// Handle common mismatches
|
||||
|| (cl === 'usa' && lower.includes('united states'))
|
||||
|| (cl === 'uk' && lower === 'united kingdom')
|
||||
|| (cl === 'south korea' && lower === 'korea' || lower === 'south korea')
|
||||
|| (cl === 'deutschland' && lower === 'germany')
|
||||
|| (cl === 'frankreich' && lower === 'france')
|
||||
|| (cl === 'italien' && lower === 'italy')
|
||||
|| (cl === 'spanien' && lower === 'spain')
|
||||
|| (cl === 'österreich' && lower === 'austria')
|
||||
|| (cl === 'schweiz' && lower === 'switzerland')
|
||||
|| (cl === 'niederlande' && lower === 'netherlands')
|
||||
|| (cl === 'türkei' && (lower === 'turkey' || lower === 'türkiye'))
|
||||
|| (cl === 'griechenland' && lower === 'greece')
|
||||
|| (cl === 'tschechien' && (lower === 'czech republic' || lower === 'czechia'))
|
||||
|| (cl === 'ägypten' && lower === 'egypt')
|
||||
|| (cl === 'südkorea' && lower.includes('korea'))
|
||||
|| (cl === 'indien' && lower === 'india')
|
||||
|| (cl === 'brasilien' && lower === 'brazil')
|
||||
|| (cl === 'argentinien' && lower === 'argentina')
|
||||
|| (cl === 'russland' && lower === 'russia')
|
||||
|| (cl === 'australien' && lower === 'australia')
|
||||
|| (cl === 'kanada' && lower === 'canada')
|
||||
|| (cl === 'mexiko' && lower === 'mexico')
|
||||
|| (cl === 'neuseeland' && lower === 'new zealand')
|
||||
|| (cl === 'singapur' && lower === 'singapore')
|
||||
|| (cl === 'kroatien' && lower === 'croatia')
|
||||
|| (cl === 'ungarn' && lower === 'hungary')
|
||||
|| (cl === 'rumänien' && lower === 'romania')
|
||||
|| (cl === 'polen' && lower === 'poland')
|
||||
|| (cl === 'schweden' && lower === 'sweden')
|
||||
|| (cl === 'norwegen' && lower === 'norway')
|
||||
|| (cl === 'dänemark' && lower === 'denmark')
|
||||
|| (cl === 'finnland' && lower === 'finland')
|
||||
|| (cl === 'irland' && lower === 'ireland')
|
||||
|| (cl === 'portugal' && lower === 'portugal')
|
||||
|| (cl === 'belgien' && lower === 'belgium')
|
||||
})
|
||||
}
|
||||
|
||||
const TOTAL_COUNTRIES = 195
|
||||
|
||||
// Simple Mercator projection for SVG
|
||||
function project(lon, lat, width, height) {
|
||||
const clampedLat = Math.max(-75, Math.min(83, lat))
|
||||
const x = ((lon + 180) / 360) * width
|
||||
const latRad = (clampedLat * Math.PI) / 180
|
||||
const mercN = Math.log(Math.tan(Math.PI / 4 + latRad / 2))
|
||||
const y = (height / 2) - (width * mercN) / (2 * Math.PI)
|
||||
return [x, y]
|
||||
}
|
||||
|
||||
function geoToPath(coords, width, height) {
|
||||
return coords.map((ring) => {
|
||||
// Split ring at dateline crossings to avoid horizontal stripes
|
||||
const segments = [[]]
|
||||
for (let i = 0; i < ring.length; i++) {
|
||||
const [lon, lat] = ring[i]
|
||||
if (i > 0) {
|
||||
const prevLon = ring[i - 1][0]
|
||||
if (Math.abs(lon - prevLon) > 180) {
|
||||
// Dateline crossing — start new segment
|
||||
segments.push([])
|
||||
}
|
||||
}
|
||||
const [x, y] = project(lon, Math.max(-75, Math.min(83, lat)), width, height)
|
||||
segments[segments.length - 1].push(`${x.toFixed(1)},${y.toFixed(1)}`)
|
||||
}
|
||||
return segments
|
||||
.filter(s => s.length > 2)
|
||||
.map(s => 'M' + s.join('L') + 'Z')
|
||||
.join(' ')
|
||||
}).join(' ')
|
||||
}
|
||||
|
||||
let geoJsonCache = null
|
||||
async function loadGeoJson() {
|
||||
if (geoJsonCache) return geoJsonCache
|
||||
try {
|
||||
const res = await fetch('https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json')
|
||||
const topo = await res.json()
|
||||
const { feature } = await import('topojson-client')
|
||||
const geo = feature(topo, topo.objects.countries)
|
||||
geo.features.forEach(f => {
|
||||
f.properties.name = NUMERIC_TO_NAME[f.id] || f.properties?.name || ''
|
||||
})
|
||||
geoJsonCache = geo
|
||||
return geo
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
export default function TravelStats() {
|
||||
const { t } = useTranslation()
|
||||
const dm = useSettingsStore(s => s.settings.dark_mode)
|
||||
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
const [stats, setStats] = useState(null)
|
||||
const [geoData, setGeoData] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
authApi.travelStats().then(setStats).catch(() => {})
|
||||
loadGeoJson().then(setGeoData)
|
||||
}, [])
|
||||
|
||||
const countryCount = stats?.countries?.length || 0
|
||||
const worldPercent = ((countryCount / TOTAL_COUNTRIES) * 100).toFixed(1)
|
||||
|
||||
if (!stats || stats.totalPlaces === 0) return null
|
||||
|
||||
return (
|
||||
<div style={{ width: 340 }}>
|
||||
{/* Stats Card */}
|
||||
<div style={{
|
||||
borderRadius: 20, overflow: 'hidden', height: 300,
|
||||
display: 'flex', flexDirection: 'column', justifyContent: 'center',
|
||||
border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-card)',
|
||||
padding: 16,
|
||||
}}>
|
||||
{/* Progress bar */}
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 6 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('stats.worldProgress')}</span>
|
||||
<span style={{ fontSize: 20, fontWeight: 800, color: 'var(--text-primary)' }}>{worldPercent}%</span>
|
||||
</div>
|
||||
<div style={{ height: 6, borderRadius: 99, background: 'var(--bg-hover)', overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
height: '100%', borderRadius: 99,
|
||||
background: dark ? 'linear-gradient(90deg, #e2e8f0, #cbd5e1)' : 'linear-gradient(90deg, #111827, #374151)',
|
||||
width: `${Math.max(1, parseFloat(worldPercent))}%`,
|
||||
transition: 'width 0.5s ease',
|
||||
}} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 4 }}>
|
||||
<span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{countryCount} {t('stats.visited')}</span>
|
||||
<span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{TOTAL_COUNTRIES - countryCount} {t('stats.remaining')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stat grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, marginBottom: 14 }}>
|
||||
<StatBox icon={Globe} value={countryCount} label={t('stats.countries')} />
|
||||
<StatBox icon={MapPin} value={stats.cities.length} label={t('stats.cities')} />
|
||||
<StatBox icon={Plane} value={stats.totalTrips} label={t('stats.trips')} />
|
||||
<StatBox icon={MapPin} value={stats.totalPlaces} label={t('stats.places')} />
|
||||
</div>
|
||||
|
||||
{/* Country tags */}
|
||||
{stats.countries.length > 0 && (
|
||||
<>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', marginBottom: 6 }}>{t('stats.visitedCountries')}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{stats.countries.map(c => (
|
||||
<span key={c} style={{
|
||||
fontSize: 10.5, fontWeight: 500, color: 'var(--text-secondary)',
|
||||
background: 'var(--bg-hover)', borderRadius: 99, padding: '3px 9px',
|
||||
}}>{c}</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatBox({ icon: Icon, value, label }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '8px 10px',
|
||||
borderRadius: 10, background: 'var(--bg-hover)',
|
||||
}}>
|
||||
<Icon size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1 }}>{value}</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 1 }}>{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useCallback } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote } from 'lucide-react'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { Place, Reservation, TripFile } from '../../types'
|
||||
|
||||
function isImage(mimeType) {
|
||||
if (!mimeType) return false
|
||||
@@ -32,7 +34,12 @@ function formatDateWithLocale(dateStr, locale) {
|
||||
}
|
||||
|
||||
// Image lightbox
|
||||
function ImageLightbox({ file, onClose }) {
|
||||
interface ImageLightboxProps {
|
||||
file: TripFile & { url: string }
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function ImageLightbox({ file, onClose }: ImageLightboxProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div
|
||||
@@ -62,7 +69,12 @@ function ImageLightbox({ file, onClose }) {
|
||||
}
|
||||
|
||||
// Source badge — unified style for both place and reservation
|
||||
function SourceBadge({ icon: Icon, label }) {
|
||||
interface SourceBadgeProps {
|
||||
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
|
||||
label: string
|
||||
}
|
||||
|
||||
function SourceBadge({ icon: Icon, label }: SourceBadgeProps) {
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
@@ -77,7 +89,18 @@ function SourceBadge({ icon: Icon, label }) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, reservations = [], tripId, allowedFileTypes }) {
|
||||
interface FileManagerProps {
|
||||
files?: TripFile[]
|
||||
onUpload: (fd: FormData) => Promise<void>
|
||||
onDelete: (fileId: number) => Promise<void>
|
||||
onUpdate: (fileId: number, data: Partial<TripFile>) => Promise<void>
|
||||
places: Place[]
|
||||
reservations?: Reservation[]
|
||||
tripId: number
|
||||
allowedFileTypes: Record<string, string[]>
|
||||
}
|
||||
|
||||
export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, reservations = [], tripId, allowedFileTypes }: FileManagerProps) {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [filterType, setFilterType] = useState('all')
|
||||
const [lightboxFile, setLightboxFile] = useState(null)
|
||||
@@ -112,7 +135,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
const files = []
|
||||
for (const item of items) {
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile()
|
||||
if (file) files.push(file)
|
||||
@@ -2,7 +2,26 @@ import React, { useState, useEffect } from 'react'
|
||||
import { Info, Github, Shield, Key, Users, Database, Upload, Clock, Puzzle, CalendarDays, Globe, ArrowRightLeft, Map, Briefcase, ListChecks, Wallet, FileText, Plane } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
const texts = {
|
||||
interface DemoTexts {
|
||||
titleBefore: string
|
||||
titleAfter: string
|
||||
title: string
|
||||
description: string
|
||||
resetIn: string
|
||||
minutes: string
|
||||
uploadNote: string
|
||||
fullVersionTitle: string
|
||||
features: string[]
|
||||
addonsTitle: string
|
||||
addons: [string, string][]
|
||||
whatIs: string
|
||||
whatIsDesc: string
|
||||
selfHost: string
|
||||
selfHostLink: string
|
||||
close: string
|
||||
}
|
||||
|
||||
const texts: Record<string, DemoTexts> = {
|
||||
de: {
|
||||
titleBefore: 'Willkommen bei ',
|
||||
titleAfter: '',
|
||||
@@ -72,9 +91,9 @@ const texts = {
|
||||
const featureIcons = [Upload, Key, Users, Database, Puzzle, Shield]
|
||||
const addonIcons = [CalendarDays, Globe, ListChecks, Wallet, FileText, ArrowRightLeft]
|
||||
|
||||
export default function DemoBanner() {
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
const [minutesLeft, setMinutesLeft] = useState(59 - new Date().getMinutes())
|
||||
export default function DemoBanner(): React.ReactElement | null {
|
||||
const [dismissed, setDismissed] = useState<boolean>(false)
|
||||
const [minutesLeft, setMinutesLeft] = useState<number>(59 - new Date().getMinutes())
|
||||
const { language } = useTranslation()
|
||||
const t = texts[language] || texts.en
|
||||
|
||||
@@ -98,7 +117,7 @@ export default function DemoBanner() {
|
||||
maxWidth: 480, width: '100%',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
maxHeight: '90vh', overflow: 'auto',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
}} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
|
||||
@@ -6,18 +6,34 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { addonsApi } from '../../api/client'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
const ADDON_ICONS = { CalendarDays, Briefcase, Globe }
|
||||
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe }
|
||||
|
||||
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }) {
|
||||
interface NavbarProps {
|
||||
tripTitle?: string
|
||||
tripId?: string
|
||||
onBack?: () => void
|
||||
showBack?: boolean
|
||||
onShare?: () => void
|
||||
}
|
||||
|
||||
interface Addon {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement {
|
||||
const { user, logout } = useAuthStore()
|
||||
const { settings, updateSetting } = useSettingsStore()
|
||||
const { t, locale } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||
const [appVersion, setAppVersion] = useState(null)
|
||||
const [globalAddons, setGlobalAddons] = useState([])
|
||||
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null)
|
||||
const [globalAddons, setGlobalAddons] = useState<Addon[]>([])
|
||||
const darkMode = settings.dark_mode
|
||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef, useState, useMemo } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useEffect, useRef, useState, useMemo } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, useMap } from 'react-leaflet'
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
import L from 'leaflet'
|
||||
@@ -7,6 +7,7 @@ import 'leaflet.markercluster/dist/MarkerCluster.css'
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import type { Place } from '../../types'
|
||||
|
||||
// Fix default marker icons for vite
|
||||
delete L.Icon.Default.prototype._getIconUrl
|
||||
@@ -93,7 +94,14 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
})
|
||||
}
|
||||
|
||||
function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }) {
|
||||
interface SelectionControllerProps {
|
||||
places: Place[]
|
||||
selectedPlaceId: number | null
|
||||
dayPlaces: Place[]
|
||||
paddingOpts: Record<string, number>
|
||||
}
|
||||
|
||||
function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }: SelectionControllerProps) {
|
||||
const map = useMap()
|
||||
const prev = useRef(null)
|
||||
|
||||
@@ -117,7 +125,12 @@ function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }
|
||||
return null
|
||||
}
|
||||
|
||||
function MapController({ center, zoom }) {
|
||||
interface MapControllerProps {
|
||||
center: [number, number]
|
||||
zoom: number
|
||||
}
|
||||
|
||||
function MapController({ center, zoom }: MapControllerProps) {
|
||||
const map = useMap()
|
||||
const prevCenter = useRef(center)
|
||||
|
||||
@@ -132,7 +145,13 @@ function MapController({ center, zoom }) {
|
||||
}
|
||||
|
||||
// Fit bounds when places change (fitKey triggers re-fit)
|
||||
function BoundsController({ places, fitKey, paddingOpts }) {
|
||||
interface BoundsControllerProps {
|
||||
places: Place[]
|
||||
fitKey: number
|
||||
paddingOpts: Record<string, number>
|
||||
}
|
||||
|
||||
function BoundsController({ places, fitKey, paddingOpts }: BoundsControllerProps) {
|
||||
const map = useMap()
|
||||
const prevFitKey = useRef(-1)
|
||||
|
||||
@@ -149,7 +168,11 @@ function BoundsController({ places, fitKey, paddingOpts }) {
|
||||
return null
|
||||
}
|
||||
|
||||
function MapClickHandler({ onClick }) {
|
||||
interface MapClickHandlerProps {
|
||||
onClick: ((e: L.LeafletMouseEvent) => void) | null
|
||||
}
|
||||
|
||||
function MapClickHandler({ onClick }: MapClickHandlerProps) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
if (!onClick) return
|
||||
@@ -160,7 +183,13 @@ function MapClickHandler({ onClick }) {
|
||||
}
|
||||
|
||||
// ── Route travel time label ──
|
||||
function RouteLabel({ midpoint, walkingText, drivingText }) {
|
||||
interface RouteLabelProps {
|
||||
midpoint: [number, number]
|
||||
walkingText: string
|
||||
drivingText: string
|
||||
}
|
||||
|
||||
function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
|
||||
const map = useMap()
|
||||
const [visible, setVisible] = useState(map ? map.getZoom() >= 12 : false)
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
// OSRM routing utility - free, no API key required
|
||||
import type { RouteResult, RouteSegment, Waypoint } from '../../types'
|
||||
|
||||
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
||||
|
||||
/**
|
||||
* Calculate a route between multiple waypoints using OSRM
|
||||
* @param {Array<{lat: number, lng: number}>} waypoints
|
||||
* @param {string} profile - 'driving' | 'walking' | 'cycling'
|
||||
* @returns {Promise<{coordinates: Array<[number,number]>, distance: number, duration: number, distanceText: string, durationText: string}>}
|
||||
*/
|
||||
export async function calculateRoute(waypoints, profile = 'driving', { signal } = {}) {
|
||||
/** Fetches a full route via OSRM and returns coordinates, distance, and duration estimates for driving/walking. */
|
||||
export async function calculateRoute(
|
||||
waypoints: Waypoint[],
|
||||
profile: 'driving' | 'walking' | 'cycling' = 'driving',
|
||||
{ signal }: { signal?: AbortSignal } = {}
|
||||
): Promise<RouteResult> {
|
||||
if (!waypoints || waypoints.length < 2) {
|
||||
throw new Error('At least 2 waypoints required')
|
||||
}
|
||||
|
||||
const coords = waypoints.map(p => `${p.lng},${p.lat}`).join(';')
|
||||
// OSRM public API only supports driving; we override duration for other modes
|
||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||
const url = `${OSRM_BASE}/driving/${coords}?overview=full&geometries=geojson&steps=false`
|
||||
|
||||
const response = await fetch(url, { signal })
|
||||
@@ -28,21 +27,20 @@ export async function calculateRoute(waypoints, profile = 'driving', { signal }
|
||||
}
|
||||
|
||||
const route = data.routes[0]
|
||||
const coordinates = route.geometry.coordinates.map(([lng, lat]) => [lat, lng])
|
||||
const coordinates: [number, number][] = route.geometry.coordinates.map(([lng, lat]: [number, number]) => [lat, lng])
|
||||
|
||||
const distance = route.distance // meters
|
||||
// Compute duration based on mode (walking: 5 km/h, cycling: 15 km/h)
|
||||
let duration
|
||||
const distance: number = route.distance
|
||||
let duration: number
|
||||
if (profile === 'walking') {
|
||||
duration = distance / (5000 / 3600)
|
||||
} else if (profile === 'cycling') {
|
||||
duration = distance / (15000 / 3600)
|
||||
} else {
|
||||
duration = route.duration // driving: use OSRM value
|
||||
duration = route.duration
|
||||
}
|
||||
|
||||
const walkingDuration = distance / (5000 / 3600) // 5 km/h
|
||||
const drivingDuration = route.duration // OSRM driving value
|
||||
const walkingDuration = distance / (5000 / 3600)
|
||||
const drivingDuration: number = route.duration
|
||||
|
||||
return {
|
||||
coordinates,
|
||||
@@ -55,29 +53,23 @@ export async function calculateRoute(waypoints, profile = 'driving', { signal }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a Google Maps directions URL for the given places
|
||||
*/
|
||||
export function generateGoogleMapsUrl(places) {
|
||||
const valid = places.filter(p => p.lat && p.lng)
|
||||
export function generateGoogleMapsUrl(places: Waypoint[]): string | null {
|
||||
const valid = places.filter((p) => p.lat && p.lng)
|
||||
if (valid.length === 0) return null
|
||||
if (valid.length === 1) {
|
||||
return `https://www.google.com/maps/search/?api=1&query=${valid[0].lat},${valid[0].lng}`
|
||||
}
|
||||
// Use /dir/stop1/stop2/.../stopN format — all stops as path segments
|
||||
const stops = valid.map(p => `${p.lat},${p.lng}`).join('/')
|
||||
const stops = valid.map((p) => `${p.lat},${p.lng}`).join('/')
|
||||
return `https://www.google.com/maps/dir/${stops}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple nearest-neighbor route optimization
|
||||
*/
|
||||
export function optimizeRoute(places) {
|
||||
const valid = places.filter(p => p.lat && p.lng)
|
||||
/** Reorders waypoints using a nearest-neighbor heuristic to minimize total Euclidean distance. */
|
||||
export function optimizeRoute(places: Waypoint[]): Waypoint[] {
|
||||
const valid = places.filter((p) => p.lat && p.lng)
|
||||
if (valid.length <= 2) return places
|
||||
|
||||
const visited = new Set()
|
||||
const result = []
|
||||
const visited = new Set<number>()
|
||||
const result: Waypoint[] = []
|
||||
let current = valid[0]
|
||||
visited.add(0)
|
||||
result.push(current)
|
||||
@@ -100,14 +92,14 @@ export function optimizeRoute(places) {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate per-leg travel times in a single OSRM request
|
||||
* Returns array of { mid, walkingText, drivingText } for each leg
|
||||
*/
|
||||
export async function calculateSegments(waypoints, { signal } = {}) {
|
||||
/** Fetches per-leg distance/duration from OSRM and returns segment metadata (midpoints, walking/driving times). */
|
||||
export async function calculateSegments(
|
||||
waypoints: Waypoint[],
|
||||
{ signal }: { signal?: AbortSignal } = {}
|
||||
): Promise<RouteSegment[]> {
|
||||
if (!waypoints || waypoints.length < 2) return []
|
||||
|
||||
const coords = waypoints.map(p => `${p.lng},${p.lat}`).join(';')
|
||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||
const url = `${OSRM_BASE}/driving/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration`
|
||||
|
||||
const response = await fetch(url, { signal })
|
||||
@@ -117,11 +109,11 @@ export async function calculateSegments(waypoints, { signal } = {}) {
|
||||
if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found')
|
||||
|
||||
const legs = data.routes[0].legs
|
||||
return legs.map((leg, i) => {
|
||||
const from = [waypoints[i].lat, waypoints[i].lng]
|
||||
const to = [waypoints[i + 1].lat, waypoints[i + 1].lng]
|
||||
const mid = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2]
|
||||
const walkingDuration = leg.distance / (5000 / 3600) // 5 km/h
|
||||
return legs.map((leg: { distance: number; duration: number }, i: number): RouteSegment => {
|
||||
const from: [number, number] = [waypoints[i].lat, waypoints[i].lng]
|
||||
const to: [number, number] = [waypoints[i + 1].lat, waypoints[i + 1].lng]
|
||||
const mid: [number, number] = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2]
|
||||
const walkingDuration = leg.distance / (5000 / 3600)
|
||||
return {
|
||||
mid, from, to,
|
||||
walkingText: formatDuration(walkingDuration),
|
||||
@@ -130,14 +122,14 @@ export async function calculateSegments(waypoints, { signal } = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
function formatDistance(meters) {
|
||||
function formatDistance(meters: number): string {
|
||||
if (meters < 1000) {
|
||||
return `${Math.round(meters)} m`
|
||||
}
|
||||
return `${(meters / 1000).toFixed(1)} km`
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
if (h > 0) {
|
||||
@@ -3,6 +3,7 @@ import { createElement } from 'react'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark } from 'lucide-react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
|
||||
|
||||
const NOTE_ICON_MAP = { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark }
|
||||
function noteIconSvg(iconId) {
|
||||
@@ -88,7 +89,18 @@ async function fetchPlacePhotos(assignments) {
|
||||
return photoMap
|
||||
}
|
||||
|
||||
export async function downloadTripPDF({ trip, days, places, assignments, categories, dayNotes, t: _t, locale: _locale }) {
|
||||
interface downloadTripPDFProps {
|
||||
trip: Trip
|
||||
days: Day[]
|
||||
places: Place[]
|
||||
assignments: AssignmentsMap
|
||||
categories: Category[]
|
||||
dayNotes: DayNotesMap
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
locale: string
|
||||
}
|
||||
|
||||
export async function downloadTripPDF({ trip, days, places, assignments, categories, dayNotes, t: _t, locale: _locale }: downloadTripPDFProps) {
|
||||
await ensureRenderer()
|
||||
const loc = _locale || 'de-DE'
|
||||
const tr = _t || (k => k)
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo, useRef } from 'react'
|
||||
import { useState, useMemo, useRef } from 'react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
CheckSquare, Square, Trash2, Plus, ChevronDown, ChevronRight,
|
||||
Sparkles, X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage,
|
||||
} from 'lucide-react'
|
||||
import type { PackingItem } from '../../types'
|
||||
|
||||
const VORSCHLAEGE = [
|
||||
{ name: 'Passport', category: 'Documents' },
|
||||
@@ -64,7 +65,14 @@ function katColor(kat, allCategories) {
|
||||
}
|
||||
|
||||
// ── Artikel-Zeile ──────────────────────────────────────────────────────────
|
||||
function ArtikelZeile({ item, tripId, categories, onCategoryChange }) {
|
||||
interface ArtikelZeileProps {
|
||||
item: PackingItem
|
||||
tripId: number
|
||||
categories: string[]
|
||||
onCategoryChange: () => void
|
||||
}
|
||||
|
||||
function ArtikelZeile({ item, tripId, categories, onCategoryChange }: ArtikelZeileProps) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editName, setEditName] = useState(item.name)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
@@ -178,7 +186,16 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange }) {
|
||||
}
|
||||
|
||||
// ── Kategorie-Gruppe ───────────────────────────────────────────────────────
|
||||
function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll }) {
|
||||
interface KategorieGruppeProps {
|
||||
kategorie: string
|
||||
items: PackingItem[]
|
||||
tripId: number
|
||||
allCategories: string[]
|
||||
onRename: (oldName: string, newName: string) => Promise<void>
|
||||
onDeleteAll: (items: PackingItem[]) => Promise<void>
|
||||
}
|
||||
|
||||
function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll }: KategorieGruppeProps) {
|
||||
const [offen, setOffen] = useState(true)
|
||||
const [editingName, setEditingName] = useState(false)
|
||||
const [editKatName, setEditKatName] = useState(kategorie)
|
||||
@@ -198,12 +215,12 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
}
|
||||
|
||||
const handleCheckAll = async () => {
|
||||
for (const item of items) {
|
||||
for (const item of Array.from(items)) {
|
||||
if (!item.checked) await togglePackingItem(tripId, item.id, true)
|
||||
}
|
||||
}
|
||||
const handleUncheckAll = async () => {
|
||||
for (const item of items) {
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.checked) await togglePackingItem(tripId, item.id, false)
|
||||
}
|
||||
}
|
||||
@@ -272,7 +289,14 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
)
|
||||
}
|
||||
|
||||
function MenuItem({ icon, label, onClick, danger }) {
|
||||
interface MenuItemProps {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
onClick: () => void
|
||||
danger: boolean
|
||||
}
|
||||
|
||||
function MenuItem({ icon, label, onClick, danger }: MenuItemProps) {
|
||||
return (
|
||||
<button onClick={onClick} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||
@@ -289,7 +313,12 @@ function MenuItem({ icon, label, onClick, danger }) {
|
||||
}
|
||||
|
||||
// ── Haupt-Panel ────────────────────────────────────────────────────────────
|
||||
export default function PackingListPanel({ tripId, items }) {
|
||||
interface PackingListPanelProps {
|
||||
tripId: number
|
||||
items: PackingItem[]
|
||||
}
|
||||
|
||||
export default function PackingListPanel({ tripId, items }: PackingListPanelProps) {
|
||||
const [neuerName, setNeuerName] = useState('')
|
||||
const [neueKategorie, setNeueKategorie] = useState('')
|
||||
const [zeigeVorschlaege, setZeigeVorschlaege] = useState(false)
|
||||
@@ -1,11 +1,22 @@
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { PhotoLightbox } from './PhotoLightbox'
|
||||
import { PhotoUpload } from './PhotoUpload'
|
||||
import { Upload, Camera } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { Photo, Place, Day } from '../../types'
|
||||
|
||||
export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, places, days, tripId }) {
|
||||
interface PhotoGalleryProps {
|
||||
photos: Photo[]
|
||||
onUpload: (fd: FormData) => Promise<void>
|
||||
onDelete: (photoId: number) => Promise<void>
|
||||
onUpdate: (photoId: number, data: Partial<Photo>) => Promise<void>
|
||||
places: Place[]
|
||||
days: Day[]
|
||||
tripId: number
|
||||
}
|
||||
|
||||
export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, places, days, tripId }: PhotoGalleryProps) {
|
||||
const { t } = useTranslation()
|
||||
const [lightboxIndex, setLightboxIndex] = useState(null)
|
||||
const [showUpload, setShowUpload] = useState(false)
|
||||
@@ -153,7 +164,14 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
||||
)
|
||||
}
|
||||
|
||||
function PhotoThumbnail({ photo, days, places, onClick }) {
|
||||
interface PhotoThumbnailProps {
|
||||
photo: Photo
|
||||
days: Day[]
|
||||
places: Place[]
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function PhotoThumbnail({ photo, days, places, onClick }: PhotoThumbnailProps) {
|
||||
const day = days?.find(d => d.id === photo.day_id)
|
||||
const place = places?.find(p => p.id === photo.place_id)
|
||||
|
||||
@@ -168,8 +186,8 @@ function PhotoThumbnail({ photo, days, places, onClick }) {
|
||||
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
onError={e => {
|
||||
e.target.style.display = 'none'
|
||||
e.target.nextSibling && (e.target.nextSibling.style.display = 'flex')
|
||||
(e.target as HTMLImageElement).style.display = 'none'
|
||||
const next = (e.target as HTMLImageElement).nextSibling as HTMLElement; if (next) next.style.display = 'flex'
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { X, ChevronLeft, ChevronRight, Edit2, Trash2, Check } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { Photo, Place, Day } from '../../types'
|
||||
|
||||
export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelete, days, places, tripId }) {
|
||||
interface PhotoLightboxProps {
|
||||
photos: Photo[]
|
||||
initialIndex: number
|
||||
onClose: () => void
|
||||
onUpdate: (photoId: number, data: Partial<Photo>) => Promise<void>
|
||||
onDelete: (photoId: number) => Promise<void>
|
||||
days: Day[]
|
||||
places: Place[]
|
||||
tripId: number
|
||||
}
|
||||
|
||||
export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelete, days, places, tripId }: PhotoLightboxProps) {
|
||||
const { t } = useTranslation()
|
||||
const [index, setIndex] = useState(initialIndex || 0)
|
||||
const [editCaption, setEditCaption] = useState(false)
|
||||
@@ -1,9 +1,18 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { Upload, X, Image } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { Place, Day } from '../../types'
|
||||
|
||||
export function PhotoUpload({ tripId, days, places, onUpload, onClose }) {
|
||||
interface PhotoUploadProps {
|
||||
tripId: number
|
||||
days: Day[]
|
||||
places: Place[]
|
||||
onUpload: (fd: FormData) => Promise<void>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUploadProps) {
|
||||
const { t } = useTranslation()
|
||||
const [files, setFiles] = useState([])
|
||||
const [dayId, setDayId] = useState('')
|
||||
@@ -48,7 +57,7 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }) {
|
||||
await onUpload(formData)
|
||||
files.forEach(f => URL.revokeObjectURL(f.preview))
|
||||
setFiles([])
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
console.error('Upload failed:', err)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
@@ -1,503 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { mapsApi, tagsApi, categoriesApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Search, Plus, MapPin, Loader } from 'lucide-react'
|
||||
|
||||
const STATUSES = [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'confirmed', label: 'Confirmed' },
|
||||
]
|
||||
|
||||
export default function PlaceFormModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
place,
|
||||
tripId,
|
||||
categories: initialCategories = [],
|
||||
tags: initialTags = [],
|
||||
onCategoryCreated,
|
||||
onTagCreated,
|
||||
}) {
|
||||
const isEditing = !!place
|
||||
const { user, hasMapsKey } = useAuthStore()
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
|
||||
const [categories, setCategories] = useState(initialCategories)
|
||||
const [tags, setTags] = useState(initialTags)
|
||||
|
||||
useEffect(() => { setCategories(initialCategories) }, [initialCategories])
|
||||
useEffect(() => { setTags(initialTags) }, [initialTags])
|
||||
|
||||
const emptyForm = {
|
||||
name: '',
|
||||
description: '',
|
||||
address: '',
|
||||
lat: '',
|
||||
lng: '',
|
||||
category_id: '',
|
||||
place_time: '',
|
||||
reservation_status: 'none',
|
||||
reservation_notes: '',
|
||||
reservation_datetime: '',
|
||||
google_place_id: '',
|
||||
website: '',
|
||||
tags: [],
|
||||
}
|
||||
|
||||
const [formData, setFormData] = useState(emptyForm)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Maps search state
|
||||
const [mapQuery, setMapQuery] = useState('')
|
||||
const [mapResults, setMapResults] = useState([])
|
||||
const [mapSearching, setMapSearching] = useState(false)
|
||||
|
||||
// New category/tag
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [newCategoryColor, setNewCategoryColor] = useState('#374151')
|
||||
const [showNewCategory, setShowNewCategory] = useState(false)
|
||||
const [newTagName, setNewTagName] = useState('')
|
||||
const [newTagColor, setNewTagColor] = useState('#374151')
|
||||
const [showNewTag, setShowNewTag] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (place && isOpen) {
|
||||
setFormData({
|
||||
name: place.name || '',
|
||||
description: place.description || '',
|
||||
address: place.address || '',
|
||||
lat: place.lat ?? '',
|
||||
lng: place.lng ?? '',
|
||||
category_id: place.category_id || '',
|
||||
place_time: place.place_time || '',
|
||||
reservation_status: place.reservation_status || 'none',
|
||||
reservation_notes: place.reservation_notes || '',
|
||||
reservation_datetime: place.reservation_datetime || '',
|
||||
google_place_id: place.google_place_id || '',
|
||||
website: place.website || '',
|
||||
tags: (place.tags || []).map(t => t.id),
|
||||
})
|
||||
} else if (!place && isOpen) {
|
||||
setFormData(emptyForm)
|
||||
}
|
||||
setError('')
|
||||
setMapResults([])
|
||||
setMapQuery('')
|
||||
}, [place, isOpen])
|
||||
|
||||
const update = (field, value) => setFormData(prev => ({ ...prev, [field]: value }))
|
||||
|
||||
const toggleTag = (tagId) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
tags: prev.tags.includes(tagId)
|
||||
? prev.tags.filter(id => id !== tagId)
|
||||
: [...prev.tags, tagId]
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!formData.name.trim()) {
|
||||
setError('Place name is required')
|
||||
return
|
||||
}
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
await onSave({
|
||||
...formData,
|
||||
lat: formData.lat !== '' ? parseFloat(formData.lat) : null,
|
||||
lng: formData.lng !== '' ? parseFloat(formData.lng) : null,
|
||||
category_id: formData.category_id || null,
|
||||
})
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to save place')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const [searchSource, setSearchSource] = useState(null)
|
||||
|
||||
const handleMapSearch = async () => {
|
||||
if (!mapQuery.trim()) return
|
||||
setMapSearching(true)
|
||||
try {
|
||||
const data = await mapsApi.search(mapQuery)
|
||||
setMapResults(data.places || [])
|
||||
setSearchSource(data.source || 'google')
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('places.mapsSearchError'))
|
||||
} finally {
|
||||
setMapSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const selectMapPlace = (p) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
name: p.name || prev.name,
|
||||
address: p.address || prev.address,
|
||||
lat: p.lat ?? prev.lat,
|
||||
lng: p.lng ?? prev.lng,
|
||||
google_place_id: p.google_place_id || prev.google_place_id,
|
||||
website: p.website || prev.website,
|
||||
}))
|
||||
setMapResults([])
|
||||
setMapQuery('')
|
||||
}
|
||||
|
||||
const handleCreateCategory = async () => {
|
||||
if (!newCategoryName.trim()) return
|
||||
try {
|
||||
const data = await categoriesApi.create({ name: newCategoryName, color: newCategoryColor, icon: 'MapPin' })
|
||||
setCategories(prev => [...prev, data.category])
|
||||
if (onCategoryCreated) onCategoryCreated(data.category)
|
||||
setFormData(prev => ({ ...prev, category_id: data.category.id }))
|
||||
setNewCategoryName('')
|
||||
setShowNewCategory(false)
|
||||
toast.success('Category created')
|
||||
} catch (err) {
|
||||
toast.error('Failed to create category')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateTag = async () => {
|
||||
if (!newTagName.trim()) return
|
||||
try {
|
||||
const data = await tagsApi.create({ name: newTagName, color: newTagColor })
|
||||
setTags(prev => [...prev, data.tag])
|
||||
if (onTagCreated) onTagCreated(data.tag)
|
||||
setFormData(prev => ({ ...prev, tags: [...prev.tags, data.tag.id] }))
|
||||
setNewTagName('')
|
||||
setShowNewTag(false)
|
||||
toast.success('Tag created')
|
||||
} catch (err) {
|
||||
toast.error('Failed to create tag')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={isEditing ? 'Edit Place' : 'Add Place'}
|
||||
size="xl"
|
||||
footer={
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 text-sm bg-slate-900 hover:bg-slate-700 disabled:bg-slate-400 text-white rounded-lg flex items-center gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
||||
Saving...
|
||||
</>
|
||||
) : isEditing ? 'Save Changes' : 'Add Place'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Place search — Google Maps or OpenStreetMap fallback */}
|
||||
<div className="bg-slate-50 rounded-xl p-3 border border-slate-200">
|
||||
{!hasMapsKey && (
|
||||
<p className="mb-2 text-xs" style={{ color: 'var(--text-faint)' }}>
|
||||
{t('places.osmActive')}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={mapQuery}
|
||||
onChange={e => setMapQuery(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleMapSearch()}
|
||||
placeholder={t('places.mapsSearchPlaceholder')}
|
||||
className="w-full pl-8 pr-3 py-2 text-sm border border-slate-200 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-transparent bg-white"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleMapSearch}
|
||||
disabled={mapSearching}
|
||||
className="px-3 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-50"
|
||||
>
|
||||
{mapSearching ? <Loader className="w-4 h-4 animate-spin" /> : t('common.search')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mapResults.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 max-h-48 overflow-y-auto mt-2">
|
||||
{mapResults.map((p, i) => (
|
||||
<button
|
||||
key={p.google_place_id || i}
|
||||
onClick={() => selectMapPlace(p)}
|
||||
className="w-full text-left px-3 py-2.5 hover:bg-slate-50 transition-colors border-b border-slate-100 last:border-0"
|
||||
>
|
||||
<p className="text-sm font-medium text-slate-900">{p.name}</p>
|
||||
<p className="text-xs text-slate-500 truncate flex items-center gap-1 mt-0.5">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{p.address}
|
||||
</p>
|
||||
{p.rating && (
|
||||
<p className="text-xs text-amber-600 mt-0.5">★ {p.rating}</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={e => update('name', e.target.value)}
|
||||
required
|
||||
placeholder="e.g. Eiffel Tower"
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Description</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={e => update('description', e.target.value)}
|
||||
placeholder="Notes about this place..."
|
||||
rows={2}
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Address</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.address}
|
||||
onChange={e => update('address', e.target.value)}
|
||||
placeholder="Street address"
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Lat / Lng */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Latitude</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={formData.lat}
|
||||
onChange={e => update('lat', e.target.value)}
|
||||
placeholder="e.g. 48.8584"
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Longitude</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={formData.lng}
|
||||
onChange={e => update('lng', e.target.value)}
|
||||
placeholder="e.g. 2.2945"
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Category</label>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={formData.category_id}
|
||||
onChange={e => update('category_id', e.target.value)}
|
||||
className="flex-1 px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent bg-white"
|
||||
>
|
||||
<option value="">No category</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewCategory(!showNewCategory)}
|
||||
className="px-3 py-2.5 border border-slate-300 rounded-lg text-slate-500 hover:text-slate-700 hover:border-slate-400 transition-colors"
|
||||
title="Create new category"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showNewCategory && (
|
||||
<div className="mt-2 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newCategoryName}
|
||||
onChange={e => setNewCategoryName(e.target.value)}
|
||||
placeholder="Category name"
|
||||
className="flex-1 px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
value={newCategoryColor}
|
||||
onChange={e => setNewCategoryColor(e.target.value)}
|
||||
className="w-10 h-10 border border-slate-300 rounded-lg cursor-pointer p-1"
|
||||
title="Category color"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateCategory}
|
||||
className="px-3 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Tags</label>
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{tags.map(tag => (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => toggleTag(tag.id)}
|
||||
className={`text-xs px-2.5 py-1 rounded-full font-medium transition-all ${
|
||||
formData.tags.includes(tag.id)
|
||||
? 'text-white shadow-sm ring-2 ring-offset-1'
|
||||
: 'text-white opacity-50 hover:opacity-80'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: tag.color || '#374151',
|
||||
ringColor: formData.tags.includes(tag.id) ? tag.color : 'transparent'
|
||||
}}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewTag(!showNewTag)}
|
||||
className="text-xs px-2.5 py-1 border border-dashed border-slate-300 rounded-full text-slate-500 hover:border-slate-400 hover:text-slate-700 transition-colors"
|
||||
>
|
||||
<Plus className="inline w-3 h-3 mr-0.5" />
|
||||
New tag
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showNewTag && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newTagName}
|
||||
onChange={e => setNewTagName(e.target.value)}
|
||||
placeholder="Tag name"
|
||||
className="flex-1 px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
value={newTagColor}
|
||||
onChange={e => setNewTagColor(e.target.value)}
|
||||
className="w-10 h-10 border border-slate-300 rounded-lg cursor-pointer p-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateTag}
|
||||
className="px-3 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reservation */}
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Reservation</label>
|
||||
<select
|
||||
value={formData.reservation_status}
|
||||
onChange={e => update('reservation_status', e.target.value)}
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent bg-white"
|
||||
>
|
||||
{STATUSES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reservation details */}
|
||||
{formData.reservation_status !== 'none' && (
|
||||
<div className="space-y-3 p-3 bg-amber-50 rounded-lg border border-amber-200">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Reservation Date & Time</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={formData.reservation_datetime}
|
||||
onChange={e => update('reservation_datetime', e.target.value)}
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Reservation Notes</label>
|
||||
<textarea
|
||||
value={formData.reservation_notes}
|
||||
onChange={e => update('reservation_notes', e.target.value)}
|
||||
placeholder="Confirmation number, special requests..."
|
||||
rows={2}
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent resize-none bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Website */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Website</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.website}
|
||||
onChange={e => update('website', e.target.value)}
|
||||
placeholder="https://..."
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import React from 'react'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { GripVertical, X, Edit2, Clock, DollarSign, MapPin } from 'lucide-react'
|
||||
|
||||
export default function AssignedPlaceItem({ assignment, dayId, onRemove, onEdit }) {
|
||||
const { place } = assignment
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: `assignment-${assignment.id}`,
|
||||
data: {
|
||||
type: 'assignment',
|
||||
dayId: dayId,
|
||||
assignment,
|
||||
},
|
||||
})
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`
|
||||
group bg-white border rounded-lg p-2.5 transition-all
|
||||
${isDragging
|
||||
? 'opacity-40 border-slate-300 shadow-lg'
|
||||
: 'border-slate-200 hover:border-slate-300 hover:shadow-sm'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{/* Drag handle */}
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="drag-handle mt-0.5 p-0.5 text-slate-300 hover:text-slate-500 flex-shrink-0 rounded touch-none"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Name row */}
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
{place.category && (
|
||||
<div
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: place.category.color || '#6366f1' }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm font-medium text-slate-800 truncate">{place.name}</span>
|
||||
</div>
|
||||
|
||||
{/* Time & price row */}
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{place.place_time && (
|
||||
<span className="flex items-center gap-1 text-xs text-slate-600 bg-slate-50 px-1.5 py-0.5 rounded">
|
||||
<Clock className="w-3 h-3" />
|
||||
{place.place_time}
|
||||
</span>
|
||||
)}
|
||||
{place.price != null && (
|
||||
<span className="flex items-center gap-1 text-xs text-emerald-700 bg-emerald-50 px-1.5 py-0.5 rounded">
|
||||
<DollarSign className="w-3 h-3" />
|
||||
{Number(place.price).toLocaleString()} {place.currency || ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
{place.address && (
|
||||
<p className="text-xs text-slate-400 truncate flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3 flex-shrink-0" />
|
||||
{place.address}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Category badge */}
|
||||
{place.category && (
|
||||
<span
|
||||
className="inline-block mt-1 text-xs px-1.5 py-0.5 rounded text-white text-[10px] font-medium"
|
||||
style={{ backgroundColor: place.category.color || '#6366f1' }}
|
||||
>
|
||||
{place.category.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{place.tags && place.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{place.tags.map(tag => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded-full text-white font-medium"
|
||||
style={{ backgroundColor: tag.color || '#6366f1' }}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-col gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={() => onEdit(place, assignment.id)}
|
||||
className="p-1 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded transition-colors"
|
||||
title="Edit place"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onRemove(assignment.id)}
|
||||
className="p-1 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded transition-colors"
|
||||
title="Remove from day"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useDroppable } from '@dnd-kit/core'
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
||||
import AssignedPlaceItem from './AssignedPlaceItem'
|
||||
import { ChevronDown, ChevronUp, Plus, FileText, Package, DollarSign } from 'lucide-react'
|
||||
|
||||
export default function DayColumn({
|
||||
day,
|
||||
assignments,
|
||||
tripId,
|
||||
onRemoveAssignment,
|
||||
onEditPlace,
|
||||
onQuickAdd,
|
||||
}) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false)
|
||||
const [showNotes, setShowNotes] = useState(false)
|
||||
const [notes, setNotes] = useState(day.notes || '')
|
||||
const [notesEditing, setNotesEditing] = useState(false)
|
||||
|
||||
const { isOver, setNodeRef } = useDroppable({
|
||||
id: `day-${day.id}`,
|
||||
data: {
|
||||
type: 'day',
|
||||
dayId: day.id,
|
||||
},
|
||||
})
|
||||
|
||||
const sortableIds = (assignments || []).map(a => `assignment-${a.id}`)
|
||||
|
||||
const totalCost = (assignments || []).reduce((sum, a) => {
|
||||
return sum + (a.place?.price ? Number(a.place.price) : 0)
|
||||
}, 0)
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return null
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex-shrink-0 w-72 flex flex-col rounded-xl border-2 transition-all duration-150
|
||||
${isOver
|
||||
? 'border-slate-400 bg-slate-50 shadow-lg shadow-slate-100'
|
||||
: 'border-transparent bg-white shadow-sm'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={`
|
||||
px-3 py-2.5 border-b flex items-center gap-2 rounded-t-xl
|
||||
${isOver ? 'border-slate-200 bg-slate-50' : 'border-slate-100 bg-slate-50'}
|
||||
`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-bold text-slate-900">Day {day.day_number}</span>
|
||||
<span className="text-xs bg-slate-100 text-slate-600 px-1.5 py-0.5 rounded-full font-medium">
|
||||
{assignments?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
{day.date && (
|
||||
<p className="text-xs text-slate-500 mt-0.5">{formatDate(day.date)}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{totalCost > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-xs text-emerald-700 bg-emerald-50 px-1.5 py-0.5 rounded">
|
||||
<DollarSign className="w-3 h-3" />
|
||||
{totalCost.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowNotes(!showNotes)}
|
||||
className={`p-1 rounded transition-colors ${showNotes ? 'text-slate-700 bg-slate-100' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-100'}`}
|
||||
title="Notes"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="p-1 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded transition-colors"
|
||||
>
|
||||
{isCollapsed ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes area */}
|
||||
{showNotes && (
|
||||
<div className="px-3 py-2 border-b border-slate-100 bg-amber-50">
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
onBlur={() => setNotesEditing(false)}
|
||||
onFocus={() => setNotesEditing(true)}
|
||||
placeholder="Add notes for this day..."
|
||||
rows={2}
|
||||
className="w-full text-xs text-slate-600 bg-transparent resize-none focus:outline-none placeholder-amber-400"
|
||||
/>
|
||||
{notesEditing && (
|
||||
<div className="flex gap-2 mt-1">
|
||||
<button
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
// Parent will handle save via onUpdateNotes if passed
|
||||
}}
|
||||
className="text-xs text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assignments list */}
|
||||
{!isCollapsed && (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`
|
||||
flex-1 p-2 flex flex-col gap-2 min-h-24 transition-colors duration-150
|
||||
${isOver ? 'bg-slate-50' : 'bg-transparent'}
|
||||
`}
|
||||
>
|
||||
{assignments && assignments.length > 0 ? (
|
||||
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
|
||||
{assignments.map(assignment => (
|
||||
<AssignedPlaceItem
|
||||
key={assignment.id}
|
||||
assignment={assignment}
|
||||
dayId={day.id}
|
||||
onRemove={(id) => onRemoveAssignment(day.id, id)}
|
||||
onEdit={onEditPlace}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
) : (
|
||||
<div className={`
|
||||
flex-1 flex flex-col items-center justify-center py-6 rounded-lg border-2 border-dashed
|
||||
text-xs text-center transition-colors
|
||||
${isOver
|
||||
? 'border-slate-400 bg-slate-100 text-slate-500'
|
||||
: 'border-slate-200 text-slate-400'
|
||||
}
|
||||
`}>
|
||||
<Package className="w-8 h-8 mb-2 opacity-50" />
|
||||
<p className="font-medium">Drop places here</p>
|
||||
<p className="text-[10px] mt-0.5 opacity-70">or drag from the left panel</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick add button */}
|
||||
<button
|
||||
onClick={() => onQuickAdd(day)}
|
||||
className="flex items-center justify-center gap-1 py-1.5 text-xs text-slate-400 hover:text-slate-700 hover:bg-slate-50 rounded-lg border border-dashed border-slate-200 hover:border-slate-300 transition-all mt-1"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add place
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCollapsed && (
|
||||
<div
|
||||
className="px-3 py-2 text-xs text-slate-400 cursor-pointer hover:bg-slate-50"
|
||||
onClick={() => setIsCollapsed(false)}
|
||||
>
|
||||
{assignments?.length || 0} place{(assignments?.length || 0) !== 1 ? 's' : ''} — click to expand
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,13 +9,19 @@ import CustomSelect from '../shared/CustomSelect'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
|
||||
|
||||
const WEATHER_ICON_MAP = {
|
||||
Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle,
|
||||
Thunderstorm: CloudLightning, Snow: CloudSnow, Mist: Wind, Fog: Wind, Haze: Wind,
|
||||
}
|
||||
|
||||
function WIcon({ main, size = 14 }) {
|
||||
interface WIconProps {
|
||||
main: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
function WIcon({ main, size = 14 }: WIconProps) {
|
||||
const Icon = WEATHER_ICON_MAP[main] || Cloud
|
||||
return <Icon size={size} strokeWidth={1.8} />
|
||||
}
|
||||
@@ -32,7 +38,21 @@ function formatTime12(val, is12h) {
|
||||
return `${h12}:${String(m).padStart(2, '0')} ${period}`
|
||||
}
|
||||
|
||||
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange }) {
|
||||
interface DayDetailPanelProps {
|
||||
day: Day
|
||||
days: Day[]
|
||||
places: Place[]
|
||||
categories?: Category[]
|
||||
tripId: number
|
||||
assignments: AssignmentsMap
|
||||
reservations?: Reservation[]
|
||||
lat: number | null
|
||||
lng: number | null
|
||||
onClose: () => void
|
||||
onAccommodationChange: () => void
|
||||
}
|
||||
|
||||
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange }: DayDetailPanelProps) {
|
||||
const { t, language } = useTranslation()
|
||||
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
@@ -504,7 +524,12 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
)
|
||||
}
|
||||
|
||||
function Chip({ icon: Icon, value }) {
|
||||
interface ChipProps {
|
||||
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
|
||||
value: string
|
||||
}
|
||||
|
||||
function Chip({ icon: Icon, value }: ChipProps) {
|
||||
return (
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
<Icon size={11} style={{ flexShrink: 0, opacity: 0.6 }} />
|
||||
@@ -513,7 +538,16 @@ function Chip({ icon: Icon, value }) {
|
||||
)
|
||||
}
|
||||
|
||||
function InfoChip({ icon: Icon, label, value, placeholder, onEdit, type }) {
|
||||
interface InfoChipProps {
|
||||
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
|
||||
label: string
|
||||
value: string
|
||||
placeholder: string
|
||||
onEdit: (value: string) => void
|
||||
type: 'text' | 'time'
|
||||
}
|
||||
|
||||
function InfoChip({ icon: Icon, label, value, placeholder, onEdit, type }: InfoChipProps) {
|
||||
const [editing, setEditing] = React.useState(false)
|
||||
const [val, setVal] = React.useState(value || '')
|
||||
const inputRef = React.useRef(null)
|
||||
@@ -1,3 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
|
||||
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; fromDayId?: string }
|
||||
declare global { interface Window { __dragData: DragDataPayload | null } }
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users } from 'lucide-react'
|
||||
@@ -13,36 +17,9 @@ import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
function formatDate(dateStr, locale) {
|
||||
if (!dateStr) return null
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, {
|
||||
weekday: 'short', day: 'numeric', month: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
function formatTime(timeStr, locale, timeFormat) {
|
||||
if (!timeStr) return ''
|
||||
try {
|
||||
const parts = timeStr.split(':')
|
||||
const h = Number(parts[0]) || 0
|
||||
const m = Number(parts[1]) || 0
|
||||
if (isNaN(h)) return timeStr
|
||||
if (timeFormat === '12h') {
|
||||
const period = h >= 12 ? 'PM' : 'AM'
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||
return `${h12}:${String(m).padStart(2, '0')} ${period}`
|
||||
}
|
||||
const str = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
||||
return locale?.startsWith('de') ? `${str} Uhr` : str
|
||||
} catch { return timeStr }
|
||||
}
|
||||
|
||||
function dayTotalCost(dayId, assignments, currency) {
|
||||
const da = assignments[String(dayId)] || []
|
||||
const total = da.reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
|
||||
return total > 0 ? `${total.toFixed(0)} ${currency}` : null
|
||||
}
|
||||
import { formatDate, formatTime, dayTotalCost } from '../../utils/formatters'
|
||||
import { useDayNotes } from '../../hooks/useDayNotes'
|
||||
import type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult } from '../../types'
|
||||
|
||||
const NOTE_ICONS = [
|
||||
{ id: 'FileText', Icon: FileText },
|
||||
@@ -74,6 +51,31 @@ const TYPE_ICONS = {
|
||||
car: '🚗', cruise: '🚢', event: '🎫', other: '📋',
|
||||
}
|
||||
|
||||
interface DayPlanSidebarProps {
|
||||
tripId: number
|
||||
trip: Trip
|
||||
days: Day[]
|
||||
places: Place[]
|
||||
categories: Category[]
|
||||
assignments: AssignmentsMap
|
||||
selectedDayId: number | null
|
||||
selectedPlaceId: number | null
|
||||
selectedAssignmentId: number | null
|
||||
onSelectDay: (dayId: number | null) => void
|
||||
onPlaceClick: (placeId: number) => void
|
||||
onDayDetail: (day: Day) => void
|
||||
accommodations?: Assignment[]
|
||||
onReorder: (dayId: number, orderedIds: number[]) => void
|
||||
onUpdateDayTitle: (dayId: number, title: string) => void
|
||||
onRouteCalculated: (dayId: number, route: RouteResult | null) => void
|
||||
onAssignToDay: (placeId: number, dayId: number) => void
|
||||
onRemoveAssignment: (assignmentId: number, dayId: number) => void
|
||||
onEditPlace: (place: Place) => void
|
||||
onDeletePlace: (placeId: number) => void
|
||||
reservations?: Reservation[]
|
||||
onAddReservation: () => void
|
||||
}
|
||||
|
||||
export default function DayPlanSidebar({
|
||||
tripId,
|
||||
trip, days, places, categories, assignments,
|
||||
@@ -83,14 +85,14 @@ export default function DayPlanSidebar({
|
||||
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
|
||||
reservations = [],
|
||||
onAddReservation,
|
||||
}) {
|
||||
}: DayPlanSidebarProps) {
|
||||
const toast = useToast()
|
||||
const { t, language, locale } = useTranslation()
|
||||
const ctxMenu = useContextMenu()
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
const tripStore = useTripStore()
|
||||
|
||||
const dayNotes = tripStore.dayNotes || {}
|
||||
const { noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote: _openAddNote, openEditNote: _openEditNote, cancelNote, saveNote, deleteNote: _deleteNote, moveNote: _moveNote } = useDayNotes(tripId)
|
||||
|
||||
const [expandedDays, setExpandedDays] = useState(() => {
|
||||
try {
|
||||
@@ -109,9 +111,7 @@ export default function DayPlanSidebar({
|
||||
const [dropTargetKey, setDropTargetKey] = useState(null)
|
||||
const [dragOverDayId, setDragOverDayId] = useState(null)
|
||||
const [hoveredId, setHoveredId] = useState(null)
|
||||
const [noteUi, setNoteUi] = useState({}) // { [dayId]: { mode, text, time, noteId?, sortOrder? } }
|
||||
const inputRef = useRef(null)
|
||||
const noteInputRef = useRef(null)
|
||||
const dragDataRef = useRef(null) // Speichert Drag-Daten als Backup (dataTransfer geht bei Re-Render verloren)
|
||||
|
||||
const currency = trip?.currency || 'EUR'
|
||||
@@ -190,40 +190,19 @@ export default function DayPlanSidebar({
|
||||
|
||||
const openAddNote = (dayId, e) => {
|
||||
e?.stopPropagation()
|
||||
const merged = getMergedItems(dayId)
|
||||
const maxKey = merged.length > 0 ? Math.max(...merged.map(i => i.sortKey)) : -1
|
||||
setNoteUi(prev => ({ ...prev, [dayId]: { mode: 'add', text: '', time: '', icon: 'FileText', sortOrder: maxKey + 1 } }))
|
||||
if (!expandedDays.has(dayId)) setExpandedDays(prev => new Set([...prev, dayId]))
|
||||
setTimeout(() => noteInputRef.current?.focus(), 50)
|
||||
_openAddNote(dayId, getMergedItems, (id) => {
|
||||
if (!expandedDays.has(id)) setExpandedDays(prev => new Set([...prev, id]))
|
||||
})
|
||||
}
|
||||
|
||||
const openEditNote = (dayId, note, e) => {
|
||||
e?.stopPropagation()
|
||||
setNoteUi(prev => ({ ...prev, [dayId]: { mode: 'edit', noteId: note.id, text: note.text, time: note.time || '', icon: note.icon || 'FileText' } }))
|
||||
setTimeout(() => noteInputRef.current?.focus(), 50)
|
||||
}
|
||||
|
||||
const cancelNote = (dayId) => {
|
||||
setNoteUi(prev => { const n = { ...prev }; delete n[dayId]; return n })
|
||||
}
|
||||
|
||||
const saveNote = async (dayId) => {
|
||||
const ui = noteUi[dayId]
|
||||
if (!ui?.text?.trim()) return
|
||||
try {
|
||||
if (ui.mode === 'add') {
|
||||
await tripStore.addDayNote(tripId, dayId, { text: ui.text.trim(), time: ui.time || null, icon: ui.icon || 'FileText', sort_order: ui.sortOrder })
|
||||
} else {
|
||||
await tripStore.updateDayNote(tripId, dayId, ui.noteId, { text: ui.text.trim(), time: ui.time || null, icon: ui.icon || 'FileText' })
|
||||
}
|
||||
cancelNote(dayId)
|
||||
} catch (err) { toast.error(err.message) }
|
||||
_openEditNote(dayId, note)
|
||||
}
|
||||
|
||||
const deleteNote = async (dayId, noteId, e) => {
|
||||
e?.stopPropagation()
|
||||
try { await tripStore.deleteDayNote(tripId, dayId, noteId) }
|
||||
catch (err) { toast.error(err.message) }
|
||||
await _deleteNote(dayId, noteId)
|
||||
}
|
||||
|
||||
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => {
|
||||
@@ -263,26 +242,14 @@ export default function DayPlanSidebar({
|
||||
for (const n of noteChanges) {
|
||||
await tripStore.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order })
|
||||
}
|
||||
} catch (err) { toast.error(err.message) }
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
|
||||
setDraggingId(null)
|
||||
setDropTargetKey(null)
|
||||
dragDataRef.current = null
|
||||
}
|
||||
|
||||
const moveNote = async (dayId, noteId, direction) => {
|
||||
const merged = getMergedItems(dayId)
|
||||
const idx = merged.findIndex(i => i.type === 'note' && i.data.id === noteId)
|
||||
if (idx === -1) return
|
||||
let newSortOrder
|
||||
if (direction === 'up') {
|
||||
if (idx === 0) return
|
||||
newSortOrder = idx >= 2 ? (merged[idx - 2].sortKey + merged[idx - 1].sortKey) / 2 : merged[idx - 1].sortKey - 1
|
||||
} else {
|
||||
if (idx >= merged.length - 1) return
|
||||
newSortOrder = idx < merged.length - 2 ? (merged[idx + 1].sortKey + merged[idx + 2].sortKey) / 2 : merged[idx + 1].sortKey + 1
|
||||
}
|
||||
try { await tripStore.updateDayNote(tripId, dayId, noteId, { sort_order: newSortOrder }) }
|
||||
catch (err) { toast.error(err.message) }
|
||||
await _moveNote(dayId, noteId, direction, getMergedItems)
|
||||
}
|
||||
|
||||
const startEditTitle = (day, e) => {
|
||||
@@ -369,9 +336,9 @@ export default function DayPlanSidebar({
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), dayId)
|
||||
} else if (assignmentId && fromDayId !== dayId) {
|
||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId).catch(err => toast.error(err.message))
|
||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
} else if (noteId && fromDayId !== dayId) {
|
||||
tripStore.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch(err => toast.error(err.message))
|
||||
tripStore.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
}
|
||||
setDraggingId(null)
|
||||
setDropTargetKey(null)
|
||||
@@ -573,11 +540,11 @@ export default function DayPlanSidebar({
|
||||
const { assignmentId, noteId, fromDayId } = getDragData(e)
|
||||
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
|
||||
if (assignmentId && fromDayId !== day.id) {
|
||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch(err => toast.error(err.message))
|
||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
}
|
||||
if (noteId && fromDayId !== day.id) {
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch(err => toast.error(err.message))
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
}
|
||||
const m = getMergedItems(day.id)
|
||||
@@ -652,7 +619,7 @@ export default function DayPlanSidebar({
|
||||
setDropTargetKey(null); window.__dragData = null
|
||||
} else if (fromAssignmentId && fromDayId !== day.id) {
|
||||
const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.id)
|
||||
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch(err => toast.error(err.message))
|
||||
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
} else if (fromAssignmentId) {
|
||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'place', assignment.id)
|
||||
@@ -660,7 +627,7 @@ export default function DayPlanSidebar({
|
||||
const tm = getMergedItems(day.id)
|
||||
const toIdx = tm.findIndex(i => i.type === 'place' && i.data.id === assignment.id)
|
||||
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch(err => toast.error(err.message))
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
} else if (noteId) {
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), 'place', assignment.id)
|
||||
@@ -822,7 +789,7 @@ export default function DayPlanSidebar({
|
||||
const tm = getMergedItems(day.id)
|
||||
const toIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
|
||||
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch(err => toast.error(err.message))
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null)
|
||||
} else if (fromNoteId && fromNoteId !== String(note.id)) {
|
||||
handleMergedDrop(day.id, 'note', Number(fromNoteId), 'note', note.id)
|
||||
@@ -830,7 +797,7 @@ export default function DayPlanSidebar({
|
||||
const tm = getMergedItems(day.id)
|
||||
const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
|
||||
const toIdx = tm.slice(0, noteIdx).filter(i => i.type === 'place').length
|
||||
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch(err => toast.error(err.message))
|
||||
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null)
|
||||
} else if (fromAssignmentId) {
|
||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id)
|
||||
@@ -895,11 +862,11 @@ export default function DayPlanSidebar({
|
||||
}
|
||||
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
|
||||
if (assignmentId && fromDayId !== day.id) {
|
||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch(err => toast.error(err.message))
|
||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
}
|
||||
if (noteId && fromDayId !== day.id) {
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch(err => toast.error(err.message))
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
}
|
||||
const m = getMergedItems(day.id)
|
||||
@@ -1,138 +0,0 @@
|
||||
import React from 'react'
|
||||
import { CalendarDays, MapPin, Plus } from 'lucide-react'
|
||||
import WeatherWidget from '../Weather/WeatherWidget'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return null
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString('de-DE', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
function dayTotal(dayId, assignments) {
|
||||
const dayAssignments = assignments[String(dayId)] || []
|
||||
return dayAssignments.reduce((sum, a) => {
|
||||
const cost = parseFloat(a.place?.cost) || 0
|
||||
return sum + cost
|
||||
}, 0)
|
||||
}
|
||||
|
||||
export function DaysList({ days, selectedDayId, onSelectDay, assignments, trip }) {
|
||||
const { t } = useTranslation()
|
||||
const totalCost = days.reduce((sum, d) => sum + dayTotal(d.id, assignments), 0)
|
||||
const currency = trip?.currency || 'EUR'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-gray-100 flex-shrink-0">
|
||||
<h2 className="text-sm font-semibold text-gray-700">{t('planner.dayPlan')}</h2>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{t('planner.dayCount', { n: days.length })}</p>
|
||||
</div>
|
||||
|
||||
{/* All places overview option */}
|
||||
<button
|
||||
onClick={() => onSelectDay(null)}
|
||||
className={`w-full text-left px-4 py-3 border-b border-gray-100 transition-colors flex items-center gap-2 flex-shrink-0 ${
|
||||
selectedDayId === null
|
||||
? 'bg-slate-50 border-l-2 border-l-slate-900'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<MapPin className={`w-4 h-4 flex-shrink-0 ${selectedDayId === null ? 'text-slate-900' : 'text-gray-400'}`} />
|
||||
<div>
|
||||
<p className={`text-sm font-medium ${selectedDayId === null ? 'text-slate-900' : 'text-gray-700'}`}>
|
||||
{t('planner.allPlaces')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">{t('planner.overview')}</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Day list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{days.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center">
|
||||
<CalendarDays className="w-8 h-8 text-gray-300 mx-auto mb-2" />
|
||||
<p className="text-xs text-gray-400">{t('planner.noDays')}</p>
|
||||
<p className="text-xs text-gray-300 mt-1">{t('planner.editTripToAddDays')}</p>
|
||||
</div>
|
||||
) : (
|
||||
days.map((day, index) => {
|
||||
const isSelected = selectedDayId === day.id
|
||||
const dayAssignments = assignments[String(day.id)] || []
|
||||
const cost = dayTotal(day.id, assignments)
|
||||
const placeCount = dayAssignments.length
|
||||
|
||||
return (
|
||||
<button
|
||||
key={day.id}
|
||||
onClick={() => onSelectDay(day.id)}
|
||||
className={`w-full text-left px-4 py-3 border-b border-gray-50 transition-colors ${
|
||||
isSelected
|
||||
? 'bg-slate-50 border-l-2 border-l-slate-900'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`text-xs font-bold px-1.5 py-0.5 rounded ${
|
||||
isSelected ? 'bg-slate-900 text-white' : 'bg-gray-200 text-gray-600'
|
||||
}`}>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className={`text-sm font-medium truncate ${isSelected ? 'text-slate-900' : 'text-gray-700'}`}>
|
||||
{day.title || `Tag ${index + 1}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{day.date && (
|
||||
<p className="text-xs text-gray-400 mt-1 ml-0.5">
|
||||
{formatDate(day.date)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mt-1.5">
|
||||
{placeCount > 0 && (
|
||||
<span className="text-xs text-gray-400">
|
||||
{placeCount === 1 ? t('planner.placeOne') : t('planner.placeN', { n: placeCount })}
|
||||
</span>
|
||||
)}
|
||||
{cost > 0 && (
|
||||
<span className="text-xs text-emerald-600 font-medium">
|
||||
{cost.toFixed(0)} {currency}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weather for this day */}
|
||||
{day.date && isSelected && (
|
||||
<div className="mt-2">
|
||||
<WeatherWidget date={day.date} compact />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Budget summary footer */}
|
||||
{totalCost > 0 && (
|
||||
<div className="flex-shrink-0 border-t border-gray-100 px-4 py-3 bg-gray-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500">{t('planner.totalCost')}</span>
|
||||
<span className="text-sm font-semibold text-gray-800">
|
||||
{totalCost.toFixed(2)} {currency}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import React from 'react'
|
||||
import { useDraggable } from '@dnd-kit/core'
|
||||
import { MapPin, DollarSign, Check } from 'lucide-react'
|
||||
|
||||
export default function DraggablePlaceCard({ place, isAssigned, onEdit }) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||
id: `place-${place.id}`,
|
||||
data: {
|
||||
type: 'place',
|
||||
place,
|
||||
},
|
||||
})
|
||||
|
||||
const style = transform ? {
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
zIndex: isDragging ? 999 : undefined,
|
||||
} : undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={`
|
||||
group relative bg-white border rounded-lg p-3 cursor-grab active:cursor-grabbing
|
||||
transition-all select-none
|
||||
${isDragging
|
||||
? 'opacity-50 shadow-2xl border-slate-400 scale-105'
|
||||
: 'border-slate-200 hover:border-slate-300 hover:shadow-md place-card-hover'
|
||||
}
|
||||
`}
|
||||
onClick={e => {
|
||||
if (!isDragging && onEdit) {
|
||||
e.stopPropagation()
|
||||
onEdit(place)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Category left border accent */}
|
||||
{place.category && (
|
||||
<div
|
||||
className="absolute left-0 top-3 bottom-3 w-0.5 rounded-r"
|
||||
style={{ backgroundColor: place.category.color || '#6366f1' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="pl-1">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-1 mb-1">
|
||||
<p className="text-sm font-medium text-slate-800 leading-tight line-clamp-2 flex-1">
|
||||
{place.name}
|
||||
</p>
|
||||
{isAssigned && (
|
||||
<span className="flex-shrink-0 w-5 h-5 bg-emerald-100 rounded-full flex items-center justify-center" title="Already assigned to a day">
|
||||
<Check className="w-3 h-3 text-emerald-600" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
{place.address && (
|
||||
<p className="text-xs text-slate-400 truncate flex items-center gap-1 mb-1.5">
|
||||
<MapPin className="w-3 h-3 flex-shrink-0" />
|
||||
{place.address}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Category badge */}
|
||||
{place.category && (
|
||||
<span
|
||||
className="inline-block text-[10px] px-1.5 py-0.5 rounded text-white font-medium mr-1"
|
||||
style={{ backgroundColor: place.category.color || '#6366f1' }}
|
||||
>
|
||||
{place.category.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Price */}
|
||||
{place.price != null && (
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] text-emerald-700 bg-emerald-50 px-1.5 py-0.5 rounded">
|
||||
<DollarSign className="w-2.5 h-2.5" />
|
||||
{Number(place.price).toLocaleString()} {place.currency || ''}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{place.tags && place.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{place.tags.slice(0, 3).map(tag => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded-full text-white font-medium"
|
||||
style={{ backgroundColor: tag.color || '#6366f1' }}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
{place.tags.length > 3 && (
|
||||
<span className="text-[10px] text-slate-400">+{place.tags.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { X, ExternalLink, Phone, MapPin, Clock, Euro, Edit2, Trash2, Plus, Minus } from 'lucide-react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
export function PlaceDetailPanel({
|
||||
place, categories, tags, selectedDayId, dayAssignments,
|
||||
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [googlePhoto, setGooglePhoto] = useState(null)
|
||||
const [photoAttribution, setPhotoAttribution] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!place?.google_place_id || place?.image_url) {
|
||||
setGooglePhoto(null)
|
||||
return
|
||||
}
|
||||
mapsApi.placePhoto(place.google_place_id)
|
||||
.then(data => {
|
||||
setGooglePhoto(data.photoUrl || null)
|
||||
setPhotoAttribution(data.attribution || null)
|
||||
})
|
||||
.catch(() => setGooglePhoto(null))
|
||||
}, [place?.google_place_id, place?.image_url])
|
||||
|
||||
if (!place) return null
|
||||
|
||||
const displayPhoto = place.image_url || googlePhoto
|
||||
const category = categories?.find(c => c.id === place.category_id)
|
||||
const placeTags = (place.tags || []).map(t =>
|
||||
tags?.find(tg => tg.id === (t.id || t)) || t
|
||||
).filter(Boolean)
|
||||
|
||||
const assignmentInDay = selectedDayId
|
||||
? dayAssignments?.find(a => a.place?.id === place.id)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="bg-white">
|
||||
{/* Image */}
|
||||
{displayPhoto ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={displayPhoto}
|
||||
alt={place.name}
|
||||
className="w-full h-40 object-cover"
|
||||
onError={e => { e.target.style.display = 'none' }}
|
||||
/>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-2 right-2 bg-white/90 rounded-full p-1.5 shadow"
|
||||
>
|
||||
<X className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
{photoAttribution && !place.image_url && (
|
||||
<div className="absolute bottom-1 right-2 text-[10px] text-white/70">
|
||||
© {photoAttribution}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="h-24 flex items-center justify-center relative"
|
||||
style={{ backgroundColor: category?.color ? `${category.color}20` : '#f0f0ff' }}
|
||||
>
|
||||
<span className="text-4xl">{category?.icon || '📍'}</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-2 right-2 bg-white/90 rounded-full p-1.5 shadow"
|
||||
>
|
||||
<X className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Name + category */}
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 text-base leading-snug">{place.name}</h3>
|
||||
{category && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full mt-1"
|
||||
style={{ backgroundColor: `${category.color}20`, color: category.color }}
|
||||
>
|
||||
{category.icon} {category.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick info row */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{place.place_time && (
|
||||
<div className="flex items-center gap-1 text-xs text-gray-600 bg-gray-50 px-2 py-1 rounded-lg">
|
||||
<Clock className="w-3 h-3" />
|
||||
{place.place_time}
|
||||
</div>
|
||||
)}
|
||||
{place.price > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-emerald-700 bg-emerald-50 px-2 py-1 rounded-lg">
|
||||
<Euro className="w-3 h-3" />
|
||||
{place.price} {place.currency}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
{place.address && (
|
||||
<div className="flex items-start gap-1.5 text-xs text-gray-600">
|
||||
<MapPin className="w-3.5 h-3.5 flex-shrink-0 mt-0.5 text-gray-400" />
|
||||
<span>{place.address}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Coordinates */}
|
||||
{place.lat && place.lng && (
|
||||
<div className="text-xs text-gray-400">
|
||||
{Number(place.lat).toFixed(6)}, {Number(place.lng).toFixed(6)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Links */}
|
||||
<div className="flex gap-2">
|
||||
{place.website && (
|
||||
<a
|
||||
href={place.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-slate-700 hover:underline"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
Website
|
||||
</a>
|
||||
)}
|
||||
{place.phone && (
|
||||
<a
|
||||
href={`tel:${place.phone}`}
|
||||
className="flex items-center gap-1 text-xs text-slate-700 hover:underline"
|
||||
>
|
||||
<Phone className="w-3 h-3" />
|
||||
{place.phone}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{place.description && (
|
||||
<p className="text-xs text-gray-600 leading-relaxed">{place.description}</p>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{place.notes && (
|
||||
<div className="bg-amber-50 border border-amber-100 rounded-lg px-3 py-2">
|
||||
<p className="text-xs text-amber-800 leading-relaxed">📝 {place.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{placeTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{placeTags.map((tag, i) => (
|
||||
<span
|
||||
key={tag.id || i}
|
||||
className="text-xs px-2 py-0.5 rounded-full"
|
||||
style={{ backgroundColor: `${tag.color || '#6366f1'}20`, color: tag.color || '#6366f1' }}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Day assignment actions */}
|
||||
{selectedDayId && (
|
||||
<div className="pt-1">
|
||||
{assignmentInDay ? (
|
||||
<button
|
||||
onClick={() => onRemoveAssignment(selectedDayId, assignmentInDay.id)}
|
||||
className="w-full flex items-center justify-center gap-2 py-2 text-sm text-red-600 border border-red-200 rounded-lg hover:bg-red-50"
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
{t('planner.removeFromDay')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onAssignToDay(place.id)}
|
||||
className="w-full flex items-center justify-center gap-2 py-2 text-sm text-white bg-slate-900 rounded-lg hover:bg-slate-700"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('planner.addToThisDay')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit / Delete */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs text-gray-700 border border-gray-200 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
{t('common.edit')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="flex items-center justify-center gap-1.5 py-2 px-3 text-xs text-red-600 border border-red-200 rounded-lg hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDateTime(dt) {
|
||||
if (!dt) return ''
|
||||
try {
|
||||
return new Date(dt).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
|
||||
} catch {
|
||||
return dt
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { mapsApi } from '../../api/client'
|
||||
@@ -7,8 +7,23 @@ import { useToast } from '../shared/Toast'
|
||||
import { Search, Paperclip, X, AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import type { Place, Category, Assignment } from '../../types'
|
||||
|
||||
const DEFAULT_FORM = {
|
||||
interface PlaceFormData {
|
||||
name: string
|
||||
description: string
|
||||
address: string
|
||||
lat: string
|
||||
lng: string
|
||||
category_id: string
|
||||
place_time: string
|
||||
end_time: string
|
||||
notes: string
|
||||
transport_mode: string
|
||||
website: string
|
||||
}
|
||||
|
||||
const DEFAULT_FORM: PlaceFormData = {
|
||||
name: '',
|
||||
description: '',
|
||||
address: '',
|
||||
@@ -22,10 +37,22 @@ const DEFAULT_FORM = {
|
||||
website: '',
|
||||
}
|
||||
|
||||
interface PlaceFormModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSave: (data: PlaceFormData, files?: File[]) => Promise<void> | void
|
||||
place: Place | null
|
||||
tripId: number
|
||||
categories: Category[]
|
||||
onCategoryCreated: (category: Category) => void
|
||||
assignmentId: number | null
|
||||
dayAssignments?: Assignment[]
|
||||
}
|
||||
|
||||
export default function PlaceFormModal({
|
||||
isOpen, onClose, onSave, place, tripId, categories,
|
||||
onCategoryCreated, assignmentId, dayAssignments = [],
|
||||
}) {
|
||||
}: PlaceFormModalProps) {
|
||||
const [form, setForm] = useState(DEFAULT_FORM)
|
||||
const [mapsSearch, setMapsSearch] = useState('')
|
||||
const [mapsResults, setMapsResults] = useState([])
|
||||
@@ -70,7 +97,7 @@ export default function PlaceFormModal({
|
||||
try {
|
||||
const result = await mapsApi.search(mapsSearch, language)
|
||||
setMapsResults(result.places || [])
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
toast.error(t('places.mapsSearchError'))
|
||||
} finally {
|
||||
setIsSearchingMaps(false)
|
||||
@@ -97,13 +124,13 @@ export default function PlaceFormModal({
|
||||
if (cat) setForm(prev => ({ ...prev, category_id: cat.id }))
|
||||
setNewCategoryName('')
|
||||
setShowNewCategory(false)
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
toast.error(t('places.categoryCreateError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileAdd = (e) => {
|
||||
const files = Array.from(e.target.files || [])
|
||||
const files = Array.from((e.target as HTMLInputElement).files || [])
|
||||
setPendingFiles(prev => [...prev, ...files])
|
||||
e.target.value = ''
|
||||
}
|
||||
@@ -116,7 +143,7 @@ export default function PlaceFormModal({
|
||||
const handlePaste = (e) => {
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
for (const item of items) {
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.type.startsWith('image/') || item.type === 'application/pdf') {
|
||||
e.preventDefault()
|
||||
const file = item.getAsFile()
|
||||
@@ -144,8 +171,8 @@ export default function PlaceFormModal({
|
||||
_pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined,
|
||||
})
|
||||
onClose()
|
||||
} catch (err) {
|
||||
toast.error(err.message || t('places.saveError'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('places.saveError'))
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
@@ -371,7 +398,16 @@ export default function PlaceFormModal({
|
||||
)
|
||||
}
|
||||
|
||||
function TimeSection({ form, handleChange, assignmentId, dayAssignments, hasTimeError, t }) {
|
||||
interface TimeSectionProps {
|
||||
form: PlaceFormData
|
||||
handleChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void
|
||||
assignmentId: number | null
|
||||
dayAssignments: Assignment[]
|
||||
hasTimeError: boolean
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
}
|
||||
|
||||
function TimeSection({ form, handleChange, assignmentId, dayAssignments, hasTimeError, t }: TimeSectionProps) {
|
||||
|
||||
const collisions = useMemo(() => {
|
||||
if (!assignmentId || !form.place_time || form.place_time.length < 5) return []
|
||||
@@ -5,6 +5,7 @@ import { mapsApi } from '../../api/client'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types'
|
||||
|
||||
const detailsCache = new Map()
|
||||
|
||||
@@ -97,11 +98,37 @@ function formatFileSize(bytes) {
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
interface TripMember {
|
||||
id: number
|
||||
username: string
|
||||
avatar_url?: string | null
|
||||
}
|
||||
|
||||
interface PlaceInspectorProps {
|
||||
place: Place | null
|
||||
categories: Category[]
|
||||
days: Day[]
|
||||
selectedDayId: number | null
|
||||
selectedAssignmentId: number | null
|
||||
assignments: AssignmentsMap
|
||||
reservations?: Reservation[]
|
||||
onClose: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onAssignToDay: (placeId: number, dayId: number) => void
|
||||
onRemoveAssignment: (assignmentId: number, dayId: number) => void
|
||||
files: TripFile[]
|
||||
onFileUpload: (fd: FormData) => Promise<void>
|
||||
tripMembers?: TripMember[]
|
||||
onSetParticipants: (assignmentId: number, dayId: number, participantIds: number[]) => void
|
||||
onUpdatePlace: (placeId: number, data: Partial<Place>) => void
|
||||
}
|
||||
|
||||
export default function PlaceInspector({
|
||||
place, categories, days, selectedDayId, selectedAssignmentId, assignments, reservations = [],
|
||||
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
|
||||
files, onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace,
|
||||
}) {
|
||||
}: PlaceInspectorProps) {
|
||||
const { t, locale, language } = useTranslation()
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
const [hoursExpanded, setHoursExpanded] = useState(false)
|
||||
@@ -147,7 +174,7 @@ export default function PlaceInspector({
|
||||
const placeFiles = (files || []).filter(f => String(f.place_id) === String(place.id))
|
||||
|
||||
const handleFileUpload = useCallback(async (e) => {
|
||||
const selectedFiles = Array.from(e.target.files || [])
|
||||
const selectedFiles = Array.from((e.target as HTMLInputElement).files || [])
|
||||
if (!selectedFiles.length || !onFileUpload) return
|
||||
setIsUploading(true)
|
||||
try {
|
||||
@@ -158,7 +185,7 @@ export default function PlaceInspector({
|
||||
await onFileUpload(fd)
|
||||
}
|
||||
setFilesExpanded(true)
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
console.error('Upload failed', err)
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
@@ -488,7 +515,14 @@ export default function PlaceInspector({
|
||||
)
|
||||
}
|
||||
|
||||
function Chip({ icon, text, color = 'var(--text-secondary)', bg = 'var(--bg-hover)' }) {
|
||||
interface ChipProps {
|
||||
icon: React.ReactNode
|
||||
text: React.ReactNode
|
||||
color?: string
|
||||
bg?: string
|
||||
}
|
||||
|
||||
function Chip({ icon, text, color = 'var(--text-secondary)', bg = 'var(--bg-hover)' }: ChipProps) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 9px', borderRadius: 99, background: bg, color, fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
|
||||
<span style={{ flexShrink: 0, display: 'flex' }}>{icon}</span>
|
||||
@@ -497,7 +531,12 @@ function Chip({ icon, text, color = 'var(--text-secondary)', bg = 'var(--bg-hove
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ icon, children }) {
|
||||
interface RowProps {
|
||||
icon: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function Row({ icon, children }: RowProps) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ flexShrink: 0 }}>{icon}</div>
|
||||
@@ -506,7 +545,14 @@ function Row({ icon, children }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ActionButton({ onClick, variant, icon, label }) {
|
||||
interface ActionButtonProps {
|
||||
onClick: () => void
|
||||
variant: 'primary' | 'ghost' | 'danger'
|
||||
icon: React.ReactNode
|
||||
label: React.ReactNode
|
||||
}
|
||||
|
||||
function ActionButton({ onClick, variant, icon, label }: ActionButtonProps) {
|
||||
const base = {
|
||||
primary: { background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', hoverBg: 'var(--text-secondary)' },
|
||||
ghost: { background: 'var(--bg-hover)', color: 'var(--text-secondary)', border: 'none', hoverBg: 'var(--bg-tertiary)' },
|
||||
@@ -531,7 +577,17 @@ function ActionButton({ onClick, variant, icon, label }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticipants, selectedAssignmentId, selectedDayId, t }) {
|
||||
interface ParticipantsBoxProps {
|
||||
tripMembers: TripMember[]
|
||||
participantIds: number[]
|
||||
allJoined: boolean
|
||||
onSetParticipants: (assignmentId: number, dayId: number, participantIds: number[]) => void
|
||||
selectedAssignmentId: number | null
|
||||
selectedDayId: number | null
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticipants, selectedAssignmentId, selectedDayId, t }: ParticipantsBoxProps) {
|
||||
const [showAdd, setShowAdd] = React.useState(false)
|
||||
const [hoveredId, setHoveredId] = React.useState(null)
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import DraggablePlaceCard from './DraggablePlaceCard'
|
||||
import { Search, Plus, Filter, Map, X, SlidersHorizontal } from 'lucide-react'
|
||||
|
||||
export default function PlacesPanel({
|
||||
places,
|
||||
categories,
|
||||
tags,
|
||||
assignments,
|
||||
tripId,
|
||||
onAddPlace,
|
||||
onEditPlace,
|
||||
hasMapKey,
|
||||
onSearchMaps,
|
||||
}) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedCategory, setSelectedCategory] = useState('')
|
||||
const [selectedTags, setSelectedTags] = useState([])
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
|
||||
// Get set of assigned place IDs (for any day)
|
||||
const assignedPlaceIds = useMemo(() => {
|
||||
const ids = new Set()
|
||||
Object.values(assignments || {}).forEach(dayAssignments => {
|
||||
dayAssignments.forEach(a => {
|
||||
if (a.place?.id) ids.add(a.place.id)
|
||||
})
|
||||
})
|
||||
return ids
|
||||
}, [assignments])
|
||||
|
||||
const filteredPlaces = useMemo(() => {
|
||||
return places.filter(place => {
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
if (!place.name.toLowerCase().includes(q) &&
|
||||
!place.address?.toLowerCase().includes(q) &&
|
||||
!place.description?.toLowerCase().includes(q)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (selectedCategory && place.category_id !== parseInt(selectedCategory)) {
|
||||
return false
|
||||
}
|
||||
if (selectedTags.length > 0) {
|
||||
const placeTags = (place.tags || []).map(t => t.id)
|
||||
if (!selectedTags.every(tagId => placeTags.includes(tagId))) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [places, search, selectedCategory, selectedTags])
|
||||
|
||||
const toggleTag = (tagId) => {
|
||||
setSelectedTags(prev =>
|
||||
prev.includes(tagId) ? prev.filter(id => id !== tagId) : [...prev, tagId]
|
||||
)
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearch('')
|
||||
setSelectedCategory('')
|
||||
setSelectedTags([])
|
||||
}
|
||||
|
||||
const hasActiveFilters = search || selectedCategory || selectedTags.length > 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white border-r border-slate-200">
|
||||
{/* Header */}
|
||||
<div className="p-3 border-b border-slate-100">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-sm font-semibold text-slate-800">
|
||||
Places
|
||||
<span className="ml-1.5 text-xs font-normal text-slate-400">
|
||||
({filteredPlaces.length}{filteredPlaces.length !== places.length ? `/${places.length}` : ''})
|
||||
</span>
|
||||
</h2>
|
||||
<div className="flex gap-1">
|
||||
{hasMapKey && (
|
||||
<button
|
||||
onClick={onSearchMaps}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-50 rounded-lg transition-colors"
|
||||
title="Search Google Maps"
|
||||
>
|
||||
<Map className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className={`p-1.5 rounded-lg transition-colors ${
|
||||
showFilters || hasActiveFilters
|
||||
? 'text-slate-700 bg-slate-50'
|
||||
: 'text-slate-400 hover:text-slate-600 hover:bg-slate-100'
|
||||
}`}
|
||||
title="Filters"
|
||||
>
|
||||
<SlidersHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search places..."
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm border border-slate-200 rounded-lg focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{showFilters && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{/* Category filter */}
|
||||
{categories.length > 0 && (
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={e => setSelectedCategory(e.target.value)}
|
||||
className="w-full px-2.5 py-1.5 text-sm border border-slate-200 rounded-lg focus:ring-2 focus:ring-slate-900 focus:border-transparent bg-white"
|
||||
>
|
||||
<option value="">All categories</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{/* Tag filters */}
|
||||
{tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.map(tag => (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => toggleTag(tag.id)}
|
||||
className={`text-xs px-2 py-0.5 rounded-full font-medium transition-all ${
|
||||
selectedTags.includes(tag.id)
|
||||
? 'text-white shadow-sm'
|
||||
: 'text-white opacity-50 hover:opacity-80'
|
||||
}`}
|
||||
style={{ backgroundColor: tag.color || '#6366f1' }}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="text-xs text-slate-500 hover:text-red-500 flex items-center gap-1"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add place button */}
|
||||
<div className="px-3 py-2 border-b border-slate-100">
|
||||
<button
|
||||
onClick={onAddPlace}
|
||||
className="w-full flex items-center justify-center gap-2 py-2 text-sm text-slate-700 hover:text-slate-900 bg-slate-50 hover:bg-slate-100 rounded-lg transition-colors font-medium"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Place
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Places list */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2 scroll-container">
|
||||
{filteredPlaces.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<Search className="w-6 h-6 text-slate-400" />
|
||||
</div>
|
||||
{places.length === 0 ? (
|
||||
<>
|
||||
<p className="text-sm font-medium text-slate-600">No places yet</p>
|
||||
<p className="text-xs text-slate-400 mt-1">Add places and drag them to days</p>
|
||||
<button
|
||||
onClick={onAddPlace}
|
||||
className="mt-3 text-sm text-slate-700 hover:text-slate-900 font-medium"
|
||||
>
|
||||
+ Add your first place
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium text-slate-600">No matches found</p>
|
||||
<p className="text-xs text-slate-400 mt-1">Try adjusting your filters</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
filteredPlaces.map(place => (
|
||||
<DraggablePlaceCard
|
||||
key={place.id}
|
||||
place={place}
|
||||
isAssigned={assignedPlaceIds.has(place.id)}
|
||||
onEdit={onEditPlace}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,33 @@
|
||||
import React, { useState } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation } from 'lucide-react'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||
|
||||
interface PlacesSidebarProps {
|
||||
places: Place[]
|
||||
categories: Category[]
|
||||
assignments: AssignmentsMap
|
||||
selectedDayId: number | null
|
||||
selectedPlaceId: number | null
|
||||
onPlaceClick: (placeId: number | null) => void
|
||||
onAddPlace: () => void
|
||||
onAssignToDay: (placeId: number, dayId: number) => void
|
||||
onEditPlace: (place: Place) => void
|
||||
onDeletePlace: (placeId: number) => void
|
||||
days: Day[]
|
||||
isMobile: boolean
|
||||
}
|
||||
|
||||
export default function PlacesSidebar({
|
||||
places, categories, assignments, selectedDayId, selectedPlaceId,
|
||||
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile,
|
||||
}) {
|
||||
}: PlacesSidebarProps) {
|
||||
const { t } = useTranslation()
|
||||
const ctxMenu = useContextMenu()
|
||||
const [search, setSearch] = useState('')
|
||||
@@ -1,876 +0,0 @@
|
||||
import React, { useState, useCallback, useEffect, useRef, useMemo, useLayoutEffect } from 'react'
|
||||
import { FixedSizeList } from 'react-window'
|
||||
import {
|
||||
Plus, Search, X, Navigation, RotateCcw, ExternalLink,
|
||||
ChevronDown, ChevronRight, ChevronUp, Clock, MapPin,
|
||||
CalendarDays, FileText, Check, Pencil, Trash2,
|
||||
} from 'lucide-react'
|
||||
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
|
||||
import PackingListPanel from '../Packing/PackingListPanel'
|
||||
import FileManager from '../Files/FileManager'
|
||||
import { ReservationModal } from './ReservationModal'
|
||||
import { PlaceDetailPanel } from './PlaceDetailPanel'
|
||||
import WeatherWidget from '../Weather/WeatherWidget'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
function formatShortDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString('de-DE', {
|
||||
day: 'numeric', month: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
function formatDateTime(dt) {
|
||||
if (!dt) return ''
|
||||
try {
|
||||
return new Date(dt).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
|
||||
} catch { return dt }
|
||||
}
|
||||
|
||||
export default function PlannerSidebar({
|
||||
trip, days, places, categories, tags,
|
||||
assignments, reservations, packingItems,
|
||||
selectedDayId, selectedPlaceId,
|
||||
onSelectDay, onPlaceClick, onPlaceEdit, onPlaceDelete,
|
||||
onAssignToDay, onRemoveAssignment, onReorder,
|
||||
onAddPlace, onEditTrip, onRouteCalculated, tripId,
|
||||
}) {
|
||||
const [activeSegment, setActiveSegment] = useState('plan')
|
||||
const [search, setSearch] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState('')
|
||||
const [isCalculatingRoute, setIsCalculatingRoute] = useState(false)
|
||||
const [showReservationModal, setShowReservationModal] = useState(false)
|
||||
const [editingReservation, setEditingReservation] = useState(null)
|
||||
const [routeInfo, setRouteInfo] = useState(null)
|
||||
const [expandedDays, setExpandedDays] = useState(new Set())
|
||||
// Day notes inline UI state: { [dayId]: { mode: 'add'|'edit', noteId?, text, time } }
|
||||
const [noteUi, setNoteUi] = useState({})
|
||||
const noteInputRef = useRef(null)
|
||||
|
||||
const tripStore = useTripStore()
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const SEGMENTS = [
|
||||
{ id: 'plan', label: 'Plan' },
|
||||
{ id: 'orte', label: t('planner.places') },
|
||||
{ id: 'reservierungen', label: t('planner.bookings') },
|
||||
{ id: 'packliste', label: t('planner.packingList') },
|
||||
{ id: 'dokumente', label: t('planner.documents') },
|
||||
]
|
||||
|
||||
const dayNotes = tripStore.dayNotes || {}
|
||||
const placesListRef = useRef(null)
|
||||
const [placesListHeight, setPlacesListHeight] = useState(400)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!placesListRef.current) return
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
setPlacesListHeight(entry.contentRect.height)
|
||||
})
|
||||
ro.observe(placesListRef.current)
|
||||
return () => ro.disconnect()
|
||||
}, [activeSegment])
|
||||
|
||||
// Auto-expand selected day
|
||||
useEffect(() => {
|
||||
if (selectedDayId) {
|
||||
setExpandedDays(prev => new Set([...prev, selectedDayId]))
|
||||
}
|
||||
}, [selectedDayId])
|
||||
|
||||
const toggleDay = (dayId) => {
|
||||
setExpandedDays(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(dayId)) next.delete(dayId)
|
||||
else next.add(dayId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const getDayAssignments = (dayId) =>
|
||||
(assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
|
||||
const selectedDayAssignments = selectedDayId ? getDayAssignments(selectedDayId) : []
|
||||
const selectedDay = selectedDayId ? days.find(d => d.id === selectedDayId) : null
|
||||
|
||||
const filteredPlaces = useMemo(() => places.filter(p => {
|
||||
const matchSearch = !search || p.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(p.address || '').toLowerCase().includes(search.toLowerCase())
|
||||
const matchCat = !categoryFilter || String(p.category_id) === String(categoryFilter)
|
||||
return matchSearch && matchCat
|
||||
}), [places, search, categoryFilter])
|
||||
|
||||
const isAssignedToDay = (placeId) =>
|
||||
selectedDayId && selectedDayAssignments.some(a => a.place?.id === placeId)
|
||||
|
||||
const totalCost = days.reduce((sum, d) => {
|
||||
const da = assignments[String(d.id)] || []
|
||||
return sum + da.reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
|
||||
}, 0)
|
||||
const currency = trip?.currency || 'EUR'
|
||||
|
||||
const filteredReservations = selectedDayId
|
||||
? reservations.filter(r => String(r.day_id) === String(selectedDayId) || !r.day_id)
|
||||
: reservations
|
||||
|
||||
// Get representative location for a day (first place with coords)
|
||||
const getDayLocation = (dayId) => {
|
||||
const da = getDayAssignments(dayId)
|
||||
const p = da.find(a => a.place?.lat && a.place?.lng)
|
||||
return p ? { lat: p.place.lat, lng: p.place.lng } : null
|
||||
}
|
||||
|
||||
// Route handlers
|
||||
const handleCalculateRoute = async () => {
|
||||
if (!selectedDayId) return
|
||||
const waypoints = selectedDayAssignments
|
||||
.map(a => a.place)
|
||||
.filter(p => p?.lat && p?.lng)
|
||||
.map(p => ({ lat: p.lat, lng: p.lng }))
|
||||
if (waypoints.length < 2) {
|
||||
toast.error(t('planner.minTwoPlaces'))
|
||||
return
|
||||
}
|
||||
setIsCalculatingRoute(true)
|
||||
try {
|
||||
const result = await calculateRoute(waypoints, 'walking')
|
||||
setRouteInfo({ distance: result.distanceText, duration: result.durationText })
|
||||
onRouteCalculated?.(result)
|
||||
toast.success(t('planner.routeCalculated'))
|
||||
} catch {
|
||||
toast.error(t('planner.routeCalcFailed'))
|
||||
} finally {
|
||||
setIsCalculatingRoute(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOptimizeRoute = async () => {
|
||||
if (!selectedDayId || selectedDayAssignments.length < 3) return
|
||||
const withCoords = selectedDayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||
const optimized = optimizeRoute(withCoords)
|
||||
const reorderedIds = optimized
|
||||
.map(p => selectedDayAssignments.find(a => a.place?.id === p.id)?.id)
|
||||
.filter(Boolean)
|
||||
// Append assignments without coordinates at end
|
||||
for (const a of selectedDayAssignments) {
|
||||
if (!reorderedIds.includes(a.id)) reorderedIds.push(a.id)
|
||||
}
|
||||
await onReorder(selectedDayId, reorderedIds)
|
||||
toast.success(t('planner.routeOptimized'))
|
||||
}
|
||||
|
||||
const handleOpenGoogleMaps = () => {
|
||||
const ps = selectedDayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||
const url = generateGoogleMapsUrl(ps)
|
||||
if (url) window.open(url, '_blank')
|
||||
else toast.error(t('planner.noGeoPlaces'))
|
||||
}
|
||||
|
||||
const handleMoveUp = async (dayId, idx) => {
|
||||
const da = getDayAssignments(dayId)
|
||||
if (idx === 0) return
|
||||
const ids = da.map(a => a.id)
|
||||
;[ids[idx - 1], ids[idx]] = [ids[idx], ids[idx - 1]]
|
||||
await onReorder(dayId, ids)
|
||||
}
|
||||
|
||||
const handleMoveDown = async (dayId, idx) => {
|
||||
const da = getDayAssignments(dayId)
|
||||
if (idx === da.length - 1) return
|
||||
const ids = da.map(a => a.id)
|
||||
;[ids[idx], ids[idx + 1]] = [ids[idx + 1], ids[idx]]
|
||||
await onReorder(dayId, ids)
|
||||
}
|
||||
|
||||
// Merge place assignments + day notes into a single sorted list
|
||||
const getMergedDayItems = (dayId) => {
|
||||
const da = getDayAssignments(dayId)
|
||||
const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
|
||||
return [
|
||||
...da.map(a => ({ type: 'place', sortKey: a.order_index, data: a })),
|
||||
...dn.map(n => ({ type: 'note', sortKey: n.sort_order, data: n })),
|
||||
].sort((a, b) => a.sortKey - b.sortKey)
|
||||
}
|
||||
|
||||
const openAddNote = (dayId) => {
|
||||
const merged = getMergedDayItems(dayId)
|
||||
const maxKey = merged.length > 0 ? Math.max(...merged.map(i => i.sortKey)) : -1
|
||||
setNoteUi(prev => ({ ...prev, [dayId]: { mode: 'add', text: '', time: '', sortOrder: maxKey + 1 } }))
|
||||
setTimeout(() => noteInputRef.current?.focus(), 50)
|
||||
}
|
||||
|
||||
const openEditNote = (dayId, note) => {
|
||||
setNoteUi(prev => ({ ...prev, [dayId]: { mode: 'edit', noteId: note.id, text: note.text, time: note.time || '' } }))
|
||||
setTimeout(() => noteInputRef.current?.focus(), 50)
|
||||
}
|
||||
|
||||
const cancelNote = (dayId) => {
|
||||
setNoteUi(prev => { const n = { ...prev }; delete n[dayId]; return n })
|
||||
}
|
||||
|
||||
const saveNote = async (dayId) => {
|
||||
const ui = noteUi[dayId]
|
||||
if (!ui?.text?.trim()) return
|
||||
try {
|
||||
if (ui.mode === 'add') {
|
||||
await tripStore.addDayNote(tripId, dayId, { text: ui.text.trim(), time: ui.time || null, sort_order: ui.sortOrder })
|
||||
} else {
|
||||
await tripStore.updateDayNote(tripId, dayId, ui.noteId, { text: ui.text.trim(), time: ui.time || null })
|
||||
}
|
||||
cancelNote(dayId)
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteNote = async (dayId, noteId) => {
|
||||
try {
|
||||
await tripStore.deleteDayNote(tripId, dayId, noteId)
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNoteMoveUp = async (dayId, noteId) => {
|
||||
const merged = getMergedDayItems(dayId)
|
||||
const idx = merged.findIndex(item => item.type === 'note' && item.data.id === noteId)
|
||||
if (idx <= 0) return
|
||||
const newSortOrder = idx >= 2
|
||||
? (merged[idx - 2].sortKey + merged[idx - 1].sortKey) / 2
|
||||
: merged[idx - 1].sortKey - 1
|
||||
try {
|
||||
await tripStore.updateDayNote(tripId, dayId, noteId, { sort_order: newSortOrder })
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNoteMoveDown = async (dayId, noteId) => {
|
||||
const merged = getMergedDayItems(dayId)
|
||||
const idx = merged.findIndex(item => item.type === 'note' && item.data.id === noteId)
|
||||
if (idx === -1 || idx >= merged.length - 1) return
|
||||
const newSortOrder = idx < merged.length - 2
|
||||
? (merged[idx + 1].sortKey + merged[idx + 2].sortKey) / 2
|
||||
: merged[idx + 1].sortKey + 1
|
||||
try {
|
||||
await tripStore.updateDayNote(tripId, dayId, noteId, { sort_order: newSortOrder })
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveReservation = async (data) => {
|
||||
try {
|
||||
if (editingReservation) {
|
||||
await tripStore.updateReservation(tripId, editingReservation.id, data)
|
||||
toast.success(t('planner.reservationUpdated'))
|
||||
} else {
|
||||
await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null })
|
||||
toast.success(t('planner.reservationAdded'))
|
||||
}
|
||||
setShowReservationModal(false)
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteReservation = async (id) => {
|
||||
if (!confirm(t('planner.confirmDeleteReservation'))) return
|
||||
try {
|
||||
await tripStore.deleteReservation(tripId, id)
|
||||
toast.success(t('planner.reservationDeleted'))
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white relative overflow-hidden" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif" }}>
|
||||
|
||||
<div className="px-4 pt-4 pb-3 flex-shrink-0 border-b border-gray-100">
|
||||
<button onClick={onEditTrip} className="w-full text-left group">
|
||||
<h1 className="font-semibold text-gray-900 text-[15px] leading-tight truncate group-hover:text-slate-600 transition-colors">
|
||||
{trip?.title}
|
||||
</h1>
|
||||
{(trip?.start_date || trip?.end_date) && (
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
{trip.start_date && formatShortDate(trip.start_date)}
|
||||
{trip.start_date && trip.end_date && ' – '}
|
||||
{trip.end_date && formatShortDate(trip.end_date)}
|
||||
{days.length > 0 && ` · ${days.length} ${t('planner.days')}`}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2 flex-shrink-0 border-b border-gray-100">
|
||||
<div className="flex bg-gray-100 rounded-[10px] p-0.5 gap-0.5">
|
||||
{SEGMENTS.map(seg => (
|
||||
<button
|
||||
key={seg.id}
|
||||
onClick={() => setActiveSegment(seg.id)}
|
||||
className={`flex-1 py-[5px] text-[11px] font-medium rounded-[8px] transition-all duration-150 leading-none ${
|
||||
activeSegment === seg.id
|
||||
? 'bg-white shadow-sm text-gray-900'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{seg.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
|
||||
{/* ── PLAN ── */}
|
||||
{activeSegment === 'plan' && (
|
||||
<div className="pb-4">
|
||||
<button
|
||||
onClick={() => onSelectDay(null)}
|
||||
className={`w-full text-left px-4 py-3 flex items-center gap-3 transition-colors border-b border-gray-50 ${
|
||||
selectedDayId === null ? 'bg-slate-100/70' : 'hover:bg-gray-50/80'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
selectedDayId === null ? 'bg-slate-900' : 'bg-gray-100'
|
||||
}`}>
|
||||
<MapPin className={`w-4 h-4 ${selectedDayId === null ? 'text-white' : 'text-gray-400'}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-medium ${selectedDayId === null ? 'text-slate-900' : 'text-gray-700'}`}>
|
||||
{t('planner.allPlaces')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">{t('planner.totalPlaces', { n: places.length })}</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{days.length === 0 ? (
|
||||
<div className="px-4 py-10 text-center">
|
||||
<CalendarDays className="w-10 h-10 text-gray-200 mx-auto mb-3" />
|
||||
<p className="text-sm text-gray-400">{t('planner.noDaysPlanned')}</p>
|
||||
<button onClick={onEditTrip} className="mt-2 text-slate-700 text-sm">
|
||||
{t('planner.editTrip')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
days.map((day, index) => {
|
||||
const isSelected = selectedDayId === day.id
|
||||
const isExpanded = expandedDays.has(day.id)
|
||||
const da = getDayAssignments(day.id)
|
||||
const cost = da.reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
|
||||
const loc = getDayLocation(day.id)
|
||||
const merged = getMergedDayItems(day.id)
|
||||
const dayNoteUi = noteUi[day.id]
|
||||
const placeItems = merged.filter(i => i.type === 'place')
|
||||
|
||||
return (
|
||||
<div key={day.id} className="border-b border-gray-50">
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-3 cursor-pointer select-none transition-colors ${
|
||||
isSelected ? 'bg-slate-100/60' : 'hover:bg-gray-50/80'
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectDay(day.id)
|
||||
if (!isExpanded) toggleDay(day.id)
|
||||
}}
|
||||
>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 ${
|
||||
isSelected ? 'bg-slate-900 text-white' : 'bg-gray-100 text-gray-500'
|
||||
}`}>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className={`text-sm font-medium truncate ${isSelected ? 'text-slate-900' : 'text-gray-800'}`}>
|
||||
{day.title || `Tag ${index + 1}`}
|
||||
</p>
|
||||
{da.length > 0 && (
|
||||
<span className="text-xs text-gray-400 flex-shrink-0">
|
||||
{da.length === 1 ? t('planner.placeOne') : t('planner.placeN', { n: da.length })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
{day.date && <span className="text-xs text-gray-400">{formatShortDate(day.date)}</span>}
|
||||
{cost > 0 && <span className="text-xs text-emerald-600">{cost.toFixed(0)} {currency}</span>}
|
||||
{day.date && loc && (
|
||||
<WeatherWidget lat={loc.lat} lng={loc.lng} date={day.date} compact />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); openAddNote(day.id); if (!isExpanded) toggleDay(day.id) }}
|
||||
title={t('planner.addNote')}
|
||||
className="p-1 text-gray-300 hover:text-amber-500 flex-shrink-0 transition-colors"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); toggleDay(day.id) }}
|
||||
className="p-1 text-gray-300 hover:text-gray-500 flex-shrink-0"
|
||||
>
|
||||
{isExpanded
|
||||
? <ChevronDown className="w-4 h-4" />
|
||||
: <ChevronRight className="w-4 h-4" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="bg-gray-50/40">
|
||||
{merged.length === 0 && !dayNoteUi ? (
|
||||
<div className="px-4 py-4 text-center">
|
||||
<p className="text-xs text-gray-400">{t('planner.noEntries')}</p>
|
||||
<button
|
||||
onClick={() => { onSelectDay(day.id); setActiveSegment('orte') }}
|
||||
className="mt-1 text-xs text-slate-700"
|
||||
>
|
||||
{t('planner.addPlaceShort')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100/60">
|
||||
{merged.map((item, idx) => {
|
||||
if (item.type === 'place') {
|
||||
const assignment = item.data
|
||||
const place = assignment.place
|
||||
if (!place) return null
|
||||
const category = categories.find(c => c.id === place.category_id)
|
||||
const isPlaceSelected = place.id === selectedPlaceId
|
||||
const placeIdx = placeItems.findIndex(i => i.data.id === assignment.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`place-${assignment.id}`}
|
||||
className={`group flex items-center gap-2.5 pl-4 pr-3 py-2.5 cursor-pointer transition-colors ${
|
||||
isPlaceSelected ? 'bg-slate-50' : 'hover:bg-white/80'
|
||||
}`}
|
||||
onClick={() => onPlaceClick(isPlaceSelected ? null : place.id)}
|
||||
>
|
||||
<div
|
||||
className="w-9 h-9 rounded-[10px] overflow-hidden flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: (category?.color || '#6366f1') + '22' }}
|
||||
>
|
||||
{place.image_url ? (
|
||||
<img src={place.image_url} alt={place.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-lg">{category?.icon || '📍'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-[13px] font-medium truncate leading-snug ${isPlaceSelected ? 'text-slate-900' : 'text-gray-800'}`}>
|
||||
{place.name}
|
||||
</p>
|
||||
{(place.description || place.notes) && (
|
||||
<p className="text-[11px] text-gray-400 mt-0.5 leading-snug line-clamp-2">
|
||||
{place.description || place.notes}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
{place.place_time && (
|
||||
<span className="text-[11px] text-slate-600 font-medium">{place.place_time}{place.end_time ? ` – ${place.end_time}` : ''}</span>
|
||||
)}
|
||||
{place.price > 0 && (
|
||||
<span className="text-[11px] text-gray-400">{place.price} {place.currency || currency}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleMoveUp(day.id, placeIdx) }}
|
||||
disabled={placeIdx === 0}
|
||||
className="p-0.5 text-gray-300 hover:text-gray-600 disabled:opacity-20"
|
||||
>
|
||||
<ChevronUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleMoveDown(day.id, placeIdx) }}
|
||||
disabled={placeIdx === placeItems.length - 1}
|
||||
className="p-0.5 text-gray-300 hover:text-gray-600 disabled:opacity-20"
|
||||
>
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const note = item.data
|
||||
const isEditingThis = dayNoteUi?.mode === 'edit' && dayNoteUi.noteId === note.id
|
||||
if (isEditingThis) {
|
||||
return (
|
||||
<div key={`note-edit-${note.id}`} className="px-3 py-2 bg-amber-50/60">
|
||||
<div className="flex gap-2 mb-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={dayNoteUi.time}
|
||||
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], time: e.target.value } }))}
|
||||
placeholder={t('planner.noteTimePlaceholder')}
|
||||
className="w-24 text-[11px] border border-amber-200 rounded-lg px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
ref={noteInputRef}
|
||||
value={dayNoteUi.text}
|
||||
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], text: e.target.value } }))}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveNote(day.id) } if (e.key === 'Escape') cancelNote(day.id) }}
|
||||
placeholder={t('planner.notePlaceholder')}
|
||||
rows={2}
|
||||
className="w-full text-[12px] border border-amber-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300 resize-none"
|
||||
/>
|
||||
<div className="flex gap-1.5 mt-1.5">
|
||||
<button onClick={() => saveNote(day.id)} className="flex items-center gap-1 text-[11px] bg-amber-500 text-white px-2.5 py-1 rounded-lg hover:bg-amber-600">
|
||||
<Check className="w-3 h-3" /> {t('common.save')}
|
||||
</button>
|
||||
<button onClick={() => cancelNote(day.id)} className="text-[11px] text-gray-500 px-2.5 py-1 rounded-lg hover:bg-gray-100">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`note-${note.id}`} className="group flex items-start gap-2 pl-4 pr-3 py-2 bg-amber-50/40 hover:bg-amber-50/70 transition-colors">
|
||||
<FileText className="w-3.5 h-3.5 text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
{note.time && (
|
||||
<span className="text-[11px] font-semibold text-amber-600 mr-1.5">{note.time}</span>
|
||||
)}
|
||||
<span className="text-[12px] text-gray-700 leading-snug">{note.text}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={e => { e.stopPropagation(); handleNoteMoveUp(day.id, note.id) }} className="p-0.5 text-gray-300 hover:text-gray-600">
|
||||
<ChevronUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={e => { e.stopPropagation(); handleNoteMoveDown(day.id, note.id) }} className="p-0.5 text-gray-300 hover:text-gray-600">
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-0.5 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={e => { e.stopPropagation(); openEditNote(day.id, note) }} className="p-1 text-gray-300 hover:text-amber-500 rounded">
|
||||
<Pencil className="w-3 h-3" />
|
||||
</button>
|
||||
<button onClick={e => { e.stopPropagation(); handleDeleteNote(day.id, note.id) }} className="p-1 text-gray-300 hover:text-red-500 rounded">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dayNoteUi?.mode === 'add' && (
|
||||
<div className="px-3 py-2 border-t border-amber-100 bg-amber-50/60">
|
||||
<div className="flex gap-2 mb-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={dayNoteUi.time}
|
||||
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], time: e.target.value } }))}
|
||||
placeholder={t('planner.noteTimePlaceholder')}
|
||||
className="w-24 text-[11px] border border-amber-200 rounded-lg px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
ref={noteInputRef}
|
||||
value={dayNoteUi.text}
|
||||
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], text: e.target.value } }))}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveNote(day.id) } if (e.key === 'Escape') cancelNote(day.id) }}
|
||||
placeholder={t('planner.noteExamplePlaceholder')}
|
||||
rows={2}
|
||||
className="w-full text-[12px] border border-amber-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300 resize-none"
|
||||
/>
|
||||
<div className="flex gap-1.5 mt-1.5">
|
||||
<button onClick={() => saveNote(day.id)} className="flex items-center gap-1 text-[11px] bg-amber-500 text-white px-2.5 py-1 rounded-lg hover:bg-amber-600">
|
||||
<Check className="w-3 h-3" /> {t('common.add')}
|
||||
</button>
|
||||
<button onClick={() => cancelNote(day.id)} className="text-[11px] text-gray-500 px-2.5 py-1 rounded-lg hover:bg-gray-100">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!dayNoteUi && (
|
||||
<div className="px-4 py-2 border-t border-gray-100/60 flex gap-2">
|
||||
<button
|
||||
onClick={() => openAddNote(day.id)}
|
||||
className="flex items-center gap-1 text-[11px] text-amber-600 hover:text-amber-700 py-1"
|
||||
>
|
||||
<FileText className="w-3 h-3" />
|
||||
{t('planner.addNote')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Route tools — only for the selected day */}
|
||||
{isSelected && da.length >= 2 && (
|
||||
<div className="px-4 py-3 space-y-2 border-t border-gray-100/60">
|
||||
{routeInfo && (
|
||||
<div className="flex items-center justify-center gap-3 text-xs bg-slate-50 rounded-lg px-3 py-2">
|
||||
<span className="text-slate-900">🛣️ {routeInfo.distance}</span>
|
||||
<span className="text-slate-300">·</span>
|
||||
<span className="text-slate-900">⏱️ {routeInfo.duration}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<button
|
||||
onClick={handleCalculateRoute}
|
||||
disabled={isCalculatingRoute}
|
||||
className="flex items-center justify-center gap-1.5 bg-slate-900 text-white text-xs py-2 rounded-lg hover:bg-slate-700 disabled:opacity-60 transition-colors"
|
||||
>
|
||||
<Navigation className="w-3.5 h-3.5" />
|
||||
{isCalculatingRoute ? t('planner.calculating') : t('planner.route')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleOptimizeRoute}
|
||||
className="flex items-center justify-center gap-1.5 bg-emerald-600 text-white text-xs py-2 rounded-lg hover:bg-emerald-700 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
{t('planner.optimize')}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleOpenGoogleMaps}
|
||||
className="w-full flex items-center justify-center gap-1.5 border border-gray-200 text-gray-600 text-xs py-2 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
{t('planner.openGoogleMaps')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
|
||||
{totalCost > 0 && (
|
||||
<div className="px-4 py-3 border-t border-gray-100 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500">{t('planner.totalCost')}</span>
|
||||
<span className="text-sm font-semibold text-gray-800">{totalCost.toFixed(2)} {currency}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── ORTE ── */}
|
||||
{activeSegment === 'orte' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div className="p-3 space-y-2 border-b border-gray-100">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-[9px] w-3.5 h-3.5 text-gray-400 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder={t('planner.searchPlaces')}
|
||||
className="w-full pl-8 pr-8 py-2 bg-gray-100 rounded-[10px] text-sm focus:outline-none focus:bg-white focus:ring-2 focus:ring-slate-400 transition-colors"
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => setSearch('')} className="absolute right-3 top-[9px]">
|
||||
<X className="w-3.5 h-3.5 text-gray-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={e => setCategoryFilter(e.target.value)}
|
||||
className="flex-1 bg-gray-100 rounded-lg text-xs py-2 px-2 focus:outline-none text-gray-600"
|
||||
>
|
||||
<option value="">{t('planner.allCategories')}</option>
|
||||
{categories.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.icon} {c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={onAddPlace}
|
||||
className="flex items-center gap-1 bg-slate-900 text-white text-xs px-3 py-2 rounded-lg hover:bg-slate-700 whitespace-nowrap transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{t('planner.new')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredPlaces.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||
<span className="text-3xl mb-2">📍</span>
|
||||
<p className="text-sm">{t('planner.noPlacesFound')}</p>
|
||||
<button onClick={onAddPlace} className="mt-3 text-slate-700 text-sm">
|
||||
{t('planner.addFirstPlace')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div ref={placesListRef} style={{ flex: 1, minHeight: 0 }}>
|
||||
<FixedSizeList
|
||||
height={placesListHeight}
|
||||
itemCount={filteredPlaces.length}
|
||||
itemSize={68}
|
||||
overscanCount={10}
|
||||
width="100%"
|
||||
>
|
||||
{({ index, style }) => {
|
||||
const place = filteredPlaces[index]
|
||||
const category = categories.find(c => c.id === place.category_id)
|
||||
const inDay = isAssignedToDay(place.id)
|
||||
const isSelected = place.id === selectedPlaceId
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
key={place.id}
|
||||
onClick={() => onPlaceClick(isSelected ? null : place.id)}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors border-b border-gray-50 ${
|
||||
isSelected ? 'bg-slate-50' : 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="w-9 h-9 rounded-[10px] overflow-hidden flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: (category?.color || '#6366f1') + '22' }}
|
||||
>
|
||||
{place.image_url ? (
|
||||
<img src={place.image_url} alt={place.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-lg">{category?.icon || '📍'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="font-medium text-[13px] text-gray-900 truncate">{place.name}</span>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{inDay
|
||||
? <span className="text-[11px] text-emerald-600 bg-emerald-50 px-1.5 py-0.5 rounded-full">✓</span>
|
||||
: selectedDayId && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
|
||||
className="text-[11px] text-slate-700 bg-slate-50 px-1.5 py-0.5 rounded hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
{t('planner.addToDay')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{category && <p className="text-xs text-gray-500 mt-0.5">{category.icon} {category.name}</p>}
|
||||
{place.address && <p className="text-xs text-gray-400 truncate">{place.address}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</FixedSizeList>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── RESERVIERUNGEN ── */}
|
||||
{activeSegment === 'reservierungen' && (
|
||||
<div>
|
||||
<div className="px-4 py-3 flex items-center justify-between border-b border-gray-100">
|
||||
<h3 className="font-medium text-sm text-gray-900">
|
||||
{t('planner.reservations')}
|
||||
{selectedDay && <span className="text-gray-400 font-normal"> · Tag {selectedDay.day_number}</span>}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => { setEditingReservation(null); setShowReservationModal(true) }}
|
||||
className="flex items-center gap-1 bg-slate-900 text-white text-xs px-2.5 py-1.5 rounded-lg hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{t('common.add')}
|
||||
</button>
|
||||
</div>
|
||||
{filteredReservations.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||
<span className="text-3xl mb-2">🎫</span>
|
||||
<p className="text-sm">{t('planner.noReservations')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 space-y-2.5">
|
||||
{filteredReservations.map(r => (
|
||||
<div key={r.id} className="bg-white border border-gray-100 rounded-2xl p-3.5 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-[13px] text-gray-900">{r.title}</div>
|
||||
{r.reservation_time && (
|
||||
<div className="flex items-center gap-1 mt-1 text-xs text-slate-700">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatDateTime(r.reservation_time)}
|
||||
{r.reservation_end_time && ` – ${r.reservation_end_time}`}
|
||||
</div>
|
||||
)}
|
||||
{r.location && <div className="text-xs text-gray-500 mt-0.5">📍 {r.location}</div>}
|
||||
{r.confirmation_number && (
|
||||
<div className="text-xs text-emerald-600 mt-1 bg-emerald-50 rounded-lg px-2 py-0.5 inline-block">
|
||||
# {r.confirmation_number}
|
||||
</div>
|
||||
)}
|
||||
{r.notes && <p className="text-xs text-gray-500 mt-1.5 leading-relaxed">{r.notes}</p>}
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => { setEditingReservation(r); setShowReservationModal(true) }}
|
||||
className="p-1.5 text-gray-400 hover:text-slate-700 rounded-lg hover:bg-slate-50 transition-colors"
|
||||
>✏️</button>
|
||||
<button
|
||||
onClick={() => handleDeleteReservation(r.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50 transition-colors"
|
||||
>🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── PACKLISTE ── */}
|
||||
{activeSegment === 'packliste' && (
|
||||
<PackingListPanel tripId={tripId} items={packingItems} />
|
||||
)}
|
||||
|
||||
{/* ── DOKUMENTE ── */}
|
||||
{activeSegment === 'dokumente' && (
|
||||
<FileManager tripId={tripId} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── INSPECTOR OVERLAY ── */}
|
||||
{selectedPlace && (
|
||||
<div className="absolute inset-0 bg-white z-10 overflow-y-auto">
|
||||
<PlaceDetailPanel
|
||||
place={selectedPlace}
|
||||
categories={categories}
|
||||
tags={tags}
|
||||
selectedDayId={selectedDayId}
|
||||
dayAssignments={selectedDayAssignments}
|
||||
onClose={() => onPlaceClick(null)}
|
||||
onEdit={() => onPlaceEdit(selectedPlace)}
|
||||
onDelete={() => onPlaceDelete(selectedPlace.id)}
|
||||
onAssignToDay={onAssignToDay}
|
||||
onRemoveAssignment={onRemoveAssignment}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ReservationModal
|
||||
isOpen={showReservationModal}
|
||||
onClose={() => { setShowReservationModal(false); setEditingReservation(null) }}
|
||||
onSave={handleSaveReservation}
|
||||
reservation={editingReservation}
|
||||
days={days}
|
||||
places={places}
|
||||
selectedDayId={selectedDayId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
|
||||
@@ -6,6 +6,7 @@ import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import type { Day, Place, Reservation, TripFile, AssignmentsMap } from '../../types'
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
|
||||
@@ -45,7 +46,21 @@ function buildAssignmentOptions(days, assignments, t, locale) {
|
||||
return options
|
||||
}
|
||||
|
||||
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete }) {
|
||||
interface ReservationModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSave: (data: Record<string, string | number | null>) => Promise<void> | void
|
||||
reservation: Reservation | null
|
||||
days: Day[]
|
||||
places: Place[]
|
||||
assignments: AssignmentsMap
|
||||
selectedDayId: number | null
|
||||
files?: TripFile[]
|
||||
onFileUpload: (fd: FormData) => Promise<void>
|
||||
onFileDelete: (fileId: number) => Promise<void>
|
||||
}
|
||||
|
||||
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete }: ReservationModalProps) {
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
const fileInputRef = useRef(null)
|
||||
@@ -113,7 +128,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
}
|
||||
|
||||
const handleFileChange = async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
if (reservation?.id) {
|
||||
setUploadingFile(true)
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -8,6 +8,16 @@ import {
|
||||
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users,
|
||||
ExternalLink, BookMarked, Lightbulb, Link2, Clock,
|
||||
} from 'lucide-react'
|
||||
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
|
||||
|
||||
interface AssignmentLookupEntry {
|
||||
dayNumber: number
|
||||
dayTitle: string | null
|
||||
dayDate: string
|
||||
placeName: string
|
||||
startTime: string | null
|
||||
endTime: string | null
|
||||
}
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane, color: '#3b82f6' },
|
||||
@@ -37,7 +47,17 @@ function buildAssignmentLookup(days, assignments) {
|
||||
return map
|
||||
}
|
||||
|
||||
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup }) {
|
||||
interface ReservationCardProps {
|
||||
r: Reservation
|
||||
tripId: number
|
||||
onEdit: (reservation: Reservation) => void
|
||||
onDelete: (id: number) => void
|
||||
files?: TripFile[]
|
||||
onNavigateToFiles: () => void
|
||||
assignmentLookup: Record<number, AssignmentLookupEntry>
|
||||
}
|
||||
|
||||
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup }: ReservationCardProps) {
|
||||
const { toggleReservationStatus } = useTripStore()
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
@@ -176,7 +196,15 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ title, count, children, defaultOpen = true, accent }) {
|
||||
interface SectionProps {
|
||||
title: string
|
||||
count: number
|
||||
children: React.ReactNode
|
||||
defaultOpen?: boolean
|
||||
accent: 'green' | string
|
||||
}
|
||||
|
||||
function Section({ title, count, children, defaultOpen = true, accent }: SectionProps) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
return (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
@@ -197,7 +225,19 @@ function Section({ title, count, children, defaultOpen = true, accent }) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles }) {
|
||||
interface ReservationsPanelProps {
|
||||
tripId: number
|
||||
reservations: Reservation[]
|
||||
days: Day[]
|
||||
assignments: AssignmentsMap
|
||||
files?: TripFile[]
|
||||
onAdd: () => void
|
||||
onEdit: (reservation: Reservation) => void
|
||||
onDelete: (id: number) => void
|
||||
onNavigateToFiles: () => void
|
||||
}
|
||||
|
||||
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles }: ReservationsPanelProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const [showHint, setShowHint] = useState(() => !localStorage.getItem('hideReservationHint'))
|
||||
|
||||
@@ -1,591 +0,0 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { Plus, Search, ChevronUp, ChevronDown, X, Map, ExternalLink, Navigation, RotateCcw, Clock, Euro, FileText, Package } from 'lucide-react'
|
||||
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
|
||||
import PackingListPanel from '../Packing/PackingListPanel'
|
||||
import { ReservationModal } from './ReservationModal'
|
||||
import { PlaceDetailPanel } from './PlaceDetailPanel'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
export function RightPanel({
|
||||
trip, days, places, categories, tags,
|
||||
assignments, reservations, packingItems,
|
||||
selectedDay, selectedDayId, selectedPlaceId,
|
||||
onPlaceClick, onPlaceEdit, onPlaceDelete,
|
||||
onAssignToDay, onRemoveAssignment, onReorder,
|
||||
onAddPlace, onEditTrip, onRouteCalculated, tripId,
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState('orte')
|
||||
const [search, setSearch] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState('')
|
||||
const [isCalculatingRoute, setIsCalculatingRoute] = useState(false)
|
||||
const [showReservationModal, setShowReservationModal] = useState(false)
|
||||
const [editingReservation, setEditingReservation] = useState(null)
|
||||
const [routeInfo, setRouteInfo] = useState(null)
|
||||
|
||||
const tripStore = useTripStore()
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const TABS = [
|
||||
{ id: 'orte', label: t('planner.places'), icon: '📍' },
|
||||
{ id: 'tagesplan', label: t('planner.dayPlan'), icon: '📅' },
|
||||
{ id: 'reservierungen', label: t('planner.reservations'), icon: '🎫' },
|
||||
{ id: 'packliste', label: t('planner.packingList'), icon: '🎒' },
|
||||
]
|
||||
|
||||
// Filtered places for Orte tab
|
||||
const filteredPlaces = places.filter(p => {
|
||||
const matchesSearch = !search || p.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(p.address || '').toLowerCase().includes(search.toLowerCase())
|
||||
const matchesCategory = !categoryFilter || String(p.category_id) === String(categoryFilter)
|
||||
return matchesSearch && matchesCategory
|
||||
})
|
||||
|
||||
// Ordered assignments for selected day
|
||||
const dayAssignments = selectedDayId
|
||||
? (assignments[String(selectedDayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
: []
|
||||
|
||||
const isAssignedToSelectedDay = (placeId) =>
|
||||
selectedDayId && dayAssignments.some(a => a.place?.id === placeId)
|
||||
|
||||
// Calculate schedule with times
|
||||
const getSchedule = () => {
|
||||
if (!dayAssignments.length) return []
|
||||
let currentTime = null
|
||||
return dayAssignments.map((assignment, idx) => {
|
||||
const place = assignment.place
|
||||
const startTime = place?.place_time || (currentTime ? currentTime : null)
|
||||
const duration = place?.duration_minutes || 60
|
||||
if (startTime) {
|
||||
const [h, m] = startTime.split(':').map(Number)
|
||||
const endMinutes = h * 60 + m + duration
|
||||
const endH = Math.floor(endMinutes / 60) % 24
|
||||
const endM = endMinutes % 60
|
||||
currentTime = `${String(endH).padStart(2, '0')}:${String(endM).padStart(2, '0')}`
|
||||
}
|
||||
return { assignment, startTime, endTime: currentTime }
|
||||
})
|
||||
}
|
||||
|
||||
const handleCalculateRoute = async () => {
|
||||
if (!selectedDayId) return
|
||||
const waypoints = dayAssignments
|
||||
.map(a => a.place)
|
||||
.filter(p => p?.lat && p?.lng)
|
||||
.map(p => ({ lat: p.lat, lng: p.lng }))
|
||||
|
||||
if (waypoints.length < 2) {
|
||||
toast.error(t('planner.minTwoPlaces'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsCalculatingRoute(true)
|
||||
try {
|
||||
const result = await calculateRoute(waypoints, 'walking')
|
||||
if (result) {
|
||||
setRouteInfo({ distance: result.distanceText, duration: result.durationText })
|
||||
onRouteCalculated?.(result)
|
||||
toast.success(t('planner.routeCalculated'))
|
||||
} else {
|
||||
toast.error(t('planner.routeCalcFailed'))
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(t('planner.routeError'))
|
||||
} finally {
|
||||
setIsCalculatingRoute(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOptimizeRoute = async () => {
|
||||
if (!selectedDayId || dayAssignments.length < 3) return
|
||||
const places = dayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||
const optimized = optimizeRoute(places)
|
||||
const optimizedIds = optimized.map(p => {
|
||||
const a = dayAssignments.find(a => a.place?.id === p.id)
|
||||
return a?.id
|
||||
}).filter(Boolean)
|
||||
await onReorder(selectedDayId, optimizedIds)
|
||||
toast.success(t('planner.routeOptimized'))
|
||||
}
|
||||
|
||||
const handleOpenGoogleMaps = () => {
|
||||
const places = dayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||
const url = generateGoogleMapsUrl(places)
|
||||
if (url) window.open(url, '_blank')
|
||||
else toast.error(t('planner.noGeoPlaces'))
|
||||
}
|
||||
|
||||
const handleMoveUp = async (idx) => {
|
||||
if (idx === 0) return
|
||||
const ids = dayAssignments.map(a => a.id)
|
||||
;[ids[idx - 1], ids[idx]] = [ids[idx], ids[idx - 1]]
|
||||
await onReorder(selectedDayId, ids)
|
||||
}
|
||||
|
||||
const handleMoveDown = async (idx) => {
|
||||
if (idx === dayAssignments.length - 1) return
|
||||
const ids = dayAssignments.map(a => a.id)
|
||||
;[ids[idx], ids[idx + 1]] = [ids[idx + 1], ids[idx]]
|
||||
await onReorder(selectedDayId, ids)
|
||||
}
|
||||
|
||||
const handleAddReservation = () => {
|
||||
setEditingReservation(null)
|
||||
setShowReservationModal(true)
|
||||
}
|
||||
|
||||
const handleSaveReservation = async (data) => {
|
||||
try {
|
||||
if (editingReservation) {
|
||||
await tripStore.updateReservation(tripId, editingReservation.id, data)
|
||||
toast.success(t('planner.reservationUpdated'))
|
||||
} else {
|
||||
await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null })
|
||||
toast.success(t('planner.reservationAdded'))
|
||||
}
|
||||
setShowReservationModal(false)
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteReservation = async (id) => {
|
||||
if (!confirm(t('planner.confirmDeleteReservation'))) return
|
||||
try {
|
||||
await tripStore.deleteReservation(tripId, id)
|
||||
toast.success(t('planner.reservationDeleted'))
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
// Reservations for selected day (or all if no day selected)
|
||||
const filteredReservations = selectedDayId
|
||||
? reservations.filter(r => String(r.day_id) === String(selectedDayId) || !r.day_id)
|
||||
: reservations
|
||||
|
||||
const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-200 flex-shrink-0">
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex-1 py-2.5 text-xs font-medium transition-colors flex flex-col items-center gap-0.5 ${
|
||||
activeTab === tab.id
|
||||
? 'text-slate-700 border-b-2 border-slate-700'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="text-base leading-none">{tab.icon}</span>
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
|
||||
{/* ORTE TAB */}
|
||||
{activeTab === 'orte' && (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Place detail (when selected) */}
|
||||
{selectedPlace && (
|
||||
<div className="border-b border-gray-100">
|
||||
<PlaceDetailPanel
|
||||
place={selectedPlace}
|
||||
categories={categories}
|
||||
tags={tags}
|
||||
selectedDayId={selectedDayId}
|
||||
dayAssignments={dayAssignments}
|
||||
onClose={() => onPlaceClick(null)}
|
||||
onEdit={() => onPlaceEdit(selectedPlace)}
|
||||
onDelete={() => onPlaceDelete(selectedPlace.id)}
|
||||
onAssignToDay={onAssignToDay}
|
||||
onRemoveAssignment={onRemoveAssignment}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search & filter */}
|
||||
<div className="p-3 space-y-2 border-b border-gray-100 flex-shrink-0">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder={t('planner.searchPlaces')}
|
||||
className="w-full pl-8 pr-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-900"
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => setSearch('')} className="absolute right-2.5 top-2.5">
|
||||
<X className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={e => setCategoryFilter(e.target.value)}
|
||||
className="flex-1 border border-gray-200 rounded-lg text-xs py-1.5 px-2 focus:outline-none focus:ring-1 focus:ring-slate-900 text-gray-600"
|
||||
>
|
||||
<option value="">{t('planner.allCategories')}</option>
|
||||
{categories.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.icon} {c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={onAddPlace}
|
||||
className="flex items-center gap-1 bg-slate-700 text-white text-xs px-3 py-1.5 rounded-lg hover:bg-slate-900 whitespace-nowrap"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{t('planner.addPlace')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Places list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{filteredPlaces.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||
<span className="text-3xl mb-2">📍</span>
|
||||
<p className="text-sm">{t('planner.noPlacesFound')}</p>
|
||||
<button onClick={onAddPlace} className="mt-3 text-slate-700 text-sm hover:underline">
|
||||
{t('planner.addFirstPlace')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-50">
|
||||
{filteredPlaces.map(place => {
|
||||
const category = categories.find(c => c.id === place.category_id)
|
||||
const isInDay = isAssignedToSelectedDay(place.id)
|
||||
const isSelected = place.id === selectedPlaceId
|
||||
|
||||
return (
|
||||
<div
|
||||
key={place.id}
|
||||
onClick={() => onPlaceClick(isSelected ? null : place.id)}
|
||||
className={`px-3 py-2.5 cursor-pointer transition-colors ${
|
||||
isSelected ? 'bg-slate-50' : 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{/* Category color bar */}
|
||||
<div
|
||||
className="w-1 rounded-full flex-shrink-0 mt-1 self-stretch"
|
||||
style={{ backgroundColor: category?.color || '#6366f1', minHeight: 16 }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="font-medium text-sm text-gray-900 truncate">{place.name}</span>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{isInDay && (
|
||||
<span className="text-xs text-emerald-600 bg-emerald-50 px-1.5 py-0.5 rounded">✓</span>
|
||||
)}
|
||||
{!isInDay && selectedDayId && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
|
||||
className="text-xs text-slate-700 bg-slate-50 px-1.5 py-0.5 rounded hover:bg-slate-100"
|
||||
>
|
||||
{t('planner.addToDay')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{category && (
|
||||
<span className="text-xs text-gray-500">{category.icon} {category.name}</span>
|
||||
)}
|
||||
{place.address && (
|
||||
<p className="text-xs text-gray-400 truncate mt-0.5">{place.address}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{place.place_time && (
|
||||
<span className="text-xs text-gray-500">🕐 {place.place_time}{place.end_time ? ` – ${place.end_time}` : ''}</span>
|
||||
)}
|
||||
{place.price > 0 && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{place.price} {place.currency || trip?.currency}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAGESPLAN TAB */}
|
||||
{activeTab === 'tagesplan' && (
|
||||
<div className="flex flex-col h-full">
|
||||
{!selectedDayId ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400 px-6">
|
||||
<span className="text-4xl mb-3">📅</span>
|
||||
<p className="text-sm text-center">{t('planner.selectDayHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Day header */}
|
||||
<div className="px-4 py-3 bg-slate-50 border-b border-slate-100 flex-shrink-0">
|
||||
<h3 className="font-semibold text-slate-900 text-sm">
|
||||
Tag {selectedDay?.day_number}
|
||||
{selectedDay?.date && (
|
||||
<span className="font-normal text-slate-700 ml-2">
|
||||
{formatGermanDate(selectedDay.date)}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-700 mt-0.5">
|
||||
{dayAssignments.length === 1 ? t('planner.placeOne') : t('planner.placeN', { n: dayAssignments.length })}
|
||||
{dayAssignments.length > 0 && ` · ${dayAssignments.reduce((s, a) => s + (a.place?.duration_minutes || 60), 0)} ${t('planner.minTotal')}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Places list with order */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{dayAssignments.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||
<span className="text-3xl mb-2">🗺️</span>
|
||||
<p className="text-sm">{t('planner.noPlacesForDay')}</p>
|
||||
<button
|
||||
onClick={() => setActiveTab('orte')}
|
||||
className="mt-3 text-slate-700 text-sm hover:underline"
|
||||
>
|
||||
{t('planner.addPlacesLink')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-50">
|
||||
{getSchedule().map(({ assignment, startTime, endTime }, idx) => {
|
||||
const place = assignment.place
|
||||
if (!place) return null
|
||||
const category = categories.find(c => c.id === place.category_id)
|
||||
|
||||
return (
|
||||
<div key={assignment.id} className="px-3 py-3 flex items-start gap-2">
|
||||
{/* Order number */}
|
||||
<div
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0 mt-0.5"
|
||||
style={{ backgroundColor: category?.color || '#6366f1' }}
|
||||
>
|
||||
{idx + 1}
|
||||
</div>
|
||||
|
||||
{/* Place info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm text-gray-900 truncate">{place.name}</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{startTime && (
|
||||
<span className="text-xs text-slate-700">🕐 {startTime}</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-400">
|
||||
{place.duration_minutes || 60} Min.
|
||||
</span>
|
||||
{place.price > 0 && (
|
||||
<span className="text-xs text-gray-400">
|
||||
{place.price} {place.currency || trip?.currency}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{place.address && (
|
||||
<p className="text-xs text-gray-400 mt-0.5 truncate">{place.address}</p>
|
||||
)}
|
||||
{assignment.notes && (
|
||||
<p className="text-xs text-gray-500 mt-1 bg-gray-50 rounded px-2 py-1">{assignment.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col items-center gap-0.5 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleMoveUp(idx)}
|
||||
disabled={idx === 0}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
|
||||
>
|
||||
<ChevronUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMoveDown(idx)}
|
||||
disabled={idx === dayAssignments.length - 1}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
|
||||
>
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRemoveAssignment(selectedDayId, assignment.id)}
|
||||
className="p-1 text-red-400 hover:text-red-600"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Route buttons */}
|
||||
{dayAssignments.length >= 2 && (
|
||||
<div className="p-3 border-t border-gray-100 flex-shrink-0 space-y-2">
|
||||
{routeInfo && (
|
||||
<div className="flex items-center justify-center gap-3 text-sm bg-slate-50 rounded-lg px-3 py-2">
|
||||
<span className="text-slate-900">🛣️ {routeInfo.distance}</span>
|
||||
<span className="text-slate-400">·</span>
|
||||
<span className="text-slate-900">⏱️ {routeInfo.duration}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={handleCalculateRoute}
|
||||
disabled={isCalculatingRoute}
|
||||
className="flex items-center justify-center gap-1.5 bg-slate-700 text-white text-xs py-2 rounded-lg hover:bg-slate-900 disabled:opacity-60"
|
||||
>
|
||||
<Navigation className="w-3.5 h-3.5" />
|
||||
{isCalculatingRoute ? t('planner.calculating') : t('planner.route')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleOptimizeRoute}
|
||||
className="flex items-center justify-center gap-1.5 bg-emerald-600 text-white text-xs py-2 rounded-lg hover:bg-emerald-700"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
{t('planner.optimize')}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleOpenGoogleMaps}
|
||||
className="w-full flex items-center justify-center gap-1.5 bg-white border border-gray-200 text-gray-700 text-xs py-2 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
{t('planner.openGoogleMaps')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RESERVIERUNGEN TAB */}
|
||||
{activeTab === 'reservierungen' && (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-3 flex items-center justify-between border-b border-gray-100 flex-shrink-0">
|
||||
<h3 className="font-medium text-sm text-gray-900">
|
||||
{t('planner.reservations')}
|
||||
{selectedDay && <span className="text-gray-500 font-normal"> · Tag {selectedDay.day_number}</span>}
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleAddReservation}
|
||||
className="flex items-center gap-1 bg-slate-700 text-white text-xs px-2.5 py-1.5 rounded-lg hover:bg-slate-900"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{t('common.add')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{filteredReservations.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||
<span className="text-3xl mb-2">🎫</span>
|
||||
<p className="text-sm">{t('planner.noReservations')}</p>
|
||||
<button onClick={handleAddReservation} className="mt-3 text-slate-700 text-sm hover:underline">
|
||||
{t('planner.addFirstReservation')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 space-y-3">
|
||||
{filteredReservations.map(reservation => (
|
||||
<div key={reservation.id} className="bg-white border border-gray-200 rounded-xl p-3 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-sm text-gray-900">{reservation.title}</div>
|
||||
{reservation.reservation_time && (
|
||||
<div className="flex items-center gap-1 mt-1 text-xs text-slate-700">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatDateTime(reservation.reservation_time)}
|
||||
{reservation.reservation_end_time && ` – ${reservation.reservation_end_time}`}
|
||||
</div>
|
||||
)}
|
||||
{reservation.location && (
|
||||
<div className="text-xs text-gray-500 mt-0.5">📍 {reservation.location}</div>
|
||||
)}
|
||||
{reservation.confirmation_number && (
|
||||
<div className="text-xs text-emerald-600 mt-1 bg-emerald-50 rounded px-2 py-0.5 inline-block">
|
||||
# {reservation.confirmation_number}
|
||||
</div>
|
||||
)}
|
||||
{reservation.notes && (
|
||||
<p className="text-xs text-gray-500 mt-1.5 leading-relaxed">{reservation.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => { setEditingReservation(reservation); setShowReservationModal(true) }}
|
||||
className="p-1.5 text-gray-400 hover:text-slate-700 hover:bg-slate-50 rounded-lg"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteReservation(reservation.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PACKLISTE TAB */}
|
||||
{activeTab === 'packliste' && (
|
||||
<PackingListPanel
|
||||
tripId={tripId}
|
||||
items={packingItems}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reservation Modal */}
|
||||
<ReservationModal
|
||||
isOpen={showReservationModal}
|
||||
onClose={() => { setShowReservationModal(false); setEditingReservation(null) }}
|
||||
onSave={handleSaveReservation}
|
||||
reservation={editingReservation}
|
||||
days={days}
|
||||
places={places}
|
||||
selectedDayId={selectedDayId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatGermanDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr + 'T00:00:00')
|
||||
return date.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' })
|
||||
}
|
||||
|
||||
function formatDateTime(dt) {
|
||||
if (!dt) return ''
|
||||
try {
|
||||
return new Date(dt).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
|
||||
} catch {
|
||||
return dt
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,21 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { Calendar, Camera, X, Clipboard } from 'lucide-react'
|
||||
import { tripsApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import type { Trip } from '../../types'
|
||||
|
||||
export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUpdate }) {
|
||||
interface TripFormModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSave: (data: Record<string, string | number | null>) => Promise<void> | void
|
||||
trip: Trip | null
|
||||
onCoverUpdate: (tripId: number, coverUrl: string) => void
|
||||
}
|
||||
|
||||
export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUpdate }: TripFormModalProps) {
|
||||
const isEditing = !!trip
|
||||
const fileRef = useRef(null)
|
||||
const toast = useToast()
|
||||
@@ -68,8 +77,8 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
}
|
||||
}
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err.message || t('places.saveError'))
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : t('places.saveError'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -88,7 +97,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
}
|
||||
|
||||
const handleCoverChange = (e) => {
|
||||
handleCoverSelect(e.target.files?.[0])
|
||||
handleCoverSelect((e.target as HTMLInputElement).files?.[0])
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
@@ -128,7 +137,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
const handlePaste = (e) => {
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
for (const item of items) {
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
e.preventDefault()
|
||||
const file = item.getAsFile()
|
||||
@@ -1,13 +1,20 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { tripsApi, authApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { Crown, UserMinus, UserPlus, Users, LogOut } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
|
||||
function Avatar({ username, avatarUrl, size = 32 }) {
|
||||
interface AvatarProps {
|
||||
username: string
|
||||
avatarUrl: string | null
|
||||
size?: number
|
||||
}
|
||||
|
||||
function Avatar({ username, avatarUrl, size = 32 }: AvatarProps) {
|
||||
if (avatarUrl) {
|
||||
return <img src={avatarUrl} alt="" style={{ width: size, height: size, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }} />
|
||||
}
|
||||
@@ -25,7 +32,14 @@ function Avatar({ username, avatarUrl, size = 32 }) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }) {
|
||||
interface TripMembersModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
tripId: number
|
||||
tripTitle: string
|
||||
}
|
||||
|
||||
export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }: TripMembersModalProps) {
|
||||
const [data, setData] = useState(null)
|
||||
const [allUsers, setAllUsers] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -71,8 +85,8 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle })
|
||||
setSelectedUserId('')
|
||||
await loadMembers()
|
||||
toast.success(`${target.username} ${t('members.added')}`)
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('members.addError'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('members.addError')))
|
||||
} finally {
|
||||
setAdding(false)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useState, useCallback } from 'react'
|
||||
import { useMemo, useState, useCallback } from 'react'
|
||||
import { useVacayStore } from '../../store/vacayStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { isWeekend } from './holidays'
|
||||
@@ -1,16 +1,29 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { isWeekend } from './holidays'
|
||||
import type { HolidaysMap, VacayEntry } from '../../types'
|
||||
|
||||
const WEEKDAYS_EN = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
||||
const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||||
const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
|
||||
const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
|
||||
|
||||
interface VacayMonthCardProps {
|
||||
year: number
|
||||
month: number
|
||||
holidays: HolidaysMap
|
||||
companyHolidaySet: Set<string>
|
||||
companyHolidaysEnabled?: boolean
|
||||
entryMap: Record<string, VacayEntry[]>
|
||||
onCellClick: (date: string) => void
|
||||
companyMode: boolean
|
||||
blockWeekends: boolean
|
||||
}
|
||||
|
||||
export default function VacayMonthCard({
|
||||
year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap,
|
||||
onCellClick, companyMode, blockWeekends
|
||||
}) {
|
||||
}: VacayMonthCardProps) {
|
||||
const { language } = useTranslation()
|
||||
const weekdays = language === 'de' ? WEEKDAYS_DE : WEEKDAYS_EN
|
||||
const monthNames = language === 'de' ? MONTHS_DE : MONTHS_EN
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useEffect } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { UserPlus, Unlink, Check, Loader2, Clock, X } from 'lucide-react'
|
||||
import { useVacayStore } from '../../store/vacayStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import apiClient from '../../api/client'
|
||||
@@ -46,8 +48,8 @@ export default function VacayPersons() {
|
||||
toast.success(t('vacay.inviteSent'))
|
||||
setShowInvite(false)
|
||||
setSelectedInviteUser(null)
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('vacay.inviteError'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('vacay.inviteError')))
|
||||
} finally {
|
||||
setInviting(false)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { MapPin, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe } from 'lucide-react'
|
||||
import { useVacayStore } from '../../store/vacayStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -6,7 +6,11 @@ import { useToast } from '../shared/Toast'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import apiClient from '../../api/client'
|
||||
|
||||
export default function VacaySettings({ onClose }) {
|
||||
interface VacaySettingsProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const { plan, updatePlan, isFused, dissolve, users } = useVacayStore()
|
||||
@@ -192,7 +196,15 @@ export default function VacaySettings({ onClose }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SettingToggle({ icon: Icon, label, hint, value, onChange }) {
|
||||
interface SettingToggleProps {
|
||||
icon: React.ComponentType<{ size?: number; className?: string; style?: React.CSSProperties }>
|
||||
label: string
|
||||
hint: string
|
||||
value: boolean
|
||||
onChange: (value: boolean) => void
|
||||
}
|
||||
|
||||
function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingToggleProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
@@ -1,8 +1,16 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Briefcase, Pencil } from 'lucide-react'
|
||||
import { useVacayStore } from '../../store/vacayStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { VacayStat } from '../../types'
|
||||
|
||||
interface VacayStatExtended extends VacayStat {
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
color: string | null
|
||||
total_available: number
|
||||
}
|
||||
|
||||
export default function VacayStats() {
|
||||
const { t } = useTranslation()
|
||||
@@ -41,7 +49,16 @@ export default function VacayStats() {
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }) {
|
||||
interface StatCardProps {
|
||||
stat: VacayStatExtended
|
||||
isMe: boolean
|
||||
canEdit: boolean
|
||||
selectedYear: number
|
||||
onSave: (userId: number, year: number, days: number) => Promise<void>
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }: StatCardProps) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [localDays, setLocalDays] = useState(s.vacation_days)
|
||||
const pct = s.total_available > 0 ? Math.min(100, (s.used / s.total_available) * 100) : 0
|
||||
@@ -1,146 +0,0 @@
|
||||
// German public holidays (Feiertage) calculation per Bundesland
|
||||
// Includes fixed and Easter-dependent movable holidays
|
||||
|
||||
const BUNDESLAENDER = {
|
||||
BW: 'Baden-Württemberg',
|
||||
BY: 'Bayern',
|
||||
BE: 'Berlin',
|
||||
BB: 'Brandenburg',
|
||||
HB: 'Bremen',
|
||||
HH: 'Hamburg',
|
||||
HE: 'Hessen',
|
||||
MV: 'Mecklenburg-Vorpommern',
|
||||
NI: 'Niedersachsen',
|
||||
NW: 'Nordrhein-Westfalen',
|
||||
RP: 'Rheinland-Pfalz',
|
||||
SL: 'Saarland',
|
||||
SN: 'Sachsen',
|
||||
ST: 'Sachsen-Anhalt',
|
||||
SH: 'Schleswig-Holstein',
|
||||
TH: 'Thüringen',
|
||||
};
|
||||
|
||||
// Gauss Easter algorithm
|
||||
function easterSunday(year) {
|
||||
const a = year % 19;
|
||||
const b = Math.floor(year / 100);
|
||||
const c = year % 100;
|
||||
const d = Math.floor(b / 4);
|
||||
const e = b % 4;
|
||||
const f = Math.floor((b + 8) / 25);
|
||||
const g = Math.floor((b - f + 1) / 3);
|
||||
const h = (19 * a + b - d - g + 15) % 30;
|
||||
const i = Math.floor(c / 4);
|
||||
const k = c % 4;
|
||||
const l = (32 + 2 * e + 2 * i - h - k) % 7;
|
||||
const m = Math.floor((a + 11 * h + 22 * l) / 451);
|
||||
const month = Math.floor((h + l - 7 * m + 114) / 31);
|
||||
const day = ((h + l - 7 * m + 114) % 31) + 1;
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
function addDays(date, days) {
|
||||
const d = new Date(date);
|
||||
d.setDate(d.getDate() + days);
|
||||
return d;
|
||||
}
|
||||
|
||||
function fmt(date) {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
export function getHolidays(year, bundesland = 'NW') {
|
||||
const easter = easterSunday(year);
|
||||
const holidays = {};
|
||||
|
||||
// Fixed holidays (nationwide)
|
||||
holidays[`${year}-01-01`] = 'Neujahr';
|
||||
holidays[`${year}-05-01`] = 'Tag der Arbeit';
|
||||
holidays[`${year}-10-03`] = 'Tag der Deutschen Einheit';
|
||||
holidays[`${year}-12-25`] = '1. Weihnachtsfeiertag';
|
||||
holidays[`${year}-12-26`] = '2. Weihnachtsfeiertag';
|
||||
|
||||
// Easter-dependent (nationwide)
|
||||
holidays[fmt(addDays(easter, -2))] = 'Karfreitag';
|
||||
holidays[fmt(addDays(easter, 1))] = 'Ostermontag';
|
||||
holidays[fmt(addDays(easter, 39))] = 'Christi Himmelfahrt';
|
||||
holidays[fmt(addDays(easter, 50))] = 'Pfingstmontag';
|
||||
|
||||
// State-specific
|
||||
const bl = bundesland.toUpperCase();
|
||||
|
||||
// Heilige Drei Könige (6. Jan) — BW, BY, ST
|
||||
if (['BW', 'BY', 'ST'].includes(bl)) {
|
||||
holidays[`${year}-01-06`] = 'Heilige Drei Könige';
|
||||
}
|
||||
|
||||
// Internationaler Frauentag (8. März) — BE, MV
|
||||
if (['BE', 'MV'].includes(bl)) {
|
||||
holidays[`${year}-03-08`] = 'Internationaler Frauentag';
|
||||
}
|
||||
|
||||
// Fronleichnam — BW, BY, HE, NW, RP, SL, SN (teilweise), TH (teilweise)
|
||||
if (['BW', 'BY', 'HE', 'NW', 'RP', 'SL'].includes(bl)) {
|
||||
holidays[fmt(addDays(easter, 60))] = 'Fronleichnam';
|
||||
}
|
||||
|
||||
// Mariä Himmelfahrt (15. Aug) — SL, BY (teilweise)
|
||||
if (['SL'].includes(bl)) {
|
||||
holidays[`${year}-08-15`] = 'Mariä Himmelfahrt';
|
||||
}
|
||||
|
||||
// Weltkindertag (20. Sep) — TH
|
||||
if (bl === 'TH') {
|
||||
holidays[`${year}-09-20`] = 'Weltkindertag';
|
||||
}
|
||||
|
||||
// Reformationstag (31. Okt) — BB, HB, HH, MV, NI, SN, ST, SH, TH
|
||||
if (['BB', 'HB', 'HH', 'MV', 'NI', 'SN', 'ST', 'SH', 'TH'].includes(bl)) {
|
||||
holidays[`${year}-10-31`] = 'Reformationstag';
|
||||
}
|
||||
|
||||
// Allerheiligen (1. Nov) — BW, BY, NW, RP, SL
|
||||
if (['BW', 'BY', 'NW', 'RP', 'SL'].includes(bl)) {
|
||||
holidays[`${year}-11-01`] = 'Allerheiligen';
|
||||
}
|
||||
|
||||
// Buß- und Bettag — SN (Mittwoch vor dem 23. November)
|
||||
if (bl === 'SN') {
|
||||
const nov23 = new Date(year, 10, 23);
|
||||
let bbt = new Date(nov23);
|
||||
while (bbt.getDay() !== 3) bbt.setDate(bbt.getDate() - 1);
|
||||
holidays[fmt(bbt)] = 'Buß- und Bettag';
|
||||
}
|
||||
|
||||
return holidays;
|
||||
}
|
||||
|
||||
export function isWeekend(dateStr) {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
const day = d.getDay();
|
||||
return day === 0 || day === 6;
|
||||
}
|
||||
|
||||
export function getWeekday(dateStr) {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
return ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][d.getDay()];
|
||||
}
|
||||
|
||||
export function getWeekdayFull(dateStr) {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
return ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'][d.getDay()];
|
||||
}
|
||||
|
||||
export function daysInMonth(year, month) {
|
||||
return new Date(year, month, 0).getDate();
|
||||
}
|
||||
|
||||
export function formatDate(dateStr) {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
export { BUNDESLAENDER };
|
||||
131
client/src/components/Vacay/holidays.ts
Normal file
131
client/src/components/Vacay/holidays.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
const BUNDESLAENDER: Record<string, string> = {
|
||||
BW: 'Baden-Württemberg',
|
||||
BY: 'Bayern',
|
||||
BE: 'Berlin',
|
||||
BB: 'Brandenburg',
|
||||
HB: 'Bremen',
|
||||
HH: 'Hamburg',
|
||||
HE: 'Hessen',
|
||||
MV: 'Mecklenburg-Vorpommern',
|
||||
NI: 'Niedersachsen',
|
||||
NW: 'Nordrhein-Westfalen',
|
||||
RP: 'Rheinland-Pfalz',
|
||||
SL: 'Saarland',
|
||||
SN: 'Sachsen',
|
||||
ST: 'Sachsen-Anhalt',
|
||||
SH: 'Schleswig-Holstein',
|
||||
TH: 'Thüringen',
|
||||
}
|
||||
|
||||
function easterSunday(year: number): Date {
|
||||
const a = year % 19
|
||||
const b = Math.floor(year / 100)
|
||||
const c = year % 100
|
||||
const d = Math.floor(b / 4)
|
||||
const e = b % 4
|
||||
const f = Math.floor((b + 8) / 25)
|
||||
const g = Math.floor((b - f + 1) / 3)
|
||||
const h = (19 * a + b - d - g + 15) % 30
|
||||
const i = Math.floor(c / 4)
|
||||
const k = c % 4
|
||||
const l = (32 + 2 * e + 2 * i - h - k) % 7
|
||||
const m = Math.floor((a + 11 * h + 22 * l) / 451)
|
||||
const month = Math.floor((h + l - 7 * m + 114) / 31)
|
||||
const day = ((h + l - 7 * m + 114) % 31) + 1
|
||||
return new Date(year, month - 1, day)
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number): Date {
|
||||
const d = new Date(date)
|
||||
d.setDate(d.getDate() + days)
|
||||
return d
|
||||
}
|
||||
|
||||
function fmt(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
export function getHolidays(year: number, bundesland: string = 'NW'): Record<string, string> {
|
||||
const easter = easterSunday(year)
|
||||
const holidays: Record<string, string> = {}
|
||||
|
||||
holidays[`${year}-01-01`] = 'Neujahr'
|
||||
holidays[`${year}-05-01`] = 'Tag der Arbeit'
|
||||
holidays[`${year}-10-03`] = 'Tag der Deutschen Einheit'
|
||||
holidays[`${year}-12-25`] = '1. Weihnachtsfeiertag'
|
||||
holidays[`${year}-12-26`] = '2. Weihnachtsfeiertag'
|
||||
|
||||
holidays[fmt(addDays(easter, -2))] = 'Karfreitag'
|
||||
holidays[fmt(addDays(easter, 1))] = 'Ostermontag'
|
||||
holidays[fmt(addDays(easter, 39))] = 'Christi Himmelfahrt'
|
||||
holidays[fmt(addDays(easter, 50))] = 'Pfingstmontag'
|
||||
|
||||
const bl = bundesland.toUpperCase()
|
||||
|
||||
if (['BW', 'BY', 'ST'].includes(bl)) {
|
||||
holidays[`${year}-01-06`] = 'Heilige Drei Könige'
|
||||
}
|
||||
|
||||
if (['BE', 'MV'].includes(bl)) {
|
||||
holidays[`${year}-03-08`] = 'Internationaler Frauentag'
|
||||
}
|
||||
|
||||
if (['BW', 'BY', 'HE', 'NW', 'RP', 'SL'].includes(bl)) {
|
||||
holidays[fmt(addDays(easter, 60))] = 'Fronleichnam'
|
||||
}
|
||||
|
||||
if (['SL'].includes(bl)) {
|
||||
holidays[`${year}-08-15`] = 'Mariä Himmelfahrt'
|
||||
}
|
||||
|
||||
if (bl === 'TH') {
|
||||
holidays[`${year}-09-20`] = 'Weltkindertag'
|
||||
}
|
||||
|
||||
if (['BB', 'HB', 'HH', 'MV', 'NI', 'SN', 'ST', 'SH', 'TH'].includes(bl)) {
|
||||
holidays[`${year}-10-31`] = 'Reformationstag'
|
||||
}
|
||||
|
||||
if (['BW', 'BY', 'NW', 'RP', 'SL'].includes(bl)) {
|
||||
holidays[`${year}-11-01`] = 'Allerheiligen'
|
||||
}
|
||||
|
||||
if (bl === 'SN') {
|
||||
const nov23 = new Date(year, 10, 23)
|
||||
const bbt = new Date(nov23)
|
||||
while (bbt.getDay() !== 3) bbt.setDate(bbt.getDate() - 1)
|
||||
holidays[fmt(bbt)] = 'Buß- und Bettag'
|
||||
}
|
||||
|
||||
return holidays
|
||||
}
|
||||
|
||||
export function isWeekend(dateStr: string): boolean {
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
const day = d.getDay()
|
||||
return day === 0 || day === 6
|
||||
}
|
||||
|
||||
export function getWeekday(dateStr: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][d.getDay()]
|
||||
}
|
||||
|
||||
export function getWeekdayFull(dateStr: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'][d.getDay()]
|
||||
}
|
||||
|
||||
export function daysInMonth(year: number, month: number): number {
|
||||
return new Date(year, month, 0).getDate()
|
||||
}
|
||||
|
||||
export function formatDate(dateStr: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
export { BUNDESLAENDER }
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind } from 'lucide-react'
|
||||
import { weatherApi } from '../../api/client'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -15,7 +15,12 @@ const WEATHER_ICON_MAP = {
|
||||
Haze: Wind,
|
||||
}
|
||||
|
||||
function WeatherIcon({ main, size = 13 }) {
|
||||
interface WeatherIconProps {
|
||||
main: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
function WeatherIcon({ main, size = 13 }: WeatherIconProps) {
|
||||
const Icon = WEATHER_ICON_MAP[main] || Cloud
|
||||
return <Icon size={size} strokeWidth={1.8} />
|
||||
}
|
||||
@@ -32,7 +37,14 @@ function setWeatherCache(key, value) {
|
||||
try { sessionStorage.setItem(key, JSON.stringify(value)) } catch {}
|
||||
}
|
||||
|
||||
export default function WeatherWidget({ lat, lng, date, compact = false }) {
|
||||
interface WeatherWidgetProps {
|
||||
lat: number | null
|
||||
lng: number | null
|
||||
date: string
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export default function WeatherWidget({ lat, lng, date, compact = false }: WeatherWidgetProps) {
|
||||
const [weather, setWeather] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [failed, setFailed] = useState(false)
|
||||
@@ -2,6 +2,17 @@ import React, { useEffect, useCallback } from 'react'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
title?: string
|
||||
message?: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
danger?: boolean
|
||||
}
|
||||
|
||||
export default function ConfirmDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -11,10 +22,10 @@ export default function ConfirmDialog({
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
danger = true,
|
||||
}) {
|
||||
}: ConfirmDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleEsc = useCallback((e) => {
|
||||
const handleEsc = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}, [onClose])
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { LucideIcon } from 'lucide-react'
|
||||
|
||||
interface MenuItem {
|
||||
label?: string
|
||||
icon?: LucideIcon
|
||||
onClick?: () => void
|
||||
danger?: boolean
|
||||
divider?: boolean
|
||||
}
|
||||
|
||||
interface MenuState {
|
||||
x: number
|
||||
y: number
|
||||
items: MenuItem[]
|
||||
}
|
||||
|
||||
export function useContextMenu() {
|
||||
const [menu, setMenu] = useState(null) // { x, y, items }
|
||||
const [menu, setMenu] = useState<MenuState | null>(null)
|
||||
|
||||
const open = (e, items) => {
|
||||
const open = (e: React.MouseEvent, items: MenuItem[]) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setMenu({ x: e.clientX, y: e.clientY, items })
|
||||
@@ -15,8 +30,13 @@ export function useContextMenu() {
|
||||
return { menu, open, close }
|
||||
}
|
||||
|
||||
export function ContextMenu({ menu, onClose }) {
|
||||
const ref = useRef(null)
|
||||
interface ContextMenuProps {
|
||||
menu: MenuState | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ContextMenu({ menu, onClose }: ContextMenuProps) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!menu) return
|
||||
@@ -29,7 +49,6 @@ export function ContextMenu({ menu, onClose }) {
|
||||
}
|
||||
}, [menu, onClose])
|
||||
|
||||
// Adjust position if menu would overflow viewport
|
||||
useEffect(() => {
|
||||
if (!menu || !ref.current) return
|
||||
const el = ref.current
|
||||
@@ -60,7 +79,7 @@ export function ContextMenu({ menu, onClose }) {
|
||||
if (item.divider) return <div key={i} style={{ height: 1, background: 'var(--border-faint)', margin: '3px 6px' }} />
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<button key={i} onClick={() => { item.onClick(); onClose() }} style={{
|
||||
<button key={i} onClick={() => { item.onClick?.(); onClose() }} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||
padding: '7px 10px', borderRadius: 7, border: 'none',
|
||||
background: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
@@ -3,24 +3,30 @@ import ReactDOM from 'react-dom'
|
||||
import { Calendar, Clock, ChevronLeft, ChevronRight, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
function daysInMonth(year, month) { return new Date(year, month + 1, 0).getDate() }
|
||||
function getWeekday(year, month, day) { return new Date(year, month, day).getDay() }
|
||||
function daysInMonth(year: number, month: number): number { return new Date(year, month + 1, 0).getDate() }
|
||||
function getWeekday(year: number, month: number, day: number): number { return new Date(year, month, day).getDay() }
|
||||
|
||||
// ── Datum-Only Picker ────────────────────────────────────────────────────────
|
||||
export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
|
||||
interface CustomDatePickerProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
export function CustomDatePicker({ value, onChange, placeholder, style = {} }: CustomDatePickerProps) {
|
||||
const { locale, t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef(null)
|
||||
const dropRef = useRef(null)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const dropRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const parsed = value ? new Date(value + 'T00:00:00') : null
|
||||
const [viewYear, setViewYear] = useState(parsed?.getFullYear() || new Date().getFullYear())
|
||||
const [viewMonth, setViewMonth] = useState(parsed?.getMonth() ?? new Date().getMonth())
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if (ref.current?.contains(e.target)) return
|
||||
if (dropRef.current?.contains(e.target)) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current?.contains(e.target as Node)) return
|
||||
if (dropRef.current?.contains(e.target as Node)) return
|
||||
setOpen(false)
|
||||
}
|
||||
if (open) document.addEventListener('mousedown', handler)
|
||||
@@ -36,12 +42,12 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
|
||||
|
||||
const monthLabel = new Date(viewYear, viewMonth).toLocaleDateString(locale, { month: 'long', year: 'numeric' })
|
||||
const days = daysInMonth(viewYear, viewMonth)
|
||||
const startDay = (getWeekday(viewYear, viewMonth, 1) + 6) % 7 // Mo=0
|
||||
const startDay = (getWeekday(viewYear, viewMonth, 1) + 6) % 7
|
||||
const weekdays = Array.from({ length: 7 }, (_, i) => new Date(2024, 0, i + 1).toLocaleDateString(locale, { weekday: 'narrow' }))
|
||||
|
||||
const displayValue = parsed ? parsed.toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' }) : null
|
||||
|
||||
const selectDay = (day) => {
|
||||
const selectDay = (day: number) => {
|
||||
const y = String(viewYear)
|
||||
const m = String(viewMonth + 1).padStart(2, '0')
|
||||
const d = String(day).padStart(2, '0')
|
||||
@@ -51,7 +57,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
|
||||
|
||||
const selectedDay = parsed && parsed.getFullYear() === viewYear && parsed.getMonth() === viewMonth ? parsed.getDate() : null
|
||||
const today = new Date()
|
||||
const isToday = (d) => today.getFullYear() === viewYear && today.getMonth() === viewMonth && today.getDate() === d
|
||||
const isToday = (d: number) => today.getFullYear() === viewYear && today.getMonth() === viewMonth && today.getDate() === d
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', ...style }}>
|
||||
@@ -81,11 +87,8 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
|
||||
const vh = window.innerHeight
|
||||
let left = r.left
|
||||
let top = r.bottom + 4
|
||||
// Keep within viewport horizontally
|
||||
if (left + w > vw - pad) left = Math.max(pad, vw - w - pad)
|
||||
// If not enough space below, open above
|
||||
if (top + 320 > vh) top = Math.max(pad, r.top - 320)
|
||||
// On very small screens, center horizontally
|
||||
if (vw < 360) left = Math.max(pad, (vw - w) / 2)
|
||||
return { top, left }
|
||||
})(),
|
||||
@@ -161,16 +164,21 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── DateTime Picker (Datum + Uhrzeit kombiniert) ─────────────────────────────
|
||||
export function CustomDateTimePicker({ value, onChange, placeholder, style = {} }) {
|
||||
interface CustomDateTimePickerProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
export function CustomDateTimePicker({ value, onChange, placeholder, style = {} }: CustomDateTimePickerProps) {
|
||||
const { locale } = useTranslation()
|
||||
// value = "2024-03-15T14:30" oder ""
|
||||
const [datePart, timePart] = (value || '').split('T')
|
||||
|
||||
const handleDateChange = (d) => {
|
||||
const handleDateChange = (d: string) => {
|
||||
onChange(d ? `${d}T${timePart || '12:00'}` : '')
|
||||
}
|
||||
const handleTimeChange = (t) => {
|
||||
const handleTimeChange = (t: string) => {
|
||||
const d = datePart || new Date().toISOString().split('T')[0]
|
||||
onChange(t ? `${d}T${t}` : d)
|
||||
}
|
||||
@@ -185,5 +193,4 @@ export function CustomDateTimePicker({ value, onChange, placeholder, style = {}
|
||||
)
|
||||
}
|
||||
|
||||
// Inline re-export for convenience
|
||||
import CustomTimePicker from './CustomTimePicker'
|
||||
@@ -2,29 +2,48 @@ import React, { useState, useRef, useEffect } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { ChevronDown, Check } from 'lucide-react'
|
||||
|
||||
interface SelectOption {
|
||||
value: string
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
isHeader?: boolean
|
||||
searchLabel?: string
|
||||
groupLabel?: string
|
||||
}
|
||||
|
||||
interface CustomSelectProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
options?: SelectOption[]
|
||||
placeholder?: string
|
||||
searchable?: boolean
|
||||
style?: React.CSSProperties
|
||||
size?: 'sm' | 'md'
|
||||
}
|
||||
|
||||
export default function CustomSelect({
|
||||
value,
|
||||
onChange,
|
||||
options = [], // [{ value, label, icon? }]
|
||||
options = [],
|
||||
placeholder = '',
|
||||
searchable = false,
|
||||
style = {},
|
||||
size = 'md', // 'sm' | 'md'
|
||||
}) {
|
||||
size = 'md',
|
||||
}: CustomSelectProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const ref = useRef(null)
|
||||
const dropRef = useRef(null)
|
||||
const searchRef = useRef(null)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const dropRef = useRef<HTMLDivElement>(null)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open && searchable && searchRef.current) searchRef.current.focus()
|
||||
}, [open, searchable])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (e) => {
|
||||
if (ref.current?.contains(e.target)) return
|
||||
if (dropRef.current?.contains(e.target)) return
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (ref.current?.contains(e.target as Node)) return
|
||||
if (dropRef.current?.contains(e.target as Node)) return
|
||||
setOpen(false)
|
||||
}
|
||||
if (open) document.addEventListener('mousedown', handleClick)
|
||||
@@ -35,8 +54,8 @@ export default function CustomSelect({
|
||||
const filtered = searchable && search
|
||||
? (() => {
|
||||
const q = search.toLowerCase()
|
||||
const result = []
|
||||
let currentHeader = null
|
||||
const result: SelectOption[] = []
|
||||
let currentHeader: SelectOption | null = null
|
||||
let headerAdded = false
|
||||
for (const o of options) {
|
||||
if (o.isHeader) {
|
||||
@@ -44,7 +63,6 @@ export default function CustomSelect({
|
||||
headerAdded = false
|
||||
continue
|
||||
}
|
||||
// Match against label, searchLabel, or groupLabel
|
||||
const haystack = [o.label, o.searchLabel, o.groupLabel].filter(Boolean).join(' ').toLowerCase()
|
||||
if (haystack.includes(q)) {
|
||||
if (currentHeader && !headerAdded) {
|
||||
@@ -3,7 +3,7 @@ import ReactDOM from 'react-dom'
|
||||
import { Clock, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
|
||||
function formatDisplay(val, is12h) {
|
||||
function formatDisplay(val: string, is12h: boolean): string {
|
||||
if (!val) return ''
|
||||
const [h, m] = val.split(':').map(Number)
|
||||
if (isNaN(h) || isNaN(m)) return val
|
||||
@@ -13,28 +13,35 @@ function formatDisplay(val, is12h) {
|
||||
return `${h12}:${String(m).padStart(2, '0')} ${period}`
|
||||
}
|
||||
|
||||
export default function CustomTimePicker({ value, onChange, placeholder = '00:00', style = {} }) {
|
||||
interface CustomTimePickerProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
export default function CustomTimePicker({ value, onChange, placeholder = '00:00', style = {} }: CustomTimePickerProps) {
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const [open, setOpen] = useState(false)
|
||||
const [inputFocused, setInputFocused] = useState(false)
|
||||
const ref = useRef(null)
|
||||
const dropRef = useRef(null)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const dropRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [h, m] = (value || '').split(':').map(Number)
|
||||
const hour = isNaN(h) ? null : h
|
||||
const minute = isNaN(m) ? null : m
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if (ref.current?.contains(e.target)) return
|
||||
if (dropRef.current?.contains(e.target)) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current?.contains(e.target as Node)) return
|
||||
if (dropRef.current?.contains(e.target as Node)) return
|
||||
setOpen(false)
|
||||
}
|
||||
if (open) document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open])
|
||||
|
||||
const update = (newH, newM) => {
|
||||
const update = (newH: number, newM: number) => {
|
||||
const hh = String(Math.max(0, Math.min(23, newH))).padStart(2, '0')
|
||||
const mm = String(Math.max(0, Math.min(59, newM))).padStart(2, '0')
|
||||
onChange(`${hh}:${mm}`)
|
||||
@@ -53,16 +60,15 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
|
||||
update(newH, newM)
|
||||
}
|
||||
|
||||
const btnStyle = {
|
||||
const btnStyle: React.CSSProperties = {
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 2,
|
||||
color: 'var(--text-faint)', display: 'flex', borderRadius: 4,
|
||||
transition: 'color 0.15s',
|
||||
}
|
||||
|
||||
const handleInput = (e) => {
|
||||
const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const raw = e.target.value
|
||||
onChange(raw)
|
||||
// Auto-format: wenn "1430" → "14:30"
|
||||
const clean = raw.replace(/[^0-9:]/g, '')
|
||||
if (/^\d{2}:\d{2}$/.test(clean)) onChange(clean)
|
||||
else if (/^\d{4}$/.test(clean)) onChange(clean.slice(0, 2) + ':' + clean.slice(2))
|
||||
@@ -139,7 +145,7 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
|
||||
animation: 'selectIn 0.15s ease-out',
|
||||
backdropFilter: 'blur(24px)', WebkitBackdropFilter: 'blur(24px)',
|
||||
}}>
|
||||
{/* Stunden */}
|
||||
{/* Hours */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||
<button type="button" onClick={incHour} style={btnStyle}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
@@ -163,7 +169,7 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
|
||||
|
||||
<span style={{ fontSize: 22, fontWeight: 700, color: 'var(--text-faint)', marginTop: -2 }}>:</span>
|
||||
|
||||
{/* Minuten */}
|
||||
{/* Minutes */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||
<button type="button" onClick={incMin} style={btnStyle}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useCallback, useRef } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
const sizeClasses = {
|
||||
const sizeClasses: Record<string, string> = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
@@ -9,6 +9,16 @@ const sizeClasses = {
|
||||
'2xl': 'max-w-4xl',
|
||||
}
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
title?: React.ReactNode
|
||||
children?: React.ReactNode
|
||||
size?: string
|
||||
footer?: React.ReactNode
|
||||
hideCloseButton?: boolean
|
||||
}
|
||||
|
||||
export default function Modal({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -17,8 +27,8 @@ export default function Modal({
|
||||
size = 'md',
|
||||
footer,
|
||||
hideCloseButton = false,
|
||||
}) {
|
||||
const handleEsc = useCallback((e) => {
|
||||
}: ModalProps) {
|
||||
const handleEsc = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}, [onClose])
|
||||
|
||||
@@ -33,7 +43,7 @@ export default function Modal({
|
||||
}
|
||||
}, [isOpen, handleEsc])
|
||||
|
||||
const mouseDownTarget = useRef(null)
|
||||
const mouseDownTarget = useRef<EventTarget | null>(null)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
@@ -1,25 +1,37 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { getCategoryIcon } from './categoryIcons'
|
||||
import type { Place } from '../../types'
|
||||
|
||||
const googlePhotoCache = new Map()
|
||||
interface Category {
|
||||
color?: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
export default function PlaceAvatar({ place, size = 32, category }) {
|
||||
const [photoSrc, setPhotoSrc] = useState(place.image_url || null)
|
||||
interface PlaceAvatarProps {
|
||||
place: Pick<Place, 'id' | 'name' | 'image_url' | 'google_place_id'>
|
||||
size?: number
|
||||
category?: Category | null
|
||||
}
|
||||
|
||||
const googlePhotoCache = new Map<string, string>()
|
||||
|
||||
export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
|
||||
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
|
||||
|
||||
useEffect(() => {
|
||||
if (place.image_url) { setPhotoSrc(place.image_url); return }
|
||||
if (!place.google_place_id) { setPhotoSrc(null); return }
|
||||
|
||||
if (googlePhotoCache.has(place.google_place_id)) {
|
||||
setPhotoSrc(googlePhotoCache.get(place.google_place_id))
|
||||
setPhotoSrc(googlePhotoCache.get(place.google_place_id)!)
|
||||
return
|
||||
}
|
||||
|
||||
mapsApi.placePhoto(place.google_place_id)
|
||||
.then(data => {
|
||||
.then((data: { photoUrl?: string }) => {
|
||||
if (data.photoUrl) {
|
||||
googlePhotoCache.set(place.google_place_id, data.photoUrl)
|
||||
googlePhotoCache.set(place.google_place_id!, data.photoUrl)
|
||||
setPhotoSrc(data.photoUrl)
|
||||
}
|
||||
})
|
||||
@@ -30,7 +42,7 @@ export default function PlaceAvatar({ place, size = 32, category }) {
|
||||
const IconComp = getCategoryIcon(category?.icon)
|
||||
const iconSize = Math.round(size * 0.46)
|
||||
|
||||
const containerStyle = {
|
||||
const containerStyle: React.CSSProperties = {
|
||||
width: size, height: size,
|
||||
borderRadius: '50%',
|
||||
overflow: 'hidden',
|
||||
@@ -1,14 +1,28 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'
|
||||
import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react'
|
||||
|
||||
const ToastContext = createContext(null)
|
||||
type ToastType = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
interface Toast {
|
||||
id: number
|
||||
message: string
|
||||
type: ToastType
|
||||
duration: number
|
||||
removing: boolean
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__addToast?: (message: string, type?: ToastType, duration?: number) => number
|
||||
}
|
||||
}
|
||||
|
||||
let toastIdCounter = 0
|
||||
|
||||
export function ToastContainer() {
|
||||
const [toasts, setToasts] = useState([])
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
|
||||
const addToast = useCallback((message, type = 'info', duration = 3000) => {
|
||||
const addToast = useCallback((message: string, type: ToastType = 'info', duration: number = 3000) => {
|
||||
const id = ++toastIdCounter
|
||||
setToasts(prev => [...prev, { id, message, type, duration, removing: false }])
|
||||
|
||||
@@ -24,27 +38,26 @@ export function ToastContainer() {
|
||||
return id
|
||||
}, [])
|
||||
|
||||
const removeToast = useCallback((id) => {
|
||||
const removeToast = useCallback((id: number) => {
|
||||
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id))
|
||||
}, 300)
|
||||
}, [])
|
||||
|
||||
// Make addToast globally accessible
|
||||
useEffect(() => {
|
||||
window.__addToast = addToast
|
||||
return () => { delete window.__addToast }
|
||||
}, [addToast])
|
||||
|
||||
const icons = {
|
||||
const icons: Record<ToastType, React.ReactNode> = {
|
||||
success: <CheckCircle className="w-5 h-5 text-emerald-500 flex-shrink-0" />,
|
||||
error: <XCircle className="w-5 h-5 text-red-500 flex-shrink-0" />,
|
||||
warning: <AlertCircle className="w-5 h-5 text-amber-500 flex-shrink-0" />,
|
||||
info: <Info className="w-5 h-5 text-blue-500 flex-shrink-0" />,
|
||||
}
|
||||
|
||||
const bgColors = {
|
||||
const bgColors: Record<ToastType, string> = {
|
||||
success: 'bg-white border-l-4 border-emerald-500',
|
||||
error: 'bg-white border-l-4 border-red-500',
|
||||
warning: 'bg-white border-l-4 border-amber-500',
|
||||
@@ -78,17 +91,17 @@ export function ToastContainer() {
|
||||
}
|
||||
|
||||
export const useToast = () => {
|
||||
const show = useCallback((message, type, duration) => {
|
||||
const show = useCallback((message: string, type: ToastType, duration?: number) => {
|
||||
if (window.__addToast) {
|
||||
window.__addToast(message, type, duration)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
success: (message, duration) => show(message, 'success', duration),
|
||||
error: (message, duration) => show(message, 'error', duration),
|
||||
warning: (message, duration) => show(message, 'warning', duration),
|
||||
info: (message, duration) => show(message, 'info', duration),
|
||||
success: (message: string, duration?: number) => show(message, 'success', duration),
|
||||
error: (message: string, duration?: number) => show(message, 'error', duration),
|
||||
warning: (message: string, duration?: number) => show(message, 'warning', duration),
|
||||
info: (message: string, duration?: number) => show(message, 'info', duration),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
Church, Library, Store, Home, Cross,
|
||||
Heart, Star, CreditCard, Wifi,
|
||||
Luggage, Backpack, Zap,
|
||||
LucideIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
export const CATEGORY_ICON_MAP = {
|
||||
export const CATEGORY_ICON_MAP: Record<string, LucideIcon> = {
|
||||
MapPin, Building2, BedDouble, UtensilsCrossed, Landmark, ShoppingBag,
|
||||
Bus, Train, Car, Plane, Ship, Bike,
|
||||
Activity, Dumbbell, Mountain, Tent, Anchor,
|
||||
@@ -24,7 +25,7 @@ export const CATEGORY_ICON_MAP = {
|
||||
Luggage, Backpack, Zap,
|
||||
}
|
||||
|
||||
export const ICON_LABELS = {
|
||||
export const ICON_LABELS: Record<string, string> = {
|
||||
MapPin: 'Pin', Building2: 'Building', BedDouble: 'Hotel', UtensilsCrossed: 'Restaurant',
|
||||
Landmark: 'Attraction', ShoppingBag: 'Shopping', Bus: 'Bus', Train: 'Train',
|
||||
Car: 'Car', Plane: 'Plane', Ship: 'Ship', Bike: 'Bicycle',
|
||||
@@ -38,6 +39,6 @@ export const ICON_LABELS = {
|
||||
Luggage: 'Luggage', Backpack: 'Backpack', Zap: 'Adventure',
|
||||
}
|
||||
|
||||
export function getCategoryIcon(iconName) {
|
||||
return CATEGORY_ICON_MAP[iconName] || MapPin
|
||||
export function getCategoryIcon(iconName: string | null | undefined): LucideIcon {
|
||||
return (iconName && CATEGORY_ICON_MAP[iconName]) || MapPin
|
||||
}
|
||||
Reference in New Issue
Block a user