Compare commits

...

3 Commits

Author SHA1 Message Date
ocroguennec 52d2767297 Nouveu fix 2026-06-18 23:13:25 +02:00
ocroguennec e1a54fa86b Fix 2026-06-18 23:11:14 +02:00
ocroguennec 5f86950dba Fix 2026-06-18 22:42:10 +02:00
4 changed files with 476 additions and 78 deletions
+3 -1
View File
@@ -46,7 +46,9 @@ router.post('/broadcast', requireAdmin, (req, res) => {
);
let targets;
if (user_id) {
if (user_id === 'admins') {
targets = db.prepare("SELECT id FROM users WHERE role = 'admin'").all();
} else if (user_id) {
const u = db.prepare('SELECT id FROM users WHERE id = ?').get(user_id);
if (!u) return res.status(404).json({ error: 'Utilisateur introuvable' });
targets = [u];
+15
View File
@@ -242,6 +242,21 @@ router.patch('/:id/status', requireAdmin, (req, res) => {
res.json({ ok: true });
});
// ── PATCH /api/tickets/:id/categorize — modifier type et pages ───────────────
router.patch('/:id/categorize', (req, res) => {
const { id: userId, role } = req.user;
const isAdmin = role === 'admin';
const ticket = db.prepare('SELECT * FROM tickets WHERE id = ?').get(req.params.id);
if (!ticket) return res.status(404).json({ error: 'Ticket introuvable' });
if (!isAdmin && ticket.user_id !== userId) return res.status(403).json({ error: 'Accès refusé' });
const { ticket_type, ticket_pages } = req.body;
db.prepare("UPDATE tickets SET ticket_type = ?, ticket_pages = ?, updated_at = datetime('now') WHERE id = ?")
.run(ticket_type ?? null, ticket_pages ?? null, req.params.id);
res.json({ ok: true });
});
// ── PATCH /api/tickets/:id/assign — assigner à un admin ──────────────────────
router.patch('/:id/assign', requireAdmin, (req, res) => {
const { assigned_to } = req.body; // null pour désassigner
+456 -77
View File
@@ -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
// ══════════════════════════════════════════════════════════════════════════
@@ -558,7 +816,10 @@ export default function Communication() {
const navigate = useNavigate();
const [notifMenuPos, setNotifMenuPos] = useState(null); // { x, y }
const [supportMenuPos, setSupportMenuPos] = useState(null); // { x, y }
const [showAssignModal, setShowAssignModal] = useState(false);
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,9 +1310,11 @@ export default function Communication() {
{tab === 'notifications' && (() => {
const NOTIF_PER_PAGE = 10;
const filteredNotifs = notifFilter.size === 2 || notifFilter.size === 0
? notifs
: notifFilter.has('unread') ? notifs.filter(n => !n.read) : notifs.filter(n => n.read);
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;
const lastPage = Math.max(0, Math.ceil(totalNotifs / NOTIF_PER_PAGE) - 1);
const pagedNotifs = filteredNotifs.slice(notifPage * NOTIF_PER_PAGE, (notifPage + 1) * NOTIF_PER_PAGE);
@@ -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: '16px 20px 20px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.05em' }}>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.05em' }}>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)}>
+2
View File
@@ -3222,6 +3222,7 @@ tr:hover td { background: var(--surface-2); }
cursor: pointer;
max-width: 300px;
vertical-align: bottom;
margin: 7px;
}
.msg-inline-img {
display: block;
@@ -3230,6 +3231,7 @@ tr:hover td { background: var(--surface-2); }
width: auto;
height: auto;
object-fit: contain;
margin: 7px;
}
.msg-img-hover-overlay {
position: absolute;