@@ -524,6 +524,94 @@ function RichTextEditor({ value, onChange, onImgExpand, placeholder, minHeight =
}
// ── Sélecteur de type de notification ─────────────────────────────────────
// ── TypeDropdown — liste déroulante custom avec icônes ─────────────────────
function TypeDropdown ( { value , onChange } ) {
const [ open , setOpen ] = useState ( false ) ;
const [ pos , setPos ] = useState ( { top : 0 , left : 0 , width : 0 } ) ;
const triggerRef = useRef ( null ) ;
const dropRef = useRef ( null ) ;
const meta = TYPE _META [ value ] ? ? TYPE _META . info ;
useEffect ( ( ) => {
if ( ! open ) return ;
const h = e => {
if ( triggerRef . current ? . contains ( e . target ) || dropRef . current ? . contains ( e . target ) ) return ;
setOpen ( false ) ;
} ;
document . addEventListener ( 'mousedown' , h ) ;
return ( ) => document . removeEventListener ( 'mousedown' , h ) ;
} , [ open ] ) ;
const openDrop = ( ) => {
if ( triggerRef . current ) {
const r = triggerRef . current . getBoundingClientRect ( ) ;
setPos ( { top : r . bottom + 4 , left : r . left , width : r . width } ) ;
}
setOpen ( o => ! o ) ;
} ;
return (
< div ref = { triggerRef } style = { { position : 'relative' } } >
< button
type = "button"
onClick = { openDrop }
style = { {
display : 'flex' , alignItems : 'center' , gap : 10 , width : '100%' ,
padding : '8px 12px' , borderRadius : 8 , cursor : 'pointer' , fontSize : 13 ,
border : ` 2px solid ${ meta . color } ` , background : meta . bg , color : meta . color ,
fontWeight : 600 ,
} }
>
< NotifTypeAvatar type = { value } size = { 20 } / >
< span style = { { flex : 1 , textAlign : 'left' } } > { meta . label } < / span >
< svg width = "11" height = "11" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2.5" >
{ open ? < polyline points = "18 15 12 9 6 15" / > : < polyline points = "6 9 12 15 18 9" / > }
< / svg >
< / button >
{ open && createPortal (
< div
ref = { dropRef }
style = { {
position : 'fixed' , top : pos . top , left : pos . left , width : pos . width ,
zIndex : 9999 , background : 'var(--surface)' , border : '1px solid var(--border)' ,
borderRadius : 8 , boxShadow : '0 4px 20px rgba(0,0,0,0.15)' , padding : '4px 0' ,
} }
>
{ Object . entries ( TYPE _META ) . map ( ( [ k , m ] ) => (
< div
key = { k }
onClick = { ( ) => { onChange ( k ) ; setOpen ( false ) ; } }
style = { {
display : 'flex' , alignItems : 'center' , gap : 10 ,
padding : '8px 12px' , cursor : 'pointer' , fontSize : 13 ,
color : k === value ? m . color : 'var(--text)' ,
background : k === value ? m . bg : 'transparent' ,
fontWeight : k === value ? 600 : 400 ,
} }
onMouseEnter = { e => { if ( k !== value ) e . currentTarget . style . background = 'var(--surface-2)' ; } }
onMouseLeave = { e => { if ( k !== value ) e . currentTarget . style . background = 'transparent' ; } }
>
< span style = { {
display : 'flex' , alignItems : 'center' , justifyContent : 'center' ,
width : 28 , height : 28 , borderRadius : '50%' , background : m . bg , color : m . color , flexShrink : 0 ,
} } >
< NotifTypeAvatar type = { k } size = { 16 } / >
< / span >
< span > { m . label } < / span >
{ k === value && (
< svg width = "12" height = "12" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2.5" style = { { marginLeft : 'auto' , color : m . color } } >
< polyline points = "20 6 9 17 4 12" / >
< / svg >
) }
< / div >
) ) }
< / div > ,
document . body
) }
< / div >
) ;
}
function TypeSelector ( { value , onChange } ) {
return (
< div style = { { display : 'flex' , gap : 8 , flexWrap : 'wrap' } } >
@@ -549,6 +637,176 @@ function TypeSelector({ value, onChange }) {
) ;
}
// ── RecipientDropdown — destinataire notification broadcast ─────────────────
function RecipientDropdown ( { value , onChange , users } ) {
const [ open , setOpen ] = useState ( false ) ;
const [ search , setSearch ] = useState ( '' ) ;
const [ pos , setPos ] = useState ( { top : 0 , left : 0 , width : 0 } ) ;
const triggerRef = useRef ( null ) ;
const dropRef = useRef ( null ) ;
const searchRef = useRef ( null ) ;
useEffect ( ( ) => {
if ( ! open ) return ;
setTimeout ( ( ) => searchRef . current ? . focus ( ) , 30 ) ;
const h = e => {
if ( triggerRef . current ? . contains ( e . target ) || dropRef . current ? . contains ( e . target ) ) return ;
setOpen ( false ) ;
} ;
document . addEventListener ( 'mousedown' , h ) ;
return ( ) => document . removeEventListener ( 'mousedown' , h ) ;
} , [ open ] ) ;
const openDrop = ( ) => {
if ( triggerRef . current ) {
const r = triggerRef . current . getBoundingClientRect ( ) ;
setPos ( { top : r . bottom + 4 , left : r . left , width : r . width } ) ;
}
setSearch ( '' ) ;
setOpen ( o => ! o ) ;
} ;
const select = ( v ) => { onChange ( v ) ; setOpen ( false ) ; } ;
// Label affiché dans le bouton
const SPECIAL = [
{ value : '' , label : 'Tous les utilisateurs' , icon : (
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" > < circle cx = "9" cy = "7" r = "4" / > < path d = "M3 21v-2a4 4 0 0 1 4-4h4" / > < circle cx = "17" cy = "9" r = "4" opacity = ".5" / > < path d = "M21 21v-2a4 4 0 0 0-4-4h-1" opacity = ".5" / > < / svg >
) } ,
{ value : 'admins' , label : 'Tous les administrateurs' , icon : (
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" > < path d = "M12 2l3 3h4v4l3 3-3 3v4h-4l-3 3-3-3H5v-4L2 12l3-3V5h4z" / > < / svg >
) } ,
] ;
const specialMatch = SPECIAL . find ( s => s . value === value ) ;
const userMatch = users . find ( u => String ( u . id ) === String ( value ) ) ;
const triggerLabel = specialMatch ? . label ? ? userMatch ? . display _name ? ? userMatch ? . email ? ? 'Tous les utilisateurs' ;
const triggerIcon = specialMatch ? . icon ? ? null ;
// Filtrer users par recherche
const q = search . toLowerCase ( ) ;
const filteredUsers = q
? users . filter ( u => ( u . display _name ? ? '' ) . toLowerCase ( ) . includes ( q ) || u . email . toLowerCase ( ) . includes ( q ) )
: users ;
return (
< div ref = { triggerRef } >
< button
type = "button"
onClick = { openDrop }
style = { {
display : 'flex' , alignItems : 'center' , gap : 8 , width : '100%' ,
padding : '8px 12px' , borderRadius : 8 , cursor : 'pointer' , fontSize : 13 ,
border : '1px solid var(--border)' , background : 'var(--bg-input, var(--surface-2))' ,
color : 'var(--text)' ,
} }
>
{ triggerIcon && < span style = { { color : 'var(--text-muted)' , flexShrink : 0 } } > { triggerIcon } < / span > }
< span style = { { flex : 1 , textAlign : 'left' } } > { triggerLabel } < / span >
< svg width = "11" height = "11" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2.5" >
{ open ? < polyline points = "18 15 12 9 6 15" / > : < polyline points = "6 9 12 15 18 9" / > }
< / svg >
< / button >
{ open && createPortal (
< div
ref = { dropRef }
style = { {
position : 'fixed' , top : pos . top , left : pos . left , minWidth : pos . width ,
zIndex : 9999 , background : 'var(--surface)' , border : '1px solid var(--border)' ,
borderRadius : 8 , boxShadow : '0 4px 20px rgba(0,0,0,0.15)' , overflow : 'hidden' ,
} }
>
{ /* Champ recherche */ }
< div style = { { padding : '8px 10px' , borderBottom : '1px solid var(--border)' } } >
< input
ref = { searchRef }
value = { search }
onChange = { e => setSearch ( e . target . value ) }
placeholder = "Rechercher..."
style = { {
width : '100%' , padding : '5px 8px' , borderRadius : 6 , fontSize : 12 ,
border : '1px solid var(--border)' , background : 'var(--surface-2)' ,
color : 'var(--text)' , outline : 'none' , boxSizing : 'border-box' ,
} }
/ >
< / div >
{ /* Options spéciales (pas filtrées) */ }
< div style = { { maxHeight : 240 , overflowY : 'auto' } } >
{ SPECIAL . map ( s => (
< div
key = { s . value }
onClick = { ( ) => select ( s . value ) }
style = { {
display : 'flex' , alignItems : 'center' , gap : 8 ,
padding : '8px 12px' , cursor : 'pointer' , fontSize : 13 ,
background : value === s . value ? 'var(--primary-light, rgba(99,102,241,0.1))' : 'transparent' ,
color : value === s . value ? 'var(--primary)' : 'var(--text)' ,
fontWeight : value === s . value ? 600 : 400 ,
} }
onMouseEnter = { e => { if ( value !== s . value ) e . currentTarget . style . background = 'var(--surface-2)' ; } }
onMouseLeave = { e => { if ( value !== s . value ) e . currentTarget . style . background = 'transparent' ; } }
>
< span style = { { color : value === s . value ? 'var(--primary)' : 'var(--text-muted)' , flexShrink : 0 } } > { s . icon } < / span >
< span style = { { flex : 1 } } > { s . label } < / span >
{ value === s . value && (
< svg width = "12" height = "12" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2.5" style = { { color : 'var(--primary)' } } >
< polyline points = "20 6 9 17 4 12" / >
< / svg >
) }
< / div >
) ) }
{ /* Séparateur */ }
{ filteredUsers . length > 0 && (
< div style = { { padding : '4px 12px 2px' , fontSize : 10 , fontWeight : 600 , color : 'var(--text-muted)' , textTransform : 'uppercase' , letterSpacing : '0.06em' , borderTop : '1px solid var(--border)' , marginTop : 2 } } >
Utilisateurs individuels
< / div >
) }
{ filteredUsers . map ( u => {
const sel = String ( u . id ) === String ( value ) ;
const name = u . display _name ? ? u . email ;
return (
< div
key = { u . id }
onClick = { ( ) => select ( String ( u . id ) ) }
style = { {
display : 'flex' , alignItems : 'center' , gap : 8 ,
padding : '7px 12px' , cursor : 'pointer' , fontSize : 13 ,
background : sel ? 'var(--primary-light, rgba(99,102,241,0.1))' : 'transparent' ,
color : sel ? 'var(--primary)' : 'var(--text)' ,
fontWeight : sel ? 600 : 400 ,
} }
onMouseEnter = { e => { if ( ! sel ) e . currentTarget . style . background = 'var(--surface-2)' ; } }
onMouseLeave = { e => { if ( ! sel ) e . currentTarget . style . background = 'transparent' ; } }
>
< span style = { {
width : 26 , height : 26 , borderRadius : '50%' , background : 'var(--primary)' , color : '#fff' ,
display : 'flex' , alignItems : 'center' , justifyContent : 'center' , fontSize : 11 , fontWeight : 700 , flexShrink : 0 ,
} } >
{ name . charAt ( 0 ) . toUpperCase ( ) }
< / span >
< div style = { { flex : 1 , minWidth : 0 } } >
< div style = { { fontSize : 13 , fontWeight : sel ? 600 : 400 , whiteSpace : 'nowrap' , overflow : 'hidden' , textOverflow : 'ellipsis' } } > { name } < / div >
{ u . display _name && < div style = { { fontSize : 11 , color : 'var(--text-muted)' , whiteSpace : 'nowrap' , overflow : 'hidden' , textOverflow : 'ellipsis' } } > { u . email } < / div > }
< / div >
{ sel && (
< svg width = "12" height = "12" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2.5" style = { { color : 'var(--primary)' } } >
< polyline points = "20 6 9 17 4 12" / >
< / svg >
) }
< / div >
) ;
} ) }
{ filteredUsers . length === 0 && q && (
< div style = { { padding : '10px 12px' , fontSize : 12 , color : 'var(--text-muted)' , textAlign : 'center' } } > Aucun résultat < / div >
) }
< / div >
< / div > ,
document . body
) }
< / div >
) ;
}
// ══════════════════════════════════════════════════════════════════════════
// COMPOSANT PRINCIPAL
// ══════════════════════════════════════════════════════════════════════════
@@ -559,6 +817,9 @@ export default function Communication() {
const [ notifMenuPos , setNotifMenuPos ] = useState ( null ) ; // { x, y }
const [ supportMenuPos , setSupportMenuPos ] = useState ( null ) ; // { x, y }
const [ showAssignModal , setShowAssignModal ] = useState ( false ) ;
const [ showCategModal , setShowCategModal ] = useState ( false ) ;
const [ categType , setCategType ] = useState ( null ) ;
const [ categPages , setCategPages ] = useState ( [ ] ) ;
const [ assignSearch , setAssignSearch ] = useState ( '' ) ;
const [ tab , setTab ] = useState ( 'support' ) ; // 'support' | 'notifications'
@@ -575,7 +836,7 @@ export default function Communication() {
const [ tickets , setTickets ] = useState ( [ ] ) ;
const [ ticketSearch , setTicketSearch ] = useState ( '' ) ;
const [ ticketFilter , setTicketFilter ] = useState ( new Set ( [ 'open' , 'pending' ] ) ) ; // multi-sélection
const [ notifFilter , setNotifFilter ] = useState ( new Set ( [ 'read' , ' unread'] ) ) ; // multi-sélection
const [ notifFilter , setNotifFilter ] = useState ( new Set ( [ 'unread' ] ) ) ; // multi-sélection
const [ selectedTicket , setSelectedTicket ] = useState ( null ) ;
const [ thread , setThread ] = useState ( null ) ; // { ticket, messages }
const [ notifs , setNotifs ] = useState ( [ ] ) ;
@@ -627,8 +888,6 @@ export default function Communication() {
setNotifs ( list ) ;
setNotifsTotal ( data . total ? ? 0 ) ;
setNotifPage ( 0 ) ;
// Auto-sélectionner la première notif si aucune n'est sélectionnée
setSelectedNotif ( prev => prev ? ? ( list . length > 0 ? list [ 0 ] : null ) ) ;
} catch { /* silencieux */ }
} , [ ] ) ;
@@ -772,7 +1031,7 @@ export default function Communication() {
setBcResult ( null ) ;
try {
const payload = { type : bcType , title : bcTitle . trim ( ) , body : bcBody . trim ( ) || undefined } ;
if ( bcUserId ) payload . user _id = Number ( bcUserId ) ;
if ( bcUserId ) payload . user _id = bcUserId === 'admins' ? 'admins' : Number ( bcUserId ) ;
const data = await api . post ( '/notifications/broadcast' , payload ) ;
setBcResult ( { ok : true , msg : ` Envoyé à ${ data . sent } utilisateur ${ data . sent > 1 ? 's' : '' } ` } ) ;
setBcTitle ( '' ) ;
@@ -872,7 +1131,6 @@ export default function Communication() {
< div className = "comm-topbar-3" >
{ tab === 'support' && thread && (
< >
< span className = "comm-topbar-ticket-ref" > { thread . ticket . ticket _number } — { thread . ticket . subject } < / span >
< div style = { { display : 'flex' , gap : 6 , marginLeft : 'auto' , flexShrink : 0 , alignItems : 'center' } } >
< button
className = "btn-icon-sm"
@@ -941,7 +1199,6 @@ export default function Communication() {
className = { ` comm-nav-item ${ tab === 'notifications' ? ' active' : '' } ` }
onClick = { ( ) => {
setTab ( 'notifications' ) ;
if ( ! selectedNotif && notifs . length > 0 ) setSelectedNotif ( notifs [ 0 ] ) ;
} }
>
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" strokeLinecap = "round" strokeLinejoin = "round" >
@@ -1013,7 +1270,7 @@ export default function Communication() {
< / div >
) }
{ tickets
. filter ( t => ticketFilter . size === 0 || ticketFilter . has ( t . status ) )
. filter ( t => ticketFilter . size > 0 && ticketFilter . has ( t . status ) )
. filter ( t => {
if ( ! ticketSearch . trim ( ) ) return true ;
const q = ticketSearch . toLowerCase ( ) ;
@@ -1036,9 +1293,9 @@ export default function Communication() {
< StatusBadge status = { t . status } / >
< / div >
< div className = "comm-list-row-subject" > { t . ticket _number } — { t . subject } < / div >
< TicketChips ticketType = { t . ticket _type } ticketPages = { t . ticket _pages } / >
< TicketChips ticketType = { t . ticket _type } / >
{ t . last _body && (
< div className = "comm-list-row-preview" > { stripPreview ( t . last _body ) } < / div >
< div className = "comm-list-row-preview" style = { { visibility : 'hidden' } } > { stripPreview ( t . last _body ) } < / div >
) }
< div className = "comm-list-row-meta" >
< span > { t . message _count } message { t . message _count > 1 ? 's' : '' } < / span >
@@ -1053,7 +1310,9 @@ export default function Communication() {
{ tab === 'notifications' && ( ( ) => {
const NOTIF _PER _PAGE = 10 ;
const filteredNotifs = notifFilter . size === 2 || notifFilter . size === 0
const filteredNotifs = notifFilter . size === 0
? [ ]
: notifFilter . size === 2
? notifs
: notifFilter . has ( 'unread' ) ? notifs . filter ( n => ! n . read ) : notifs . filter ( n => n . read ) ;
const totalNotifs = filteredNotifs . length ;
@@ -1124,7 +1383,7 @@ export default function Communication() {
{ ( TYPE _META [ n . type ] ? ? TYPE _META . info ) . label }
< / span >
< / div >
{ n . body && < div className = "comm-list-row-preview" > { stripPreview ( n . body ) } < / div > }
< div className = "comm-list-row-preview" style = { { visibility : 'hidden' } } > { n . body ? stripPreview ( n . body ) : ' ' } < / div >
< div className = "comm-list-row-meta" >
< span > { timeAgo ( n . created _at ) } < / span >
< / div >
@@ -1159,19 +1418,44 @@ export default function Communication() {
{ /* Détail ticket */ }
{ tab === 'support' && thread && (
< >
< div className = "comm-detail-header" >
< div >
< div style = { { fontWeight : 700 , fontSize : 15 , marginBottom : 2 } } >
< div className = "comm-detail-header" style = { { flexDirection : 'column' , alignItems : 'stretch' , gap : 0 } } >
{ /* Ligne 1 : type chip + titre + badge statut */ }
< div style = { { display : 'flex' , alignItems : 'center' , gap : 8 , marginBottom : 2 , flexWrap : 'wrap' } } >
{ thread . ticket . ticket _type && ( ( ) => {
const type = TICKET _TYPES . find ( t => t . value === thread . ticket . ticket _type ) ;
return type ? (
< span className = "ticket-chip ticket-chip-type" style = { TYPE _COLORS [ thread . ticket . ticket _type ] || { } } >
{ type . label }
< / span >
) : null ;
} ) ( ) }
< span style = { { fontWeight : 700 , fontSize : 15 , flex : 1 , minWidth : 0 } } >
{ thread . ticket . ticket _number } — { thread . ticket . subject }
< / div >
< div style = { { fontSize : 12 , color : 'var(--text-muted)' } } >
Ouvert par { thread . ticket . user _name } · { fmtDate ( thread . ticket . created _at ) }
< / div >
< TicketChips ticketType = { thread . ticket . ticket _type } ticketPages = { thread . ticket . ticket _pages } / >
< / div >
< div style = { { display : 'flex' , gap : 8 , alignItems : 'center' } } >
< / span >
< StatusBadge status = { thread . ticket . status } / >
< / div >
{ /* Ligne 2 : meta gauche + chips pages droite */ }
{ ( ( ) => {
const pages = thread . ticket . ticket _pages
? ( Array . isArray ( thread . ticket . ticket _pages ) ? thread . ticket . ticket _pages : JSON . parse ( thread . ticket . ticket _pages ) )
: [ ] ;
return (
< div style = { { display : 'flex' , alignItems : 'center' , justifyContent : 'space-between' , marginTop : 25 } } >
< span style = { { fontSize : 12 , color : 'var(--text-muted)' } } >
Ouvert par < strong style = { { color : 'var(--text)' } } > { thread . ticket . user _name } < / strong > · { fmtDate ( thread . ticket . created _at ) }
{ thread . ticket . assigned _name && (
< > — assigné à < strong style = { { color : 'var(--text)' } } > { thread . ticket . assigned _name } < / strong > < / >
) }
< / span >
< div className = "ticket-chips" style = { { margin : 0 } } >
{ pages . map ( p => {
const page = TICKET _PAGES . find ( x => x . value === p ) ;
return page ? < span key = { p } className = "ticket-chip ticket-chip-page" > { page . label } < / span > : null ;
} ) }
< / div >
< / div >
) ;
} ) ( ) }
< / div >
< div className = "comm-thread" >
@@ -1181,7 +1465,7 @@ export default function Communication() {
< div className = "comm-message-content" >
< div className = "comm-message-meta" >
< span style = { { fontWeight : 600 , fontSize : 13 } } > { msg . author _name } < / span >
{ msg . is _admin && (
{ ! ! msg . is _admin && (
< span style = { { fontSize : 11 , color : 'var(--primary)' , fontWeight : 600 } } > Support < / span >
) }
< span style = { { fontSize : 12 , color : 'var(--text-muted)' , marginLeft : 'auto' , display : 'flex' , alignItems : 'center' , gap : 8 } } >
@@ -1376,58 +1660,7 @@ export default function Communication() {
) }
{ /* Bloc broadcast admin (onglet notifications) */ }
{ tab === 'notifications' && isAdmin && showBroadcastForm && (
< div className = "comm-broadcast-panel" >
< div className = "comm-broadcast-title" > Envoyer une notification < / div >
< form onSubmit = { submitBroadcast } className = "comm-broadcast-form" >
< div className = "comm-form-group" >
< label className = "comm-label" > Type < / label >
< TypeSelector value = { bcType } onChange = { setBcType } / >
< / div >
< div className = "comm-form-group" >
< label className = "comm-label" > Destinataire < / label >
< select
className = "comm-select"
value = { bcUserId }
onChange = { e => setBcUserId ( e . target . value ) }
>
< option value = "" > Tous les utilisateurs < / option >
{ users . map ( u => (
< option key = { u . id } value = { u . id } > { u . name } ( { u . email } ) < / option >
) ) }
< / select >
< / div >
< div className = "comm-form-group" >
< label className = "comm-label" > Titre * < / label >
< input
className = "comm-input"
value = { bcTitle }
onChange = { e => setBcTitle ( e . target . value ) }
placeholder = "Titre de la notification"
required
/ >
< / div >
< div className = "comm-form-group" >
< label className = "comm-label" > Message ( optionnel ) < / label >
< textarea
className = "comm-reply-textarea"
value = { bcBody }
onChange = { e => setBcBody ( e . target . value ) }
placeholder = "Corps du message…"
rows = { 3 }
/ >
< / div >
{ bcResult && (
< div style = { { fontSize : 13 , color : bcResult . ok ? 'var(--success)' : 'var(--danger)' , marginBottom : 8 } } >
{ bcResult . msg }
< / div >
) }
< button type = "submit" className = "btn btn-primary btn-sm" disabled = { bcSending || ! bcTitle . trim ( ) } >
{ bcSending ? 'Envoi…' : 'Envoyer' }
< / button >
< / form >
< / div >
) }
{ /* broadcast form moved to modal */ }
{ /* KPIs support */ }
{ tab === 'support' && ! thread && ( ( ) => {
@@ -1467,6 +1700,80 @@ export default function Communication() {
< / div > { /* end comm-wrap */ }
< / div > { /* end comm-page */ }
{ /* ── Modale Nouvelle notification (admin) ── */ }
{ showBroadcastForm && isAdmin && ( ( ) => {
const selectedMeta = TYPE _META [ bcType ] ? ? TYPE _META . info ;
return (
< div className = "comm-modal-backdrop" onClick = { ( ) => setShowBroadcastForm ( false ) } >
< div className = "comm-modal" style = { { maxWidth : 500 } } onClick = { e => e . stopPropagation ( ) } >
< div className = "comm-modal-header" >
< span style = { { fontWeight : 700 , fontSize : 15 } } > Nouvelle notification < / span >
< button className = "comm-modal-close" onClick = { ( ) => setShowBroadcastForm ( false ) } > × < / button >
< / div >
< form onSubmit = { submitBroadcast } >
< div style = { { padding : '16px 20px' , display : 'flex' , flexDirection : 'column' , gap : 14 } } >
{ /* Type — liste déroulante custom */ }
< div >
< div style = { { fontSize : 11 , fontWeight : 600 , color : 'var(--text-muted)' , textTransform : 'uppercase' , letterSpacing : '0.05em' , marginBottom : 6 } } > Type < / div >
< TypeDropdown value = { bcType } onChange = { setBcType } / >
< / div >
{ /* Destinataire — dropdown searchable */ }
< div >
< div style = { { fontSize : 11 , fontWeight : 600 , color : 'var(--text-muted)' , textTransform : 'uppercase' , letterSpacing : '0.05em' , marginBottom : 6 } } > Destinataire < / div >
< RecipientDropdown value = { bcUserId } onChange = { setBcUserId } users = { users } / >
< / div >
{ /* Titre */ }
< div >
< div style = { { fontSize : 11 , fontWeight : 600 , color : 'var(--text-muted)' , textTransform : 'uppercase' , letterSpacing : '0.05em' , marginBottom : 6 } } > Titre * < / div >
< input
className = "comm-input"
style = { { width : '100%' , boxSizing : 'border-box' } }
value = { bcTitle }
onChange = { e => setBcTitle ( e . target . value ) }
placeholder = "Titre de la notification"
autoFocus
required
/ >
< / div >
{ /* Message */ }
< div >
< div style = { { fontSize : 11 , fontWeight : 600 , color : 'var(--text-muted)' , textTransform : 'uppercase' , letterSpacing : '0.05em' , marginBottom : 6 } } > Message < span style = { { fontWeight : 400 , textTransform : 'none' } } > ( optionnel ) < / span > < / div >
< RichTextEditor
value = { bcBody }
onChange = { setBcBody }
onImgExpand = { ( url , name ) => setLightbox ( { url , name } ) }
placeholder = "Corps du message… (Ctrl+V pour coller une image)"
minHeight = { 120 }
/ >
< / div >
{ bcResult && (
< div style = { { fontSize : 13 , color : bcResult . ok ? 'var(--success)' : 'var(--danger)' } } >
{ bcResult . msg }
< / div >
) }
< / div >
< div style = { { display : 'flex' , justifyContent : 'flex-end' , gap : 8 , padding : '0 20px 16px' } } >
< button type = "button" className = "btn btn-ghost" onClick = { ( ) => setShowBroadcastForm ( false ) } > Annuler < / button >
< button
type = "submit"
className = "btn btn-primary"
disabled = { bcSending || ! bcTitle . trim ( ) }
style = { { background : selectedMeta . color , border : 'none' } }
>
{ bcSending ? 'Envoi…' : 'Envoyer' }
< / button >
< / div >
< / form >
< / div >
< / div >
) ;
} ) ( ) }
{ /* ── Menu ⋮ notifications ── */ }
{ notifMenuPos && (
< >
@@ -1540,6 +1847,23 @@ export default function Communication() {
< svg width = "13" height = "13" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" > < path d = "M12 5v14" / > < path d = "M5 12h14" / > < / svg >
Nouveau ticket
< / button >
{ thread && (
< button
style = { { display : 'flex' , alignItems : 'center' , gap : 8 , width : '100%' , padding : '8px 14px' , background : 'none' , border : 'none' , cursor : 'pointer' , fontSize : 'var(--fs-sm)' , color : 'var(--text)' , textAlign : 'left' } }
onMouseEnter = { e => e . currentTarget . style . background = 'var(--surface-2)' }
onMouseLeave = { e => e . currentTarget . style . background = 'none' }
onClick = { ( ) => {
setCategType ( thread . ticket . ticket _type ? ? null ) ;
const p = thread . ticket . ticket _pages ;
setCategPages ( p ? ( Array . isArray ( p ) ? p : JSON . parse ( p ) ) : [ ] ) ;
setShowCategModal ( true ) ;
setSupportMenuPos ( null ) ;
} }
>
< svg width = "13" height = "13" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" > < path d = "M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" / > < path d = "M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" / > < / svg >
Modifier la catégorisation
< / button >
) }
{ thread && isAdmin && (
< >
< button
@@ -1583,6 +1907,10 @@ export default function Communication() {
: < > < svg width = "13" height = "13" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" > < polyline points = "20 6 9 17 4 12" / > < / svg > Clôturer le ticket < / >
}
< / button >
< / >
) }
{ thread && (
< >
< div style = { { borderTop : '1px solid var(--border)' , margin : '4px 0' } } / >
< button
style = { { display : 'flex' , alignItems : 'center' , gap : 8 , width : '100%' , padding : '8px 14px' , background : 'none' , border : 'none' , cursor : 'pointer' , fontSize : 'var(--fs-sm)' , color : 'var(--text-muted)' , textAlign : 'left' } }
@@ -1660,6 +1988,57 @@ export default function Communication() {
</div>
)}
{/* ── Modale catégorisation ticket ── */}
{showCategModal && thread && (
<div className="comm-modal-backdrop" onClick={() => setShowCategModal(false)}>
<div className="comm-modal" style={{ maxWidth: 420 }} onClick={e => e.stopPropagation()}>
<div className="comm-modal-header">
<span style={{ fontWeight: 700, fontSize: 15 }}>Modifier la catégorisation</span>
<button className="comm-modal-close" onClick={() => setShowCategModal(false)}>× </button>
</div>
<div style={{ padding: ' 16 px 20 px 20 px ', display: ' flex ', flexDirection: ' column ', gap: 16 }}>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: ' var ( -- text - muted ) ', marginBottom: 6, textTransform: ' uppercase ', letterSpacing: ' 0.05 em ' }}>Type</div>
<TagSelect label="Type" options={TICKET_TYPES} value={categType} onChange={setCategType} />
</div>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: ' var ( -- text - muted ) ', marginBottom: 6, textTransform: ' uppercase ', letterSpacing: ' 0.05 em ' }}>Pages concernées</div>
<TagSelect label="Pages" options={TICKET_PAGES} value={categPages} onChange={setCategPages} multi />
</div>
<div style={{ display: ' flex ', justifyContent: ' flex - end ', gap: 8, marginTop: 4 }}>
<button className="btn btn-ghost" onClick={() => setShowCategModal(false)}>Annuler</button>
<button
className="btn btn-primary"
onClick={async () => {
try {
await api.patch(`/tickets/${thread.ticket.id}/categorize`, {
ticket_type: categType,
ticket_pages: categPages.length ? JSON.stringify(categPages) : null,
});
setThread(prev => ({
...prev,
ticket: {
...prev.ticket,
ticket_type: categType,
ticket_pages: categPages.length ? categPages : null,
},
}));
setTickets(prev => prev.map(t => t.id === thread.ticket.id
? { ...t, ticket_type: categType, ticket_pages: categPages.length ? JSON.stringify(categPages) : null }
: t
));
setShowCategModal(false);
} catch { /* silencieux */ }
}}
>
Enregistrer
</button>
</div>
</div>
</div>
</div>
)}
{/* ── Lightbox image ── */}
{lightbox && (
<div className="comm-lightbox" onClick={() => setLightbox(null)}>