diff --git a/client/src/components/Budget/BudgetPanel.tsx b/client/src/components/Budget/BudgetPanel.tsx
index a6a8a9e..349af9c 100644
--- a/client/src/components/Budget/BudgetPanel.tsx
+++ b/client/src/components/Budget/BudgetPanel.tsx
@@ -2,6 +2,7 @@ import ReactDOM from 'react-dom'
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import DOM from 'react-dom'
import { useTripStore } from '../../store/tripStore'
+import { useCanDo } from '../../store/permissionsStore'
import { useTranslation } from '../../i18n'
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
@@ -59,7 +60,7 @@ const calcPD = (p, d) => (d > 0 ? p / d : null)
const calcPPD = (p, n, d) => (n > 0 && d > 0 ? p / (n * d) : null)
// ── Inline Edit Cell ─────────────────────────────────────────────────────────
-function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder = '', decimals = 2, locale, editTooltip }) {
+function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder = '', decimals = 2, locale, editTooltip, readOnly = false }) {
const [editing, setEditing] = useState(false)
const [editValue, setEditValue] = useState(value ?? '')
const inputRef = useRef(null)
@@ -86,12 +87,12 @@ function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder
: (value || '')
return (
-
{ setEditValue(value ?? ''); setEditing(true) }} title={editTooltip}
- style={{ cursor: 'pointer', padding: '4px 6px', borderRadius: 4, minHeight: 28, display: 'flex', alignItems: 'center',
+
{ if (readOnly) return; setEditValue(value ?? ''); setEditing(true) }} title={readOnly ? undefined : editTooltip}
+ style={{ cursor: readOnly ? 'default' : 'pointer', padding: '4px 6px', borderRadius: 4, minHeight: 28, display: 'flex', alignItems: 'center',
justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s',
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 13, ...style }}
- onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
- onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
+ onMouseEnter={e => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }}
+ onMouseLeave={e => { if (!readOnly) e.currentTarget.style.background = 'transparent' }}>
{display || placeholder || '-'}
)
@@ -227,9 +228,10 @@ interface BudgetMemberChipsProps {
onSetMembers: (memberIds: number[]) => void
onTogglePaid?: (userId: number, paid: boolean) => void
compact?: boolean
+ readOnly?: boolean
}
-function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true }: BudgetMemberChipsProps) {
+function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true, readOnly = false }: BudgetMemberChipsProps) {
const chipSize = compact ? 20 : 30
const btnSize = compact ? 18 : 28
const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14)
@@ -271,17 +273,19 @@ function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTog
{members.map(m => (
onTogglePaid(m.user_id, !m.paid) : undefined}
+ onClick={!readOnly && onTogglePaid ? () => onTogglePaid(m.user_id, !m.paid) : undefined}
/>
))}
-
+ {!readOnly && (
+
+ )}
{showDropdown && ReactDOM.createPortal(
(null)
const [settlementOpen, setSettlementOpen] = useState(false)
const currency = trip?.currency || 'EUR'
+ const canEdit = can('budget_edit', trip)
const fmt = (v, cur) => fmtNum(v, locale, cur)
const hasMultipleMembers = tripMembers.length > 1
@@ -482,16 +488,18 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
{t('budget.emptyTitle')}
{t('budget.emptyText')}
-
-
setNewCategoryName(e.target.value)}
- onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
- placeholder={t('budget.emptyPlaceholder')}
- style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} />
-
-
+ {canEdit && (
+
+
setNewCategoryName(e.target.value)}
+ onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
+ placeholder={t('budget.emptyPlaceholder')}
+ style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} />
+
+
+ )}
)
}
@@ -518,7 +526,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
- {editingCat?.name === cat ? (
+ {canEdit && editingCat?.name === cat ? (
{cat}
-
+ {canEdit && (
+
+ )}
>
)}
{fmt(subtotal, currency)}
-
+ {canEdit && (
+
+ )}
@@ -574,7 +586,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
- handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} />
+ handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
{/* Mobile: larger chips under name since Persons column is hidden */}
{hasMultipleMembers && (
@@ -584,12 +596,13 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
compact={false}
+ readOnly={!canEdit}
/>
)}
|
- handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} />
+ handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
{hasMultipleMembers ? (
@@ -598,29 +611,32 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
tripMembers={tripMembers}
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
+ readOnly={!canEdit}
/>
) : (
- handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
+ handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
)}
|
- handleUpdateField(item.id, 'days', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
+ handleUpdateField(item.id, 'days', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
{pp != null ? fmt(pp, currency) : '-'} |
{pd != null ? fmt(pd, currency) : '-'} |
{ppd != null ? fmt(ppd, currency) : '-'} |
- handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} /> |
+ handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> |
+ {canEdit && (
+ )}
|
)
})}
- handleAddItem(cat, data)} t={t} />
+ {canEdit && handleAddItem(cat, data)} t={t} />}
@@ -633,25 +649,27 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
{}}
options={CURRENCIES.map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
searchable
/>
-
-
setNewCategoryName(e.target.value)}
- onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
- placeholder={t('budget.categoryName')}
- style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-input)', color: 'var(--text-primary)' }}
- />
-
-
+ {canEdit && (
+
+
setNewCategoryName(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
+ placeholder={t('budget.categoryName')}
+ style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-input)', color: 'var(--text-primary)' }}
+ />
+
+
+ )}
s.settings.time_format) === '12h'
+ const can = useCanDo()
+ const trip = useTripStore((s) => s.trip)
+ const canEdit = can('collab_edit', trip)
const [messages, setMessages] = useState([])
const [loading, setLoading] = useState(true)
@@ -636,11 +641,11 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
style={{ position: 'relative' }}
onMouseEnter={() => setHoveredId(msg.id)}
onMouseLeave={() => setHoveredId(null)}
- onContextMenu={e => { e.preventDefault(); setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }}
+ onContextMenu={e => { e.preventDefault(); if (canEdit) setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }}
onTouchEnd={e => {
const now = Date.now()
const lastTap = e.currentTarget.dataset.lastTap || 0
- if (now - lastTap < 300) {
+ if (now - lastTap < 300 && canEdit) {
e.preventDefault()
const touch = e.changedTouches?.[0]
if (touch) setReactMenu({ msgId: msg.id, x: touch.clientX, y: touch.clientY })
@@ -703,7 +708,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
>
- {own && (
+ {own && canEdit && (
@@ -780,23 +785,27 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
{/* Emoji button */}
-
+ {canEdit && (
+
+ )}
{/* Send */}
-
+ {canEdit && (
+
+ )}
diff --git a/client/src/components/Collab/CollabNotes.tsx b/client/src/components/Collab/CollabNotes.tsx
index a09dedb..8658d8e 100644
--- a/client/src/components/Collab/CollabNotes.tsx
+++ b/client/src/components/Collab/CollabNotes.tsx
@@ -5,6 +5,8 @@ import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2 } from 'lucide-react'
import { collabApi } from '../../api/client'
+import { useCanDo } from '../../store/permissionsStore'
+import { useTripStore } from '../../store/tripStore'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
import type { User } from '../../types'
@@ -226,6 +228,9 @@ interface NoteFormModalProps {
}
function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, categoryColors, getCategoryColor, note, tripId, t }: NoteFormModalProps) {
+ const can = useCanDo()
+ const tripObj = useTripStore((s) => s.trip)
+ const canUploadFiles = can('file_upload', tripObj)
const isEdit = !!note
const allCategories = [...new Set([...existingCategories, ...Object.keys(categoryColors || {})])].filter(Boolean)
@@ -298,6 +303,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
}}
onClick={e => e.stopPropagation()}
onPaste={e => {
+ if (!canUploadFiles) return
const items = e.clipboardData?.items
if (!items) return
for (const item of Array.from(items)) {
@@ -450,7 +456,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
{/* File attachments */}
-
+ {canUploadFiles &&
{t('collab.notes.attachFiles')}
@@ -483,7 +489,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
{t('files.attach') || 'Add'}
-
+ }
{/* Submit */}
)}
-
- }
+ {canEdit &&
- }
{/* Author avatar */}
s.trip)
+ const canEdit = can('collab_edit', trip)
const [notes, setNotes] = useState([])
const [loading, setLoading] = useState(true)
const [showNewModal, setShowNewModal] = useState(false)
@@ -1130,11 +1140,11 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
-
setShowNewModal(true)}
+ {canEdit && setShowNewModal(true)}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px', background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600, fontFamily: FONT, border: 'none', cursor: 'pointer', whiteSpace: 'nowrap' }}>
{t('collab.notes.new')}
-
+ }
@@ -1252,6 +1262,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
key={note.id}
note={note}
currentUser={currentUser}
+ canEdit={canEdit}
onUpdate={handleUpdateNote}
onDelete={handleDeleteNote}
onEdit={setEditingNote}
@@ -1303,12 +1314,12 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
)}
-
{ setViewingNote(null); setEditingNote(viewingNote) }}
+ {canEdit && { setViewingNote(null); setEditingNote(viewingNote) }}
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
-
+ }
setViewingNote(null)}
style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
diff --git a/client/src/components/Collab/CollabPolls.tsx b/client/src/components/Collab/CollabPolls.tsx
index 66083ef..dc71ff9 100644
--- a/client/src/components/Collab/CollabPolls.tsx
+++ b/client/src/components/Collab/CollabPolls.tsx
@@ -3,6 +3,8 @@ import { Plus, Trash2, X, Check, BarChart3, Lock, Clock } from 'lucide-react'
import { collabApi } from '../../api/client'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
+import { useCanDo } from '../../store/permissionsStore'
+import { useTripStore } from '../../store/tripStore'
import ReactDOM from 'react-dom'
import type { User } from '../../types'
@@ -190,13 +192,14 @@ function VoterChip({ voter, offset }: VoterChipProps) {
interface PollCardProps {
poll: Poll
currentUser: User
+ canEdit: boolean
onVote: (pollId: number, optionId: number) => Promise
onClose: (pollId: number) => Promise
onDelete: (pollId: number) => Promise
t: (key: string) => string
}
-function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }: PollCardProps) {
+function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }: PollCardProps) {
const total = totalVotes(poll)
const isClosed = poll.is_closed || isExpired(poll.deadline)
const remaining = timeRemaining(poll.deadline)
@@ -238,22 +241,24 @@ function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }: PollCardP
{/* Actions */}
-
- {!isClosed && (
-
onClose(poll.id)} title={t('collab.polls.close')}
+ {canEdit && (
+
+ {!isClosed && (
+ onClose(poll.id)} title={t('collab.polls.close')}
+ style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
+ onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
+ onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
+
+
+ )}
+ onDelete(poll.id)} title={t('collab.polls.delete')}
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
- onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
+ onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
-
+
- )}
- onDelete(poll.id)} title={t('collab.polls.delete')}
- style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
- onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
- onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
-
-
-
+
+ )}
{/* Options */}
@@ -265,15 +270,15 @@ function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }: PollCardP
const isWinner = isClosed && count === Math.max(...(poll.options || []).map(o => o.voters?.length || 0)) && count > 0
return (
- !isClosed && onVote(poll.id, idx)}
- disabled={isClosed}
+ !isClosed && canEdit && onVote(poll.id, idx)}
+ disabled={isClosed || !canEdit}
style={{
position: 'relative', display: 'flex', alignItems: 'center', gap: 8,
- padding: '10px 12px', borderRadius: 10, border: 'none', cursor: isClosed ? 'default' : 'pointer',
+ padding: '10px 12px', borderRadius: 10, border: 'none', cursor: (isClosed || !canEdit) ? 'default' : 'pointer',
background: 'var(--bg-secondary)', fontFamily: FONT, textAlign: 'left', width: '100%',
overflow: 'hidden', transition: 'transform 0.1s',
}}
- onMouseEnter={e => { if (!isClosed) e.currentTarget.style.transform = 'scale(1.01)' }}
+ onMouseEnter={e => { if (!isClosed && canEdit) e.currentTarget.style.transform = 'scale(1.01)' }}
onMouseLeave={e => e.currentTarget.style.transform = 'scale(1)'}
>
{/* Progress bar background */}
@@ -337,6 +342,9 @@ interface CollabPollsProps {
export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
const { t } = useTranslation()
+ const can = useCanDo()
+ const trip = useTripStore((s) => s.trip)
+ const canEdit = can('collab_edit', trip)
const [polls, setPolls] = useState([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
@@ -426,13 +434,15 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
{t('collab.polls.title')}
- setShowForm(true)} style={{
- display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
- background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600,
- fontFamily: FONT, border: 'none', cursor: 'pointer',
- }}>
- {t('collab.polls.new')}
-
+ {canEdit && (
+ setShowForm(true)} style={{
+ display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
+ background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600,
+ fontFamily: FONT, border: 'none', cursor: 'pointer',
+ }}>
+ {t('collab.polls.new')}
+
+ )}
{/* Content */}
@@ -446,7 +456,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
) : (
{activePolls.length > 0 && activePolls.map(poll => (
-
+
))}
{closedPolls.length > 0 && (
<>
@@ -456,7 +466,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
)}
{closedPolls.map(poll => (
-
+
))}
>
)}
diff --git a/client/src/components/Files/FileManager.tsx b/client/src/components/Files/FileManager.tsx
index d0e85bb..3fcf8e2 100644
--- a/client/src/components/Files/FileManager.tsx
+++ b/client/src/components/Files/FileManager.tsx
@@ -257,6 +257,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
})
const handlePaste = useCallback((e) => {
+ if (!can('file_upload', trip)) return
const items = e.clipboardData?.items
if (!items) return
const pastedFiles = []
@@ -396,14 +397,14 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
{isTrash ? (
<>
-
handleRestore(file.id)} title={t('files.restore') || 'Restore'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
+ {can('file_delete', trip) && handleRestore(file.id)} title={t('files.restore') || 'Restore'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#22c55e'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
-
- handlePermanentDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
+ }
+ {can('file_delete', trip) && handlePermanentDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
-
+ }
>
) : (
<>
@@ -411,18 +412,18 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
onMouseEnter={e => { if (!file.starred) e.currentTarget.style.color = '#facc15' }} onMouseLeave={e => { if (!file.starred) e.currentTarget.style.color = 'var(--text-faint)' }}>
-
setAssignFileId(file.id)} title={t('files.assign') || 'Assign'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
+ {can('file_edit', trip) && setAssignFileId(file.id)} title={t('files.assign') || 'Assign'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
-
+ }
openFile({ ...file, url: fileUrl })} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
-
handleDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
+ {can('file_delete', trip) && handleDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
-
+ }
>
)}
@@ -685,7 +686,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
{showTrash ? (
/* Trash view */
- {trashFiles.length > 0 && (
+ {trashFiles.length > 0 && can('file_delete', trip) && (
Promise
+ canEdit?: boolean
}
-function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag }: ArtikelZeileProps) {
+function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
const [editing, setEditing] = useState(false)
const [editName, setEditName] = useState(item.name)
const [hovered, setHovered] = useState(false)
@@ -130,7 +132,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
{item.checked ? : }
- {editing ? (
+ {editing && canEdit ? (
setEditName(e.target.value)}
@@ -140,10 +142,10 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
/>
) : (
!item.checked && setEditing(true)}
+ onClick={() => canEdit && !item.checked && setEditing(true)}
style={{
flex: 1, fontSize: 13.5,
- cursor: item.checked ? 'default' : 'text',
+ cursor: !canEdit || item.checked ? 'default' : 'text',
color: item.checked ? 'var(--text-faint)' : 'var(--text-primary)',
textDecoration: item.checked ? 'line-through' : 'none',
}}
@@ -159,7 +161,9 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
{
+ if (!canEdit) return
const raw = e.target.value.replace(/[^0-9]/g, '')
const v = raw === '' ? null : parseInt(raw)
try { await updatePackingItem(tripId, item.id, { weight_grams: v }) } catch {}
@@ -171,9 +175,9 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
setShowBagPicker(p => !p)}
+ onClick={() => canEdit && setShowBagPicker(p => !p)}
style={{
- width: 22, height: 22, borderRadius: '50%', cursor: 'pointer', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
+ width: 22, height: 22, borderRadius: '50%', cursor: canEdit ? 'pointer' : 'default', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
border: item.bag_id ? `2.5px solid ${bags.find(b => b.id === item.bag_id)?.color || 'var(--border-primary)'}` : '2px dashed var(--border-primary)',
background: item.bag_id ? `${bags.find(b => b.id === item.bag_id)?.color || 'var(--border-primary)'}30` : 'transparent',
}}
@@ -247,6 +251,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
)}
+ {canEdit && (
)
}
@@ -319,9 +325,10 @@ interface KategorieGruppeProps {
bagTrackingEnabled?: boolean
bags?: PackingBag[]
onCreateBag: (name: string) => Promise
+ canEdit?: boolean
}
-function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag }: KategorieGruppeProps) {
+function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) {
const [offen, setOffen] = useState(true)
const [editingName, setEditingName] = useState(false)
const [editKatName, setEditKatName] = useState(kategorie)
@@ -380,7 +387,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
- {editingName ? (
+ {editingName && canEdit ? (
setEditKatName(e.target.value)}
@@ -398,11 +405,11 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
{assignees.map(a => (
{ e.stopPropagation(); onSetAssignees(kategorie, assignees.filter(x => x.user_id !== a.user_id).map(x => x.user_id)) }}
+ onClick={e => { e.stopPropagation(); if (canEdit) onSetAssignees(kategorie, assignees.filter(x => x.user_id !== a.user_id).map(x => x.user_id)) }}
>
))}
+ {canEdit && (
{ e.stopPropagation(); setShowAssigneeDropdown(v => !v) }}
style={{
@@ -479,6 +487,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
)}
+ )}
setShowMenu(false)}>
- } label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />
+ {canEdit && } label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />}
} label={t('packing.menuCheckAll')} onClick={() => { handleCheckAll(); setShowMenu(false) }} />
} label={t('packing.menuUncheckAll')} onClick={() => { handleUncheckAll(); setShowMenu(false) }} />
+ {canEdit && <>
} label={t('packing.menuDeleteCat')} danger onClick={handleDeleteAll} />
+ >}
)}
@@ -510,10 +521,10 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
{offen && (
{items.map(item => (
-
{}} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} />
+ {}} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} />
))}
{/* Inline add item */}
- {showAddItem ? (
+ {canEdit && (showAddItem ? (
)}
@@ -589,6 +600,9 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
const [addingCategory, setAddingCategory] = useState(false)
const [newCatName, setNewCatName] = useState('')
const { addPackingItem, updatePackingItem, deletePackingItem } = useTripStore()
+ const can = useCanDo()
+ const trip = useTripStore((s) => s.trip)
+ const canEdit = can('packing_edit', trip)
const toast = useToast()
const { t } = useTranslation()
@@ -814,7 +828,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
- {abgehakt > 0 && (
+ {canEdit && abgehakt > 0 && (
{t('packing.clearCheckedShort', { count: abgehakt })}
)}
+ {canEdit && (
setShowImportModal(true)} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer',
@@ -830,7 +845,8 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
}}>
{t('packing.import')}
- {availableTemplates.length > 0 && (
+ )}
+ {canEdit && availableTemplates.length > 0 && (
setShowTemplateDropdown(v => !v)} disabled={applyingTemplate} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
@@ -899,7 +915,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
)}
- {addingCategory ? (
+ {canEdit && (addingCategory ? (
{ e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}>
{t('packing.addCategory')}
- )}
+ ))}
{/* ── Filter-Tabs ── */}
@@ -972,6 +988,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
bagTrackingEnabled={bagTrackingEnabled}
bags={bags}
onCreateBag={handleCreateBagByName}
+ canEdit={canEdit}
/>
))}
@@ -998,10 +1015,12 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
+ {canEdit && (
handleDeleteBag(bag.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)', display: 'flex' }}>
+ )}
@@ -1039,7 +1058,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
{/* Add bag */}
- {showAddBag ? (
+ {canEdit && (showAddBag ? (
setNewBagName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }}
@@ -1054,7 +1073,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 12, padding: '5px 8px', borderRadius: 8, border: '1px dashed var(--border-primary)', background: 'none', cursor: 'pointer', fontSize: 11, color: 'var(--text-faint)', fontFamily: 'inherit', width: '100%' }}>
{t('packing.addBag')}
- )}
+ ))}
)}
@@ -1083,10 +1102,12 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
+ {canEdit && (
handleDeleteBag(bag.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)', display: 'flex' }}>
+ )}
@@ -1124,7 +1145,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
{/* Add bag */}
- {showAddBag ? (
+ {canEdit && (showAddBag ? (
)}
diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx
index d455595..22cc76e 100644
--- a/client/src/components/Planner/DayDetailPanel.tsx
+++ b/client/src/components/Planner/DayDetailPanel.tsx
@@ -5,6 +5,8 @@ import { X, Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind
const RES_TYPE_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
const RES_TYPE_COLORS = { flight: '#3b82f6', hotel: '#8b5cf6', restaurant: '#ef4444', train: '#06b6d4', car: '#6b7280', cruise: '#0ea5e9', event: '#f59e0b', tour: '#10b981', other: '#6b7280' }
import { weatherApi, accommodationsApi } from '../../api/client'
+import { useCanDo } from '../../store/permissionsStore'
+import { useTripStore } from '../../store/tripStore'
import CustomSelect from '../shared/CustomSelect'
import CustomTimePicker from '../shared/CustomTimePicker'
import { useSettingsStore } from '../../store/settingsStore'
@@ -56,6 +58,9 @@ interface DayDetailPanelProps {
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0 }: DayDetailPanelProps) {
const { t, language, locale } = useTranslation()
+ const can = useCanDo()
+ const tripObj = useTripStore((s) => s.trip)
+ const canEditDays = can('day_edit', tripObj)
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
@@ -337,13 +342,13 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
{acc.place_name}
{acc.place_address && {acc.place_address}
}
- { setAccommodation(acc); setHotelForm({ check_in: acc.check_in || '', check_out: acc.check_out || '', confirmation: acc.confirmation || '', place_id: acc.place_id }); setHotelDayRange({ start: acc.start_day_id, end: acc.end_day_id }); setShowHotelPicker('edit') }}
+ {canEditDays && { setAccommodation(acc); setHotelForm({ check_in: acc.check_in || '', check_out: acc.check_out || '', confirmation: acc.confirmation || '', place_id: acc.place_id }); setHotelDayRange({ start: acc.start_day_id, end: acc.end_day_id }); setShowHotelPicker('edit') }}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
-
- { setAccommodation(acc); handleRemoveAccommodation() }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
+ }
+ {canEditDays && { setAccommodation(acc); handleRemoveAccommodation() }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
-
+ }
{/* Details grid */}
@@ -394,22 +399,22 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
)
})}
{/* Add another hotel */}
- setShowHotelPicker(true)} style={{
+ {canEditDays && setShowHotelPicker(true)} style={{
width: '100%', padding: 8, border: '1.5px dashed var(--border-primary)', borderRadius: 10,
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
fontSize: 10, color: 'var(--text-faint)', fontFamily: 'inherit',
}}>
{t('day.addAccommodation')}
-
+ }
) : (
- setShowHotelPicker(true)} style={{
+ canEditDays ? setShowHotelPicker(true)} style={{
width: '100%', padding: 10, border: '1.5px dashed var(--border-primary)', borderRadius: 10,
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
fontSize: 11, color: 'var(--text-faint)', fontFamily: 'inherit',
}}>
{t('day.addAccommodation')}
-
+ : null
)}
{/* Hotel Picker Popup — portal to body to escape transform stacking context */}
diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx
index 4002ea4..6841bbd 100644
--- a/client/src/components/Planner/DayPlanSidebar.tsx
+++ b/client/src/components/Planner/DayPlanSidebar.tsx
@@ -16,6 +16,7 @@ import WeatherWidget from '../Weather/WeatherWidget'
import { useToast } from '../shared/Toast'
import { getCategoryIcon } from '../shared/categoryIcons'
import { useTripStore } from '../../store/tripStore'
+import { useCanDo } from '../../store/permissionsStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
@@ -94,6 +95,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const ctxMenu = useContextMenu()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const tripStore = useTripStore()
+ const can = useCanDo()
+ const canEditDays = can('day_edit', trip)
const { noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote: _openAddNote, openEditNote: _openEditNote, cancelNote, saveNote, deleteNote: _deleteNote, moveNote: _moveNote } = useDayNotes(tripId)
@@ -824,12 +827,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{day.title || t('dayplan.dayN', { n: index + 1 })}
- startEditTitle(day, e)}
style={{ flexShrink: 0, background: 'none', border: 'none', padding: '4px', cursor: 'pointer', opacity: 0.35, display: 'flex', alignItems: 'center' }}
>
-
+ }
{(() => {
const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
// Sort: check-out first, then ongoing stays, then check-in last
@@ -873,7 +876,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
- openAddNote(day.id, e)}
title={t('dayplan.addNote')}
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
@@ -881,7 +884,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}
>
-
+ }
toggleDay(day.id, e)}
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
@@ -1004,8 +1007,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{showDropLine && }
{
+ if (!canEditDays) { e.preventDefault(); return }
e.dataTransfer.setData('assignmentId', String(assignment.id))
e.dataTransfer.setData('fromDayId', String(day.id))
e.dataTransfer.effectAllowed = 'move'
@@ -1039,12 +1043,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
onContextMenu={e => ctxMenu.open(e, [
- onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
- onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
+ canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
+ canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank') },
{ divider: true },
- onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
+ canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
])}
onMouseEnter={() => setHoveredId(assignment.id)}
onMouseLeave={() => setHoveredId(null)}
@@ -1062,9 +1066,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
opacity: isDraggingThis ? 0.4 : 1,
}}
>
-
}
{ e.stopPropagation(); toggleLock(assignment.id) }}
onMouseEnter={e => { e.stopPropagation(); setLockHoverId(assignment.id) }}
@@ -1167,14 +1171,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
)}
- }
)
@@ -1273,8 +1277,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{showDropLine && }
{ e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
+ draggable={canEditDays}
+ onDragStart={e => { if (!canEditDays) { e.preventDefault(); return } e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
onDragEnd={() => { setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null }}
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }}
onDrop={e => {
@@ -1299,9 +1303,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
}
}}
onContextMenu={e => ctxMenu.open(e, [
- { label: t('common.edit'), icon: Pencil, onClick: () => openEditNote(day.id, note) },
- { divider: true },
- { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => deleteNote(day.id, note.id) },
+ canEditDays && { label: t('common.edit'), icon: Pencil, onClick: () => openEditNote(day.id, note) },
+ canEditDays && { divider: true },
+ canEditDays && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => deleteNote(day.id, note.id) },
])}
onMouseEnter={() => setHoveredId(`note-${note.id}`)}
onMouseLeave={() => setHoveredId(null)}
@@ -1316,9 +1320,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
}}
>
-
}
@@ -1330,14 +1334,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{note.time}
)}
-
+ {canEditDays &&
openEditNote(day.id, note, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}>
deleteNote(day.id, note.id, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}>
-
-
+
}
+ {canEditDays &&
{ e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', color: noteIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
{ e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', color: noteIdx === merged.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
-
+
}
)
diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx
index 40c4d9b..3d30f1a 100644
--- a/client/src/components/Planner/PlaceFormModal.tsx
+++ b/client/src/components/Planner/PlaceFormModal.tsx
@@ -3,6 +3,8 @@ import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { mapsApi } from '../../api/client'
import { useAuthStore } from '../../store/authStore'
+import { useCanDo } from '../../store/permissionsStore'
+import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast'
import { Search, Paperclip, X, AlertTriangle } from 'lucide-react'
import { useTranslation } from '../../i18n'
@@ -66,6 +68,9 @@ export default function PlaceFormModal({
const toast = useToast()
const { t, language } = useTranslation()
const { hasMapsKey } = useAuthStore()
+ const can = useCanDo()
+ const tripObj = useTripStore((s) => s.trip)
+ const canUploadFiles = can('file_upload', tripObj)
useEffect(() => {
if (place) {
@@ -171,6 +176,7 @@ export default function PlaceFormModal({
// Paste support for files/images
const handlePaste = (e) => {
+ if (!canUploadFiles) return
const items = e.clipboardData?.items
if (!items) return
for (const item of Array.from(items)) {
@@ -386,7 +392,7 @@ export default function PlaceFormModal({
{/* File Attachments */}
- {true && (
+ {canUploadFiles && (
diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx
index ec4aaa2..6b78182 100644
--- a/client/src/components/Planner/PlaceInspector.tsx
+++ b/client/src/components/Planner/PlaceInspector.tsx
@@ -122,7 +122,7 @@ interface PlaceInspectorProps {
onAssignToDay: (placeId: number, dayId: number) => void
onRemoveAssignment: (assignmentId: number, dayId: number) => void
files: TripFile[]
- onFileUpload: (fd: FormData) => Promise
+ onFileUpload?: (fd: FormData) => Promise
tripMembers?: TripMember[]
onSetParticipants: (assignmentId: number, dayId: number, participantIds: number[]) => void
onUpdatePlace: (placeId: number, data: Partial) => void
diff --git a/client/src/components/Planner/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx
index 8f57c4c..2b2b368 100644
--- a/client/src/components/Planner/PlacesSidebar.tsx
+++ b/client/src/components/Planner/PlacesSidebar.tsx
@@ -229,9 +229,9 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
{filter === 'unplanned' ? t('places.allPlanned') : t('places.noneFound')}
-
+ {canEditPlaces &&
{t('places.addPlace')}
-
+ }
) : (
filtered.map(place => {
diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx
index 9472fc2..dbc3ab3 100644
--- a/client/src/components/Planner/ReservationModal.tsx
+++ b/client/src/components/Planner/ReservationModal.tsx
@@ -59,7 +59,7 @@ interface ReservationModalProps {
assignments: AssignmentsMap
selectedDayId: number | null
files?: TripFile[]
- onFileUpload: (fd: FormData) => Promise
+ onFileUpload?: (fd: FormData) => Promise
onFileDelete: (fileId: number) => Promise
accommodations?: Accommodation[]
}
@@ -504,14 +504,14 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
))}
-
fileInputRef.current?.click()} disabled={uploadingFile} style={{
+ {onFileUpload && fileInputRef.current?.click()} disabled={uploadingFile} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
fontSize: 11, color: 'var(--text-faint)', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit',
}}>
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
-
+ }
{/* Link existing file picker */}
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx
index 9bb73c3..b6f9c55 100644
--- a/client/src/components/Planner/ReservationsPanel.tsx
+++ b/client/src/components/Planner/ReservationsPanel.tsx
@@ -1,6 +1,7 @@
import { useState, useMemo } from 'react'
import ReactDOM from 'react-dom'
import { useTripStore } from '../../store/tripStore'
+import { useCanDo } from '../../store/permissionsStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
@@ -56,9 +57,10 @@ interface ReservationCardProps {
files?: TripFile[]
onNavigateToFiles: () => void
assignmentLookup: Record
+ canEdit: boolean
}
-function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup }: ReservationCardProps) {
+function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup, canEdit }: ReservationCardProps) {
const { toggleReservationStatus } = useTripStore()
const toast = useToast()
const { t, locale } = useTranslation()
@@ -95,24 +97,34 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{/* Header bar */}
-
- {confirmed ? t('reservations.confirmed') : t('reservations.pending')}
-
+ {canEdit ? (
+
+ {confirmed ? t('reservations.confirmed') : t('reservations.pending')}
+
+ ) : (
+
+ {confirmed ? t('reservations.confirmed') : t('reservations.pending')}
+
+ )}
{t(typeInfo.labelKey)}
{r.title}
-
onEdit(r)} title={t('common.edit')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
- onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
- onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
-
-
-
setShowDeleteConfirm(true)} title={t('common.delete')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
- onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
- onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
-
-
+ {canEdit && (
+
onEdit(r)} title={t('common.edit')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
+ onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
+ onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
+
+
+ )}
+ {canEdit && (
+
setShowDeleteConfirm(true)} title={t('common.delete')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
+ onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
+ onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
+
+
+ )}
{/* Details */}
@@ -330,6 +342,9 @@ interface ReservationsPanelProps {
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles }: ReservationsPanelProps) {
const { t, locale } = useTranslation()
+ const can = useCanDo()
+ const trip = useTripStore((s) => s.trip)
+ const canEdit = can('reservation_edit', trip)
const [showHint, setShowHint] = useState(() => !localStorage.getItem('hideReservationHint'))
const assignmentLookup = useMemo(() => buildAssignmentLookup(days, assignments), [days, assignments])
@@ -348,13 +363,15 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
{total === 0 ? t('reservations.empty') : t('reservations.summary', { confirmed: allConfirmed.length, pending: allPending.length })}
-
- {t('reservations.addManual')}
-
+ {canEdit && (
+
+ {t('reservations.addManual')}
+
+ )}
{/* Content */}
@@ -370,14 +387,14 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
{allPending.length > 0 && (
- {allPending.map(r => )}
+ {allPending.map(r => )}
)}
{allConfirmed.length > 0 && (
- {allConfirmed.map(r => )}
+ {allConfirmed.map(r => )}
)}
diff --git a/client/src/components/Trips/TripFormModal.tsx b/client/src/components/Trips/TripFormModal.tsx
index b851f57..4e834b9 100644
--- a/client/src/components/Trips/TripFormModal.tsx
+++ b/client/src/components/Trips/TripFormModal.tsx
@@ -4,6 +4,7 @@ import { Calendar, Camera, X, Clipboard, UserPlus, Bell } from 'lucide-react'
import { tripsApi, authApi } from '../../api/client'
import CustomSelect from '../shared/CustomSelect'
import { useAuthStore } from '../../store/authStore'
+import { useCanDo } from '../../store/permissionsStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
@@ -25,6 +26,9 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
const currentUser = useAuthStore(s => s.user)
const tripRemindersEnabled = useAuthStore(s => s.tripRemindersEnabled)
const setTripRemindersEnabled = useAuthStore(s => s.setTripRemindersEnabled)
+ const can = useCanDo()
+ const canUploadCover = !isEditing || can('trip_cover_upload', trip)
+ const canEditTrip = !isEditing || can('trip_edit', trip)
const [formData, setFormData] = useState({
title: '',
@@ -174,6 +178,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
// Paste support for cover image
const handlePaste = (e) => {
+ if (!canUploadCover) return
const items = e.clipboardData?.items
if (!items) return
for (const item of Array.from(items)) {
@@ -231,8 +236,8 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
{error}
)}
- {/* Cover image — available for both create and edit */}
-
+ {/* Cover image — gated by trip_cover_upload permission */}
+ {canUploadCover &&
{coverPreview ? (
@@ -260,20 +265,20 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
{uploadingCover ? t('common.uploading') : t('dashboard.addCoverImage')}
)}
-
+
}
- update('title', e.target.value)}
- required placeholder={t('dashboard.tripTitlePlaceholder')} className={inputCls} />
+ canEditTrip && update('title', e.target.value)}
+ required readOnly={!canEditTrip} placeholder={t('dashboard.tripTitlePlaceholder')} className={inputCls} />
-
diff --git a/client/src/components/Trips/TripMembersModal.tsx b/client/src/components/Trips/TripMembersModal.tsx
index 4b40be0..47a6b54 100644
--- a/client/src/components/Trips/TripMembersModal.tsx
+++ b/client/src/components/Trips/TripMembersModal.tsx
@@ -253,7 +253,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
return (
-
+
{/* Left column: Members */}
@@ -323,7 +323,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
{allMembers.map(member => {
const isSelf = member.id === user?.id
- const canRemove = isSelf || (canManageMembers && (isCurrentOwner ? member.role !== 'owner' : false))
+ const canRemove = isSelf || (canManageMembers && member.role !== 'owner')
return (
{/* Actions */}
- {(!!trip.is_owner || isAdmin) && (
+ {(onEdit || onArchive || onDelete) && (
e.stopPropagation()}>
{onEdit &&
onEdit(trip)} icon={} label="" />}
{onArchive && onArchive(trip.id)} icon={} label="" />}
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx
index 5e48167..4de73c1 100644
--- a/client/src/pages/TripPlannerPage.tsx
+++ b/client/src/pages/TripPlannerPage.tsx
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react'
import ReactDOM from 'react-dom'
import { useParams, useNavigate } from 'react-router-dom'
import { useTripStore } from '../store/tripStore'
+import { useCanDo } from '../store/permissionsStore'
import { useSettingsStore } from '../store/settingsStore'
import { MapView } from '../components/Map/MapView'
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
@@ -38,6 +39,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
const { settings } = useSettingsStore()
const tripStore = useTripStore()
const { trip, days, places, assignments, packingItems, categories, reservations, budgetItems, files, selectedDayId, isLoading } = tripStore
+ const can = useCanDo()
+ const canUploadFiles = can('file_upload', trip)
const [enabledAddons, setEnabledAddons] = useState>({ packing: true, budget: true, documents: true })
const [tripAccommodations, setTripAccommodations] = useState([])
@@ -584,7 +587,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
onAssignToDay={handleAssignToDay}
onRemoveAssignment={handleRemoveAssignment}
files={files}
- onFileUpload={(fd) => tripStore.addFile(tripId, fd)}
+ onFileUpload={canUploadFiles ? (fd) => tripStore.addFile(tripId, fd) : undefined}
tripMembers={tripMembers}
onSetParticipants={async (assignmentId, dayId, userIds) => {
try {
@@ -688,7 +691,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
{ setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingAssignmentId ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripStore.addCategory?.(cat)} />
setShowTripForm(false)} onSave={async (data) => { await tripStore.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
- { setShowReservationModal(false); setEditingReservation(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={(fd) => tripStore.addFile(tripId, fd)} onFileDelete={(id) => tripStore.deleteFile(tripId, id)} accommodations={tripAccommodations} />
+ { setShowReservationModal(false); setEditingReservation(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripStore.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripStore.deleteFile(tripId, id)} accommodations={tripAccommodations} />
setDeletePlaceId(null)}