49e1a796ed
Critical fixes: - XSS: escapeHtml() on all user-supplied text in email notifications - Budget PATCH: added mutex lock + availability validation (prevents corruption) - batchResolveNames: fixed wrong signature for budget request earmark names Dead code cleanup: - Deleted 8 unused PostComposition* files (replaced by PostDetail full page) Performance: - budget-helpers: single-fetch with computeFromEntries(), optional prefetch param - post-composition: parallelized text + thumbnail fetches with Promise.all Consistency: - PostDetail.jsx: native <select> → PortalSelect (matches all panels) - Finance.jsx: 11 hardcoded English table headers → t() with i18n keys - PostCalendar.jsx: day names, Month/Week labels → t() with i18n keys - App.jsx Suspense: "Loading..." → brand spinner (can't use i18n in fallback) - UploadZone: proper useRef pattern, no vanilla JS document.createElement - All file inputs: className="hidden" → absolute w-0 h-0 opacity-0 - ArtefactDetailPanel: removed campaign/project selects (inherited from post) - TranslationDetailPanel: removed brand/linked post selects (inherited from post) - ApproverMultiSelect: portal-based dropdown (fixes clipping in modals) - Thumbnail fix: post-composition constructs URL from filename (was undefined) - Upload fix: UploadZone with drag-and-drop for design + video artefacts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
124 lines
4.4 KiB
React
124 lines
4.4 KiB
React
import { useState, useRef, useEffect, useCallback } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { Check, ChevronDown, X } from 'lucide-react'
|
|
|
|
export default function ApproverMultiSelect({ users = [], selected = [], onChange }) {
|
|
const [open, setOpen] = useState(false)
|
|
const triggerRef = useRef(null)
|
|
const dropdownRef = useRef(null)
|
|
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
|
|
|
|
const updatePosition = useCallback(() => {
|
|
if (!triggerRef.current) return
|
|
const rect = triggerRef.current.getBoundingClientRect()
|
|
const spaceBelow = window.innerHeight - rect.bottom
|
|
const dropdownHeight = Math.min(users.length * 40 + 8, 220)
|
|
const showAbove = spaceBelow < dropdownHeight && rect.top > dropdownHeight
|
|
|
|
setPos({
|
|
top: showAbove ? rect.top - dropdownHeight - 4 : rect.bottom + 4,
|
|
left: rect.left,
|
|
width: rect.width,
|
|
})
|
|
}, [users.length])
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
const handleClick = (e) => {
|
|
if (triggerRef.current?.contains(e.target)) return
|
|
if (dropdownRef.current?.contains(e.target)) return
|
|
setOpen(false)
|
|
}
|
|
const handleEsc = (e) => { if (e.key === 'Escape') setOpen(false) }
|
|
const handleScroll = () => updatePosition()
|
|
|
|
document.addEventListener('mousedown', handleClick)
|
|
document.addEventListener('keydown', handleEsc)
|
|
window.addEventListener('scroll', handleScroll, true)
|
|
return () => {
|
|
document.removeEventListener('mousedown', handleClick)
|
|
document.removeEventListener('keydown', handleEsc)
|
|
window.removeEventListener('scroll', handleScroll, true)
|
|
}
|
|
}, [open, updatePosition])
|
|
|
|
const handleOpen = () => {
|
|
updatePosition()
|
|
setOpen(!open)
|
|
}
|
|
|
|
const toggle = (userId) => {
|
|
const id = String(userId)
|
|
const next = selected.includes(id) ? selected.filter(s => s !== id) : [...selected, id]
|
|
onChange(next)
|
|
}
|
|
|
|
const remove = (id) => {
|
|
onChange(selected.filter(s => s !== String(id)))
|
|
}
|
|
|
|
const selectedUsers = selected.map(id => users.find(u => String(u._id || u.id || u.Id) === String(id))).filter(Boolean)
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
ref={triggerRef}
|
|
onClick={handleOpen}
|
|
className={`w-full min-h-[38px] px-3 py-1.5 text-sm border rounded-lg bg-surface cursor-pointer flex items-center flex-wrap gap-1.5 transition-colors ${
|
|
open ? 'border-brand-primary ring-2 ring-brand-primary/20' : 'border-border'
|
|
}`}
|
|
>
|
|
{selectedUsers.length === 0 && (
|
|
<span className="text-text-tertiary">Select approvers...</span>
|
|
)}
|
|
{selectedUsers.map(u => (
|
|
<span
|
|
key={u._id || u.id || u.Id}
|
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs font-medium"
|
|
>
|
|
{u.name}
|
|
<button
|
|
type="button"
|
|
onClick={e => { e.stopPropagation(); remove(u._id || u.id || u.Id) }}
|
|
className="hover:text-amber-950 transition-colors"
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</button>
|
|
</span>
|
|
))}
|
|
<ChevronDown className={`w-4 h-4 text-text-tertiary ms-auto shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
|
|
</div>
|
|
|
|
{open && createPortal(
|
|
<div
|
|
ref={dropdownRef}
|
|
className="fixed z-[99999] bg-surface border border-border rounded-lg shadow-lg max-h-[220px] overflow-y-auto"
|
|
style={{ top: pos.top, left: pos.left, width: pos.width }}
|
|
>
|
|
{users.map(u => {
|
|
const uid = String(u._id || u.id || u.Id)
|
|
const isSelected = selected.includes(uid)
|
|
return (
|
|
<button
|
|
key={uid}
|
|
type="button"
|
|
onClick={() => toggle(uid)}
|
|
className={`w-full text-start px-3 py-2 text-sm hover:bg-surface-secondary flex items-center justify-between transition-colors ${
|
|
isSelected ? 'text-brand-primary font-medium' : 'text-text-primary'
|
|
}`}
|
|
>
|
|
<span>{u.name}</span>
|
|
{isSelected && <Check className="w-3.5 h-3.5" />}
|
|
</button>
|
|
)
|
|
})}
|
|
{users.length === 0 && (
|
|
<div className="px-3 py-4 text-sm text-text-tertiary text-center">No users available</div>
|
|
)}
|
|
</div>,
|
|
document.body
|
|
)}
|
|
</>
|
|
)
|
|
}
|