feat: add client-side permission gating to all write-action UIs
Gate all mutating UI elements with useCanDo() permission checks: - BudgetPanel (budget_edit), PackingListPanel (packing_edit) - DayPlanSidebar, DayDetailPanel (day_edit) - ReservationsPanel, ReservationModal (reservation_edit) - CollabNotes, CollabPolls, CollabChat (collab_edit) - FileManager (file_edit, file_delete, file_upload) - PlaceFormModal, PlaceInspector, PlacesSidebar (place_edit, file_upload) - TripFormModal (trip_edit, trip_cover_upload) - DashboardPage (trip_edit, trip_cover_upload, trip_delete, trip_archive) - TripMembersModal (member_manage, share_manage) Also: fix redundant getTripOwnerId queries in trips.ts, remove dead getTripOwnerId function, fix TripMembersModal grid when share hidden, fix canRemove logic, guard TripListItem empty actions div.
This commit is contained in:
@@ -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 (
|
||||
<div onClick={() => { setEditValue(value ?? ''); setEditing(true) }} title={editTooltip}
|
||||
style={{ cursor: 'pointer', padding: '4px 6px', borderRadius: 4, minHeight: 28, display: 'flex', alignItems: 'center',
|
||||
<div onClick={() => { 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 || '-'}
|
||||
</div>
|
||||
)
|
||||
@@ -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 => (
|
||||
<ChipWithTooltip key={m.user_id} label={m.username} avatarUrl={m.avatar_url} size={chipSize}
|
||||
paid={!!m.paid}
|
||||
onClick={onTogglePaid ? () => onTogglePaid(m.user_id, !m.paid) : undefined}
|
||||
onClick={!readOnly && onTogglePaid ? () => onTogglePaid(m.user_id, !m.paid) : undefined}
|
||||
/>
|
||||
))}
|
||||
<button ref={btnRef} onClick={openDropdown}
|
||||
style={{
|
||||
width: btnSize, height: btnSize, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
|
||||
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--text-faint)', padding: 0, flexShrink: 0,
|
||||
}}>
|
||||
{members.length > 0 ? <Pencil size={iconSize} /> : <Users size={iconSize} />}
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button ref={btnRef} onClick={openDropdown}
|
||||
style={{
|
||||
width: btnSize, height: btnSize, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
|
||||
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--text-faint)', padding: 0, flexShrink: 0,
|
||||
}}>
|
||||
{members.length > 0 ? <Pencil size={iconSize} /> : <Users size={iconSize} />}
|
||||
</button>
|
||||
)}
|
||||
{showDropdown && ReactDOM.createPortal(
|
||||
<div ref={dropRef} style={{
|
||||
position: 'fixed', top: dropPos.top, left: dropPos.left, transform: 'translateX(-50%)', zIndex: 10000,
|
||||
@@ -412,12 +416,14 @@ interface BudgetPanelProps {
|
||||
|
||||
export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) {
|
||||
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid } = useTripStore()
|
||||
const can = useCanDo()
|
||||
const { t, locale } = useTranslation()
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [editingCat, setEditingCat] = useState(null) // { name, value }
|
||||
const [settlement, setSettlement] = useState<{ balances: any[]; flows: any[] } | null>(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
|
||||
</div>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', margin: '0 0 8px' }}>{t('budget.emptyTitle')}</h2>
|
||||
<p style={{ fontSize: 14, color: 'var(--text-muted)', margin: '0 0 24px', lineHeight: 1.5 }}>{t('budget.emptyText')}</p>
|
||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', alignItems: 'stretch', maxWidth: 320, margin: '0 auto' }}>
|
||||
<input value={newCategoryName} onChange={e => 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 }} />
|
||||
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '0 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.5, flexShrink: 0 }}>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', alignItems: 'stretch', maxWidth: 320, margin: '0 auto' }}>
|
||||
<input value={newCategoryName} onChange={e => 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 }} />
|
||||
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '0 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.5, flexShrink: 0 }}>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -518,7 +526,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#000000', color: '#fff', borderRadius: '10px 10px 0 0', padding: '9px 14px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}>
|
||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 }} />
|
||||
{editingCat?.name === cat ? (
|
||||
{canEdit && editingCat?.name === cat ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={editingCat.value}
|
||||
@@ -530,21 +538,25 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
) : (
|
||||
<>
|
||||
<span style={{ fontWeight: 600, fontSize: 13 }}>{cat}</span>
|
||||
<button onClick={() => setEditingCat({ name: cat, value: cat })}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}>
|
||||
<Pencil size={10} />
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button onClick={() => setEditingCat({ name: cat, value: cat })}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}>
|
||||
<Pencil size={10} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
|
||||
<button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
|
||||
style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 4, color: '#fff', cursor: 'pointer', padding: '3px 6px', display: 'flex', alignItems: 'center', opacity: 0.6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.6'}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
|
||||
style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 4, color: '#fff', cursor: 'pointer', padding: '3px 6px', display: 'flex', alignItems: 'center', opacity: 0.6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.6'}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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'}>
|
||||
<td style={td}>
|
||||
<InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} />
|
||||
<InlineEditCell value={item.name} onSave={v => 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 && (
|
||||
<div className="sm:hidden" style={{ marginTop: 4 }}>
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ ...td, textAlign: 'center' }}>
|
||||
<InlineEditCell value={item.total_price} type="number" decimals={currencyDecimals(currency)} onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} />
|
||||
<InlineEditCell value={item.total_price} type="number" decimals={currencyDecimals(currency)} onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center', position: 'relative' }}>
|
||||
{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}
|
||||
/>
|
||||
) : (
|
||||
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
|
||||
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||
)}
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}>
|
||||
<InlineEditCell value={item.days} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'days', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
|
||||
<InlineEditCell value={item.days} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'days', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
|
||||
</td>
|
||||
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pp != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pp != null ? fmt(pp, currency) : '-'}</td>
|
||||
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pd != null ? fmt(pd, currency) : '-'}</td>
|
||||
<td className="hidden lg:table-cell" style={{ ...td, textAlign: 'center', color: ppd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{ppd != null ? fmt(ppd, currency) : '-'}</td>
|
||||
<td className="hidden sm:table-cell" style={td}><InlineEditCell value={item.note} onSave={v => handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} /></td>
|
||||
<td className="hidden sm:table-cell" style={td}><InlineEditCell value={item.note} onSave={v => handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /></td>
|
||||
<td style={{ ...td, textAlign: 'center' }}>
|
||||
{canEdit && (
|
||||
<button onClick={() => handleDeleteItem(item.id)} title={t('common.delete')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, color: 'var(--text-faint)', borderRadius: 4, display: 'inline-flex', transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#d1d5db'}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
<AddItemRow onAdd={data => handleAddItem(cat, data)} t={t} />
|
||||
{canEdit && <AddItemRow onAdd={data => handleAddItem(cat, data)} t={t} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -633,25 +649,27 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<CustomSelect
|
||||
value={currency}
|
||||
onChange={setCurrency}
|
||||
onChange={canEdit ? setCurrency : () => {}}
|
||||
options={CURRENCIES.map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
|
||||
searchable
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 12 }}>
|
||||
<input
|
||||
value={newCategoryName}
|
||||
onChange={e => 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)' }}
|
||||
/>
|
||||
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '9px 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.4, flexShrink: 0 }}>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 12 }}>
|
||||
<input
|
||||
value={newCategoryName}
|
||||
onChange={e => 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)' }}
|
||||
/>
|
||||
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '9px 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.4, flexShrink: 0 }}>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #000000 0%, #18181b 100%)',
|
||||
|
||||
@@ -3,6 +3,8 @@ import ReactDOM from 'react-dom'
|
||||
import { ArrowUp, Trash2, Reply, ChevronUp, MessageCircle, Smile, X } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
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'
|
||||
@@ -353,6 +355,9 @@ interface CollabChatProps {
|
||||
export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => 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) {
|
||||
>
|
||||
<Reply size={11} />
|
||||
</button>
|
||||
{own && (
|
||||
{own && canEdit && (
|
||||
<button onClick={() => handleDelete(msg.id)} title="Delete" style={{
|
||||
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
||||
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
@@ -735,7 +740,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
{msg.reactions.map(r => {
|
||||
const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id))
|
||||
return (
|
||||
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => handleReact(msg.id, r.emoji)} />
|
||||
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => canEdit && handleReact(msg.id, r.emoji)} />
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@@ -780,23 +785,27 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
|
||||
{/* Emoji button */}
|
||||
<button ref={emojiBtnRef} onClick={() => setShowEmoji(!showEmoji)} style={{
|
||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||
background: showEmoji ? 'var(--bg-hover)' : 'transparent',
|
||||
color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', padding: 0, flexShrink: 0, transition: 'background 0.15s',
|
||||
}}>
|
||||
<Smile size={20} />
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button ref={emojiBtnRef} onClick={() => setShowEmoji(!showEmoji)} style={{
|
||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||
background: showEmoji ? 'var(--bg-hover)' : 'transparent',
|
||||
color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', padding: 0, flexShrink: 0, transition: 'background 0.15s',
|
||||
}}>
|
||||
<Smile size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
disabled={!canEdit}
|
||||
style={{
|
||||
flex: 1, resize: 'none', border: '1px solid var(--border-primary)', borderRadius: 20,
|
||||
padding: '8px 14px', fontSize: 14, lineHeight: 1.4, fontFamily: 'inherit',
|
||||
background: 'var(--bg-input)', color: 'var(--text-primary)', outline: 'none',
|
||||
maxHeight: 100, overflowY: 'hidden',
|
||||
opacity: canEdit ? 1 : 0.5,
|
||||
}}
|
||||
placeholder={t('collab.chat.placeholder')}
|
||||
value={text}
|
||||
@@ -805,15 +814,17 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
/>
|
||||
|
||||
{/* Send */}
|
||||
<button onClick={handleSend} disabled={!text.trim() || sending} style={{
|
||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
|
||||
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
|
||||
transition: 'background 0.15s',
|
||||
}}>
|
||||
<ArrowUp size={18} strokeWidth={2.5} />
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button onClick={handleSend} disabled={!text.trim() || sending} style={{
|
||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
|
||||
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
|
||||
transition: 'background 0.15s',
|
||||
}}>
|
||||
<ArrowUp size={18} strokeWidth={2.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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
|
||||
</div>
|
||||
|
||||
{/* File attachments */}
|
||||
<div>
|
||||
{canUploadFiles && <div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4, fontFamily: FONT }}>
|
||||
{t('collab.notes.attachFiles')}
|
||||
</div>
|
||||
@@ -483,7 +489,7 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
|
||||
<Plus size={11} /> {t('files.attach') || 'Add'}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
@@ -689,6 +695,7 @@ function CategorySettingsModal({ onClose, categories, categoryColors, onSave, on
|
||||
interface NoteCardProps {
|
||||
note: CollabNote
|
||||
currentUser: User
|
||||
canEdit: boolean
|
||||
onUpdate: (noteId: number, data: Partial<CollabNote>) => Promise<void>
|
||||
onDelete: (noteId: number) => Promise<void>
|
||||
onEdit: (note: CollabNote) => void
|
||||
@@ -699,7 +706,7 @@ interface NoteCardProps {
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onView, onPreviewFile, getCategoryColor, tripId, t }: NoteCardProps) {
|
||||
function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdit, onView, 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) }
|
||||
@@ -760,24 +767,24 @@ function NoteCard({ note, currentUser, onUpdate, onDelete, onEdit, onView, onPre
|
||||
<Maximize2 size={10} />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleTogglePin} title={note.pinned ? t('collab.notes.unpin') : t('collab.notes.pin')}
|
||||
{canEdit && <button onClick={handleTogglePin} title={note.pinned ? t('collab.notes.unpin') : t('collab.notes.pin')}
|
||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = color}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
{note.pinned ? <PinOff size={10} /> : <Pin size={10} />}
|
||||
</button>
|
||||
<button onClick={() => onEdit?.(note)} title={t('collab.notes.edit')}
|
||||
</button>}
|
||||
{canEdit && <button onClick={() => onEdit?.(note)} title={t('collab.notes.edit')}
|
||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={10} />
|
||||
</button>
|
||||
<button onClick={handleDelete} title={t('collab.notes.delete')}
|
||||
</button>}
|
||||
{canEdit && <button onClick={handleDelete} title={t('collab.notes.delete')}
|
||||
style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
</button>}
|
||||
<div style={{ width: 1, height: 12, background: 'var(--border-faint)', flexShrink: 0, marginLeft: 1, marginRight: 1 }} />
|
||||
{/* Author avatar */}
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}
|
||||
@@ -879,6 +886,9 @@ interface CollabNotesProps {
|
||||
|
||||
export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
const { t } = useTranslation()
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => 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)'}>
|
||||
<Settings size={14} />
|
||||
</button>
|
||||
<button onClick={() => setShowNewModal(true)}
|
||||
{canEdit && <button onClick={() => 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' }}>
|
||||
<Plus size={12} />
|
||||
{t('collab.notes.new')}
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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) {
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
|
||||
<button onClick={() => { setViewingNote(null); setEditingNote(viewingNote) }}
|
||||
{canEdit && <button onClick={() => { 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)'}>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</button>}
|
||||
<button onClick={() => 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)'}
|
||||
|
||||
@@ -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<void>
|
||||
onClose: (pollId: number) => Promise<void>
|
||||
onDelete: (pollId: number) => Promise<void>
|
||||
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
|
||||
</div>
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
||||
{!isClosed && (
|
||||
<button onClick={() => onClose(poll.id)} title={t('collab.polls.close')}
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
||||
{!isClosed && (
|
||||
<button onClick={() => 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)'}>
|
||||
<Lock size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => 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)'}>
|
||||
<Lock size={12} />
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => 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)'}>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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 (
|
||||
<button key={idx} onClick={() => !isClosed && onVote(poll.id, idx)}
|
||||
disabled={isClosed}
|
||||
<button key={idx} onClick={() => !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) {
|
||||
<BarChart3 size={14} color="var(--text-faint)" />
|
||||
{t('collab.polls.title')}
|
||||
</h3>
|
||||
<button onClick={() => 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',
|
||||
}}>
|
||||
<Plus size={12} /> {t('collab.polls.new')}
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button onClick={() => 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',
|
||||
}}>
|
||||
<Plus size={12} /> {t('collab.polls.new')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@@ -446,7 +456,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{activePolls.length > 0 && activePolls.map(poll => (
|
||||
<PollCard key={poll.id} poll={poll} currentUser={currentUser} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
|
||||
<PollCard key={poll.id} poll={poll} currentUser={currentUser} canEdit={canEdit} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
|
||||
))}
|
||||
{closedPolls.length > 0 && (
|
||||
<>
|
||||
@@ -456,7 +466,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
</div>
|
||||
)}
|
||||
{closedPolls.map(poll => (
|
||||
<PollCard key={poll.id} poll={poll} currentUser={currentUser} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
|
||||
<PollCard key={poll.id} poll={poll} currentUser={currentUser} canEdit={canEdit} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
<div className="file-actions" style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
||||
{isTrash ? (
|
||||
<>
|
||||
<button onClick={() => 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) && <button onClick={() => 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)'}>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
<button onClick={() => handlePermanentDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||
</button>}
|
||||
{can('file_delete', trip) && <button onClick={() => 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)'}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</button>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -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)' }}>
|
||||
<Star size={14} fill={file.starred ? '#facc15' : 'none'} />
|
||||
</button>
|
||||
<button onClick={() => 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) && <button onClick={() => 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)'}>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
</button>}
|
||||
<button onClick={() => 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)'}>
|
||||
<ExternalLink size={14} />
|
||||
</button>
|
||||
<button onClick={() => 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) && <button onClick={() => 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)'}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</button>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -685,7 +686,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
{showTrash ? (
|
||||
/* Trash view */
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
|
||||
{trashFiles.length > 0 && (
|
||||
{trashFiles.length > 0 && can('file_delete', trip) && (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
|
||||
<button onClick={handleEmptyTrash} style={{
|
||||
padding: '5px 12px', borderRadius: 8, border: '1px solid #fecaca',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useMemo, useRef, useEffect } from 'react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { packingApi, tripsApi, adminApi } from '../../api/client'
|
||||
@@ -77,9 +78,10 @@ interface ArtikelZeileProps {
|
||||
bagTrackingEnabled?: boolean
|
||||
bags?: PackingBag[]
|
||||
onCreateBag: (name: string) => Promise<PackingBag | undefined>
|
||||
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 ? <CheckSquare size={18} /> : <Square size={18} />}
|
||||
</button>
|
||||
|
||||
{editing ? (
|
||||
{editing && canEdit ? (
|
||||
<input
|
||||
type="text" value={editName} autoFocus
|
||||
onChange={e => setEditName(e.target.value)}
|
||||
@@ -140,10 +142,10 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onClick={() => !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
|
||||
<input
|
||||
type="text" inputMode="numeric"
|
||||
value={item.weight_grams ?? ''}
|
||||
readOnly={!canEdit}
|
||||
onChange={async e => {
|
||||
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
|
||||
</div>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => 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
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<div className="sm:opacity-0 sm:group-hover:opacity-100" style={{ display: 'flex', gap: 2, alignItems: 'center', transition: 'opacity 0.12s', flexShrink: 0 }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
@@ -287,6 +292,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -319,9 +325,10 @@ interface KategorieGruppeProps {
|
||||
bagTrackingEnabled?: boolean
|
||||
bags?: PackingBag[]
|
||||
onCreateBag: (name: string) => Promise<PackingBag | undefined>
|
||||
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
|
||||
|
||||
<span style={{ width: 10, height: 10, borderRadius: '50%', background: dot, flexShrink: 0 }} />
|
||||
|
||||
{editingName ? (
|
||||
{editingName && canEdit ? (
|
||||
<input
|
||||
autoFocus value={editKatName}
|
||||
onChange={e => setEditKatName(e.target.value)}
|
||||
@@ -398,11 +405,11 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, flex: 1, minWidth: 0, marginLeft: 4 }}>
|
||||
{assignees.map(a => (
|
||||
<div key={a.user_id} style={{ position: 'relative' }}
|
||||
onClick={e => { 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)) }}
|
||||
>
|
||||
<div className="assignee-chip"
|
||||
style={{
|
||||
width: 22, height: 22, borderRadius: '50%', flexShrink: 0, cursor: 'pointer',
|
||||
width: 22, height: 22, borderRadius: '50%', flexShrink: 0, cursor: canEdit ? 'pointer' : 'default',
|
||||
background: `hsl(${a.username.charCodeAt(0) * 37 % 360}, 55%, 55%)`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 10, fontWeight: 700, color: 'white', textTransform: 'uppercase',
|
||||
@@ -422,6 +429,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{canEdit && (
|
||||
<div ref={assigneeDropdownRef} style={{ position: 'relative' }}>
|
||||
<button onClick={e => { e.stopPropagation(); setShowAssigneeDropdown(v => !v) }}
|
||||
style={{
|
||||
@@ -479,6 +487,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span style={{
|
||||
@@ -497,11 +506,13 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
{showMenu && (
|
||||
<div style={{ position: 'absolute', right: 0, top: '100%', zIndex: 50, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)', padding: 4, minWidth: 170 }}
|
||||
onMouseLeave={() => setShowMenu(false)}>
|
||||
<MenuItem icon={<Pencil size={13} />} label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />
|
||||
{canEdit && <MenuItem icon={<Pencil size={13} />} label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />}
|
||||
<MenuItem icon={<CheckCheck size={13} />} label={t('packing.menuCheckAll')} onClick={() => { handleCheckAll(); setShowMenu(false) }} />
|
||||
<MenuItem icon={<RotateCcw size={13} />} label={t('packing.menuUncheckAll')} onClick={() => { handleUncheckAll(); setShowMenu(false) }} />
|
||||
{canEdit && <>
|
||||
<div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} />
|
||||
<MenuItem icon={<Trash2 size={13} />} label={t('packing.menuDeleteCat')} danger onClick={handleDeleteAll} />
|
||||
</>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -510,10 +521,10 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
{offen && (
|
||||
<div style={{ padding: '4px 4px 6px' }}>
|
||||
{items.map(item => (
|
||||
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} />
|
||||
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} />
|
||||
))}
|
||||
{/* Inline add item */}
|
||||
{showAddItem ? (
|
||||
{canEdit && (showAddItem ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px' }}>
|
||||
<input
|
||||
ref={addItemRef}
|
||||
@@ -548,7 +559,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Plus size={12} /> {t('packing.addItem')}
|
||||
</button>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -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
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{abgehakt > 0 && (
|
||||
{canEdit && abgehakt > 0 && (
|
||||
<button onClick={handleClearChecked} style={{
|
||||
fontSize: 11.5, padding: '5px 10px', borderRadius: 99, border: '1px solid rgba(239,68,68,0.3)',
|
||||
background: 'rgba(239,68,68,0.1)', color: '#ef4444', cursor: 'pointer', fontFamily: 'inherit',
|
||||
@@ -823,6 +837,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
<span className="sm:hidden">{t('packing.clearCheckedShort', { count: abgehakt })}</span>
|
||||
</button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button onClick={() => setShowImportModal(true)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
||||
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer',
|
||||
@@ -830,7 +845,8 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
}}>
|
||||
<Upload size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
|
||||
</button>
|
||||
{availableTemplates.length > 0 && (
|
||||
)}
|
||||
{canEdit && availableTemplates.length > 0 && (
|
||||
<div ref={templateDropdownRef} style={{ position: 'relative' }}>
|
||||
<button onClick={() => 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
|
||||
</div>
|
||||
)}
|
||||
|
||||
{addingCategory ? (
|
||||
{canEdit && (addingCategory ? (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<input
|
||||
autoFocus
|
||||
@@ -924,7 +940,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||
<FolderPlus size={14} /> {t('packing.addCategory')}
|
||||
</button>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Filter-Tabs ── */}
|
||||
@@ -972,6 +988,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
bagTrackingEnabled={bagTrackingEnabled}
|
||||
bags={bags}
|
||||
onCreateBag={handleCreateBagByName}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -998,10 +1015,12 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontWeight: 500 }}>
|
||||
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
|
||||
</span>
|
||||
{canEdit && (
|
||||
<button onClick={() => handleDeleteBag(bag.id)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)', display: 'flex' }}>
|
||||
<X size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ height: 6, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', borderRadius: 99, background: bag.color, width: `${pct}%`, transition: 'width 0.3s' }} />
|
||||
@@ -1039,7 +1058,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
</div>
|
||||
|
||||
{/* Add bag */}
|
||||
{showAddBag ? (
|
||||
{canEdit && (showAddBag ? (
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 12 }}>
|
||||
<input autoFocus value={newBagName} onChange={e => 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%' }}>
|
||||
<Plus size={11} /> {t('packing.addBag')}
|
||||
</button>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1083,10 +1102,12 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
<span style={{ fontSize: 13, color: 'var(--text-muted)', fontWeight: 500 }}>
|
||||
{totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
|
||||
</span>
|
||||
{canEdit && (
|
||||
<button onClick={() => handleDeleteBag(bag.id)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)', display: 'flex' }}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ height: 8, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', borderRadius: 99, background: bag.color, width: `${pct}%`, transition: 'width 0.3s' }} />
|
||||
@@ -1124,7 +1145,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
</div>
|
||||
|
||||
{/* Add bag */}
|
||||
{showAddBag ? (
|
||||
{canEdit && (showAddBag ? (
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 14 }}>
|
||||
<input autoFocus value={newBagName} onChange={e => setNewBagName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }}
|
||||
@@ -1142,7 +1163,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||
<Plus size={14} /> {t('packing.addBag')}
|
||||
</button>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_name}</div>
|
||||
{acc.place_address && <div style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_address}</div>}
|
||||
</div>
|
||||
<button onClick={() => { 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 && <button onClick={() => { 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 }}>
|
||||
<Pencil size={12} style={{ color: 'var(--text-faint)' }} />
|
||||
</button>
|
||||
<button onClick={() => { setAccommodation(acc); handleRemoveAccommodation() }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
|
||||
</button>}
|
||||
{canEditDays && <button onClick={() => { setAccommodation(acc); handleRemoveAccommodation() }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
|
||||
<X size={12} style={{ color: 'var(--text-faint)' }} />
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
{/* Details grid */}
|
||||
<div style={{ display: 'flex', gap: 0, margin: '0 12px 8px', borderRadius: 10, overflow: 'hidden', border: '1px solid var(--border-faint)' }}>
|
||||
@@ -394,22 +399,22 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
)
|
||||
})}
|
||||
{/* Add another hotel */}
|
||||
<button onClick={() => setShowHotelPicker(true)} style={{
|
||||
{canEditDays && <button onClick={() => 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',
|
||||
}}>
|
||||
<Hotel size={10} /> {t('day.addAccommodation')}
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setShowHotelPicker(true)} style={{
|
||||
canEditDays ? <button onClick={() => 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',
|
||||
}}>
|
||||
<Hotel size={12} /> {t('day.addAccommodation')}
|
||||
</button>
|
||||
</button> : null
|
||||
)}
|
||||
|
||||
{/* Hotel Picker Popup — portal to body to escape transform stacking context */}
|
||||
|
||||
@@ -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({
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 1, minWidth: 0 }}>
|
||||
{day.title || t('dayplan.dayN', { n: index + 1 })}
|
||||
</span>
|
||||
<button
|
||||
{canEditDays && <button
|
||||
onClick={e => startEditTitle(day, e)}
|
||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: '4px', cursor: 'pointer', opacity: 0.35, display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<Pencil size={15} strokeWidth={1.8} color="var(--text-secondary)" />
|
||||
</button>
|
||||
</button>}
|
||||
{(() => {
|
||||
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({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
{canEditDays && <button
|
||||
onClick={e => 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)'}
|
||||
>
|
||||
<FileText size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</button>}
|
||||
<button
|
||||
onClick={e => 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({
|
||||
<React.Fragment key={`place-${assignment.id}`}>
|
||||
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
|
||||
<div
|
||||
draggable
|
||||
draggable={canEditDays}
|
||||
onDragStart={e => {
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
{canEditDays && <div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
<GripVertical size={13} strokeWidth={1.8} />
|
||||
</div>
|
||||
</div>}
|
||||
<div
|
||||
onClick={e => { e.stopPropagation(); toggleLock(assignment.id) }}
|
||||
onMouseEnter={e => { e.stopPropagation(); setLockHoverId(assignment.id) }}
|
||||
@@ -1167,14 +1171,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
||||
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={moveUp} disabled={idx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: idx === 0 ? 'default' : 'pointer', color: idx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
||||
<ChevronUp size={12} strokeWidth={2} />
|
||||
</button>
|
||||
<button onClick={moveDown} disabled={idx === merged.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: idx === merged.length - 1 ? 'default' : 'pointer', color: idx === merged.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
||||
<ChevronDown size={12} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
@@ -1273,8 +1277,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
<React.Fragment key={`note-${note.id}`}>
|
||||
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
|
||||
<div
|
||||
draggable
|
||||
onDragStart={e => { 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',
|
||||
}}
|
||||
>
|
||||
<div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isNoteHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
{canEditDays && <div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isNoteHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
<GripVertical size={13} strokeWidth={1.8} />
|
||||
</div>
|
||||
</div>}
|
||||
<div style={{ width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', background: 'var(--bg-hover)', overflow: 'hidden' }}>
|
||||
<NoteIcon size={13} strokeWidth={1.8} color="var(--text-muted)" />
|
||||
</div>
|
||||
@@ -1330,14 +1334,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
<div style={{ fontSize: 10.5, fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.3', marginTop: 2, wordBreak: 'break-word' }}>{note.time}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: isNoteHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
|
||||
{canEditDays && <div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: isNoteHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={e => openEditNote(day.id, note, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Pencil size={10} /></button>
|
||||
<button onClick={e => deleteNote(day.id, note.id, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Trash2 size={10} /></button>
|
||||
</div>
|
||||
<div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isNoteHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
||||
</div>}
|
||||
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isNoteHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={e => { 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 }}><ChevronUp size={12} strokeWidth={2} /></button>
|
||||
<button onClick={e => { 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 }}><ChevronDown size={12} strokeWidth={2} /></button>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
|
||||
{/* File Attachments */}
|
||||
{true && (
|
||||
{canUploadFiles && (
|
||||
<div className="border border-gray-200 rounded-xl p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-gray-700">{t('files.title')}</label>
|
||||
|
||||
@@ -122,7 +122,7 @@ interface PlaceInspectorProps {
|
||||
onAssignToDay: (placeId: number, dayId: number) => void
|
||||
onRemoveAssignment: (assignmentId: number, dayId: number) => void
|
||||
files: TripFile[]
|
||||
onFileUpload: (fd: FormData) => Promise<void>
|
||||
onFileUpload?: (fd: FormData) => Promise<void>
|
||||
tripMembers?: TripMember[]
|
||||
onSetParticipants: (assignmentId: number, dayId: number, participantIds: number[]) => void
|
||||
onUpdatePlace: (placeId: number, data: Partial<Place>) => void
|
||||
|
||||
@@ -229,9 +229,9 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}>
|
||||
{filter === 'unplanned' ? t('places.allPlanned') : t('places.noneFound')}
|
||||
</span>
|
||||
<button onClick={onAddPlace} style={{ fontSize: 12, color: 'var(--text-primary)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline', fontFamily: 'inherit' }}>
|
||||
{canEditPlaces && <button onClick={onAddPlace} style={{ fontSize: 12, color: 'var(--text-primary)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline', fontFamily: 'inherit' }}>
|
||||
{t('places.addPlace')}
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map(place => {
|
||||
|
||||
@@ -59,7 +59,7 @@ interface ReservationModalProps {
|
||||
assignments: AssignmentsMap
|
||||
selectedDayId: number | null
|
||||
files?: TripFile[]
|
||||
onFileUpload: (fd: FormData) => Promise<void>
|
||||
onFileUpload?: (fd: FormData) => Promise<void>
|
||||
onFileDelete: (fileId: number) => Promise<void>
|
||||
accommodations?: Accommodation[]
|
||||
}
|
||||
@@ -504,14 +504,14 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
))}
|
||||
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
|
||||
{onFileUpload && <button type="button" onClick={() => 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',
|
||||
}}>
|
||||
<Paperclip size={11} />
|
||||
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
|
||||
</button>
|
||||
</button>}
|
||||
{/* Link existing file picker */}
|
||||
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
|
||||
<div style={{ position: 'relative' }}>
|
||||
|
||||
@@ -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<number, AssignmentLookupEntry>
|
||||
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 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)' }}>
|
||||
<div style={{ width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: confirmed ? '#16a34a' : '#d97706' }} />
|
||||
<button onClick={handleToggle} style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706', background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit' }}>
|
||||
{confirmed ? t('reservations.confirmed') : t('reservations.pending')}
|
||||
</button>
|
||||
{canEdit ? (
|
||||
<button onClick={handleToggle} style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706', background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit' }}>
|
||||
{confirmed ? t('reservations.confirmed') : t('reservations.pending')}
|
||||
</button>
|
||||
) : (
|
||||
<span style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706', padding: 0 }}>
|
||||
{confirmed ? t('reservations.confirmed') : t('reservations.pending')}
|
||||
</span>
|
||||
)}
|
||||
<div style={{ width: 1, height: 10, background: 'var(--border-faint)' }} />
|
||||
<TypeIcon size={11} style={{ color: typeInfo.color, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{t(typeInfo.labelKey)}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
|
||||
<button onClick={() => onEdit(r)} title={t('common.edit')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
<button onClick={() => 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)'}>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button onClick={() => onEdit(r)} title={t('common.edit')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button onClick={() => 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)'}>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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 })}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onAdd} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 99,
|
||||
border: 'none', background: 'var(--accent)', color: 'var(--accent-text)',
|
||||
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Plus size={13} /> <span className="hidden sm:inline">{t('reservations.addManual')}</span>
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button onClick={onAdd} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 99,
|
||||
border: 'none', background: 'var(--accent)', color: 'var(--accent-text)',
|
||||
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Plus size={13} /> <span className="hidden sm:inline">{t('reservations.addManual')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@@ -370,14 +387,14 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
||||
{allPending.length > 0 && (
|
||||
<Section title={t('reservations.pending')} count={allPending.length} accent="gray">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} />)}
|
||||
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
{allConfirmed.length > 0 && (
|
||||
<Section title={t('reservations.confirmed')} count={allConfirmed.length} accent="green">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} />)}
|
||||
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Cover image — available for both create and edit */}
|
||||
<div>
|
||||
{/* Cover image — gated by trip_cover_upload permission */}
|
||||
{canUploadCover && <div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('dashboard.coverImage')}</label>
|
||||
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleCoverChange} />
|
||||
{coverPreview ? (
|
||||
@@ -260,20 +265,20 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
<Camera size={15} /> {uploadingCover ? t('common.uploading') : t('dashboard.addCoverImage')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
{t('dashboard.tripTitle')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" value={formData.title} onChange={e => update('title', e.target.value)}
|
||||
required placeholder={t('dashboard.tripTitlePlaceholder')} className={inputCls} />
|
||||
<input type="text" value={formData.title} onChange={e => canEditTrip && update('title', e.target.value)}
|
||||
required readOnly={!canEditTrip} placeholder={t('dashboard.tripTitlePlaceholder')} className={inputCls} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('dashboard.tripDescription')}</label>
|
||||
<textarea value={formData.description} onChange={e => update('description', e.target.value)}
|
||||
placeholder={t('dashboard.tripDescriptionPlaceholder')} rows={3}
|
||||
<textarea value={formData.description} onChange={e => canEditTrip && update('description', e.target.value)}
|
||||
readOnly={!canEditTrip} placeholder={t('dashboard.tripDescriptionPlaceholder')} rows={3}
|
||||
className={`${inputCls} resize-none`} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -253,7 +253,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="3xl">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} className="share-modal-grid">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: canManageShare ? '1fr 1fr' : '1fr', gap: 24, fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} className="share-modal-grid">
|
||||
<style>{`@media (max-width: 640px) { .share-modal-grid { grid-template-columns: 1fr !important; } }`}</style>
|
||||
|
||||
{/* Left column: Members */}
|
||||
@@ -323,7 +323,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{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 (
|
||||
<div key={member.id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
|
||||
@@ -410,7 +410,7 @@ function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale, i
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{(!!trip.is_owner || isAdmin) && (
|
||||
{(onEdit || onArchive || onDelete) && (
|
||||
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
||||
{onEdit && <CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label="" />}
|
||||
{onArchive && <CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label="" />}
|
||||
|
||||
@@ -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<Record<string, boolean>>({ packing: true, budget: true, documents: true })
|
||||
const [tripAccommodations, setTripAccommodations] = useState<Accommodation[]>([])
|
||||
@@ -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 {
|
||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { 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)} />
|
||||
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripStore.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
|
||||
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
|
||||
<ReservationModal isOpen={showReservationModal} onClose={() => { 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} />
|
||||
<ReservationModal isOpen={showReservationModal} onClose={() => { 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} />
|
||||
<ConfirmDialog
|
||||
isOpen={!!deletePlaceId}
|
||||
onClose={() => setDeletePlaceId(null)}
|
||||
|
||||
Reference in New Issue
Block a user