amélrioration
This commit is contained in:
@@ -2003,4 +2003,13 @@ console.log('[DB] Migrations 2FA OK');
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
// Migration : assigned_to sur tickets
|
||||
const cols = db.prepare("PRAGMA table_info(tickets)").all().map(c => c.name);
|
||||
if (!cols.includes('assigned_to')) {
|
||||
db.exec("ALTER TABLE tickets ADD COLUMN assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL");
|
||||
console.log('[DB] tickets: colonne assigned_to ajoutée');
|
||||
}
|
||||
}
|
||||
|
||||
export default db;
|
||||
|
||||
@@ -144,8 +144,11 @@ router.get('/:id', (req, res) => {
|
||||
const isAdmin = role === 'admin';
|
||||
|
||||
const ticket = db.prepare(`
|
||||
SELECT t.*, u.display_name as user_name, u.email as user_email
|
||||
FROM tickets t JOIN users u ON u.id = t.user_id
|
||||
SELECT t.*, u.display_name as user_name, u.email as user_email,
|
||||
a.display_name as assigned_name
|
||||
FROM tickets t
|
||||
JOIN users u ON u.id = t.user_id
|
||||
LEFT JOIN users a ON a.id = t.assigned_to
|
||||
WHERE t.id = ?
|
||||
`).get(req.params.id);
|
||||
|
||||
@@ -194,7 +197,12 @@ router.post('/:id/messages', upload.array('attachments', 10), (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Réouverture automatique si l'émetteur répond sur un ticket en attente
|
||||
if (!isAdmin && ticket.status === 'pending') {
|
||||
db.prepare("UPDATE tickets SET status = 'open', updated_at = datetime('now') WHERE id = ?").run(ticket.id);
|
||||
} else {
|
||||
db.prepare("UPDATE tickets SET updated_at = datetime('now') WHERE id = ?").run(ticket.id);
|
||||
}
|
||||
return messageId;
|
||||
});
|
||||
|
||||
@@ -224,7 +232,7 @@ router.post('/:id/messages', upload.array('attachments', 10), (req, res) => {
|
||||
// ── PATCH /api/tickets/:id/status — résoudre / rouvrir (admin) ────────────
|
||||
router.patch('/:id/status', requireAdmin, (req, res) => {
|
||||
const { status } = req.body;
|
||||
if (!['open', 'resolved'].includes(status)) return res.status(400).json({ error: 'Statut invalide' });
|
||||
if (!['open', 'resolved', 'pending'].includes(status)) return res.status(400).json({ error: 'Statut invalide' });
|
||||
|
||||
const ticket = db.prepare('SELECT id FROM tickets WHERE id = ?').get(req.params.id);
|
||||
if (!ticket) return res.status(404).json({ error: 'Ticket introuvable' });
|
||||
@@ -234,6 +242,21 @@ router.patch('/:id/status', requireAdmin, (req, res) => {
|
||||
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
|
||||
if (assigned_to !== null && assigned_to !== undefined) {
|
||||
const admin = db.prepare("SELECT id FROM users WHERE id = ? AND role = 'admin'").get(assigned_to);
|
||||
if (!admin) return res.status(400).json({ error: 'Utilisateur invalide ou non admin' });
|
||||
}
|
||||
const ticket = db.prepare('SELECT id FROM tickets WHERE id = ?').get(req.params.id);
|
||||
if (!ticket) return res.status(404).json({ error: 'Ticket introuvable' });
|
||||
|
||||
db.prepare("UPDATE tickets SET assigned_to = ?, updated_at = datetime('now') WHERE id = ?")
|
||||
.run(assigned_to ?? null, req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── PUT /api/tickets/:id/messages/:msgId — éditer un message (5 min) ────────
|
||||
router.put('/:id/messages/:msgId', (req, res) => {
|
||||
const { id: userId } = req.user;
|
||||
|
||||
@@ -49,6 +49,7 @@ function StatusBadge({ status }) {
|
||||
const styles = {
|
||||
open: { bg: '#dcfce7', color: '#16a34a', label: 'Ouvert' },
|
||||
resolved: { bg: '#f1f5f9', color: '#64748b', label: 'Résolu' },
|
||||
pending: { bg: '#fef9c3', color: '#a16207', label: 'En attente' },
|
||||
};
|
||||
const s = styles[status] ?? styles.open;
|
||||
return (
|
||||
@@ -511,6 +512,9 @@ export default function Communication() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const [notifMenuPos, setNotifMenuPos] = useState(null); // { x, y }
|
||||
const [supportMenuPos, setSupportMenuPos] = useState(null); // { x, y }
|
||||
const [showAssignModal, setShowAssignModal] = useState(false);
|
||||
const [assignSearch, setAssignSearch] = useState('');
|
||||
|
||||
const [tab, setTab] = useState('support'); // 'support' | 'notifications'
|
||||
const [showBroadcastForm, setShowBroadcastForm] = useState(false);
|
||||
@@ -525,7 +529,7 @@ export default function Communication() {
|
||||
}, []);
|
||||
const [tickets, setTickets] = useState([]);
|
||||
const [ticketSearch, setTicketSearch] = useState('');
|
||||
const [ticketFilter, setTicketFilter] = useState('all'); // 'all'|'open'|'resolved'
|
||||
const [ticketFilter, setTicketFilter] = useState(new Set(['open', 'pending'])); // multi-sélection
|
||||
const [notifFilter, setNotifFilter] = useState('all'); // 'all'|'unread'
|
||||
const [selectedTicket, setSelectedTicket] = useState(null);
|
||||
const [thread, setThread] = useState(null); // { ticket, messages }
|
||||
@@ -596,7 +600,7 @@ export default function Communication() {
|
||||
if (!isAdmin) return;
|
||||
try {
|
||||
const data = await api.get('/admin/users');
|
||||
setUsers(data.users ?? data ?? []);
|
||||
setUsers((data.users ?? data ?? []).filter(u => u.role === 'admin'));
|
||||
} catch { /* silencieux */ }
|
||||
}, [isAdmin]);
|
||||
|
||||
@@ -696,6 +700,25 @@ export default function Communication() {
|
||||
} catch { /* silencieux */ }
|
||||
};
|
||||
|
||||
// ── Assigner un ticket ───────────────────────────────────────────────
|
||||
const assignTicket = async (adminId) => {
|
||||
if (!thread) return;
|
||||
try {
|
||||
await api.patch(`/tickets/${thread.ticket.id}/assign`, { assigned_to: adminId });
|
||||
await fetchThread(thread.ticket.id);
|
||||
} catch { /* silencieux */ }
|
||||
};
|
||||
|
||||
// ── Mettre en attente ────────────────────────────────────────────────
|
||||
const setTicketPending = async () => {
|
||||
if (!thread) return;
|
||||
try {
|
||||
await api.patch(`/tickets/${thread.ticket.id}/status`, { status: 'pending' });
|
||||
await fetchThread(thread.ticket.id);
|
||||
await fetchTickets();
|
||||
} catch { /* silencieux */ }
|
||||
};
|
||||
|
||||
// ── Broadcast ────────────────────────────────────────────────────────
|
||||
const submitBroadcast = async (e) => {
|
||||
e.preventDefault();
|
||||
@@ -799,13 +822,6 @@ export default function Communication() {
|
||||
{/* Colonne 2 — Dossier + filtres */}
|
||||
<div className="comm-topbar-2">
|
||||
<span className="comm-topbar-folder" style={{ fontSize: 15, fontWeight: 700 }}>{tab === 'support' ? 'Support' : 'Notifications'}</span>
|
||||
{tab === 'support' && (
|
||||
<div className="comm-topbar-filters">
|
||||
{[['all','Tous'],['open','Ouverts'],['resolved','Résolus']].map(([v,l]) => (
|
||||
<button key={v} className={`comm-topbar-filter-btn${ticketFilter === v ? ' active' : ''}`} onClick={() => setTicketFilter(v)}>{l}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{tab === 'notifications' && (
|
||||
<button
|
||||
className="btn-icon-sm"
|
||||
@@ -824,12 +840,16 @@ export default function Communication() {
|
||||
{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 }}>
|
||||
{isAdmin && (
|
||||
<button className="btn btn-sm" onClick={toggleStatus}>
|
||||
{thread.ticket.status === 'open' ? 'Résoudre' : 'Rouvrir'}
|
||||
<div style={{ display: 'flex', gap: 6, marginLeft: 'auto', flexShrink: 0, alignItems: 'center' }}>
|
||||
<button
|
||||
className="btn-icon-sm"
|
||||
onClick={e => {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
setSupportMenuPos({ x: r.right, y: r.bottom + 4 });
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -909,11 +929,15 @@ export default function Communication() {
|
||||
<div className="comm-list">
|
||||
{tab === 'support' && (
|
||||
<>
|
||||
<div className="comm-list-header">
|
||||
<div className="comm-list-header" style={{ flexDirection: 'column', alignItems: 'stretch', gap: 8 }}>
|
||||
<div className="comm-topbar-filters">
|
||||
{[['all','Tous'],['open','Ouverts'],['pending','En attente'],['resolved','Résolus']].map(([v,l]) => (
|
||||
<button key={v} className={`comm-topbar-filter-btn${ticketFilter === v ? ' active' : ''}`} onClick={() => setTicketFilter(v)}>{l}</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
className="comm-search"
|
||||
placeholder="Rechercher un ticket…"
|
||||
style={{ flex: 1 }}
|
||||
value={ticketSearch}
|
||||
onChange={e => setTicketSearch(e.target.value)}
|
||||
/>
|
||||
@@ -928,6 +952,7 @@ export default function Communication() {
|
||||
</div>
|
||||
)}
|
||||
{tickets
|
||||
.filter(t => ticketFilter === 'all' || t.status === ticketFilter)
|
||||
.filter(t => {
|
||||
if (!ticketSearch.trim()) return true;
|
||||
const q = ticketSearch.toLowerCase();
|
||||
@@ -988,8 +1013,8 @@ export default function Communication() {
|
||||
<div className="comm-list-scroll">
|
||||
{notifs.length === 0 && (
|
||||
<div className="comm-empty">
|
||||
<div style={{ fontSize: 32, marginBottom: 8 }}>🔔</div>
|
||||
<div style={{ fontWeight: 600 }}>Aucune notification</div>
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--border)" strokeWidth="1.5"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
<div style={{ marginTop: 12, color: 'var(--text-muted)', fontSize: 14 }}>Aucune notification</div>
|
||||
</div>
|
||||
)}
|
||||
{pagedNotifs.map(n => (
|
||||
@@ -1317,13 +1342,33 @@ export default function Communication() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Placeholder vide */}
|
||||
{tab === 'support' && !thread && (
|
||||
<div className="comm-detail-empty">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--border)" strokeWidth="1.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
<div style={{ marginTop: 12, color: 'var(--text-muted)', fontSize: 14 }}>Sélectionnez un ticket</div>
|
||||
{/* KPIs support */}
|
||||
{tab === 'support' && !thread && (() => {
|
||||
const kpis = [
|
||||
{ label: 'Total Tickets', count: tickets.length, bg: '#f3f4f6', color: '#111827' },
|
||||
{ label: 'En attente', count: tickets.filter(t => t.status === 'pending').length, bg: '#fefce8', color: '#a16207' },
|
||||
{ label: 'Ouverts', count: tickets.filter(t => t.status === 'open').length, bg: '#ecfdf5', color: '#0d9488' },
|
||||
{ label: 'Clôturés', count: tickets.filter(t => t.status === 'resolved').length, bg: '#fff1f2', color: '#e11d48' },
|
||||
];
|
||||
return (
|
||||
<div style={{ background: '#fff', height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ padding: '28px 28px 20px' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16 }}>
|
||||
{kpis.map(k => (
|
||||
<div key={k.label} style={{ background: k.bg, borderRadius: 12, padding: '24px 16px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 32, fontWeight: 700, color: k.color, lineHeight: 1 }}>{k.count}</div>
|
||||
<div style={{ marginTop: 8, fontSize: 13, fontWeight: 600, color: k.color }}>{k.label}</div>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--border)" strokeWidth="1.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 14 }}>Sélectionnez un ticket</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{tab === 'notifications' && !selectedNotif && !showBroadcastForm && (
|
||||
<div className="comm-detail-empty">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--border)" strokeWidth="1.5"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
@@ -1371,6 +1416,147 @@ export default function Communication() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Menu ⋮ support ── */}
|
||||
{supportMenuPos && (
|
||||
<>
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setSupportMenuPos(null)} />
|
||||
<div style={{
|
||||
position: 'fixed', left: supportMenuPos.x, top: supportMenuPos.y,
|
||||
transform: 'translateX(-100%) translateY(4px)',
|
||||
zIndex: 300,
|
||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
|
||||
padding: '4px 0', minWidth: 180,
|
||||
}}>
|
||||
<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={() => { setShowCompose(true); setSupportMenuPos(null); }}
|
||||
>
|
||||
<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 && isAdmin && (
|
||||
<>
|
||||
<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={() => { setAssignSearch(''); setShowAssignModal(true); setSupportMenuPos(null); }}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
Assigner à
|
||||
</button>
|
||||
{thread.ticket.status !== 'pending' ? (
|
||||
<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={() => { setTicketPending(); setSupportMenuPos(null); }}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
Mettre en attente
|
||||
</button>
|
||||
) : (
|
||||
<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={() => { toggleStatus(); setSupportMenuPos(null); }}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-3.5"/></svg>
|
||||
Réouvrir le ticket
|
||||
</button>
|
||||
)}
|
||||
<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={() => { toggleStatus(); setSupportMenuPos(null); }}
|
||||
>
|
||||
{thread.ticket.status === 'resolved'
|
||||
? <><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-3.5"/></svg>Rouvrir</>
|
||||
: <><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>
|
||||
<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' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
onClick={() => { setThread(null); setSelectedTicket(null); setSearchParams({}, { replace: true }); setSupportMenuPos(null); }}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
Fermer
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Modale Assigner à ── */}
|
||||
{showAssignModal && (
|
||||
<div className="comm-modal-backdrop" onClick={() => setShowAssignModal(false)}>
|
||||
<div className="comm-modal" style={{ maxWidth: 420 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="comm-modal-header">
|
||||
<span style={{ fontWeight: 700, fontSize: 15 }}>Assigner le ticket</span>
|
||||
<button className="comm-modal-close" onClick={() => setShowAssignModal(false)}>×</button>
|
||||
</div>
|
||||
<div style={{ padding: '12px 20px 16px' }}>
|
||||
<input
|
||||
className="comm-search"
|
||||
placeholder="Rechercher un administrateur…"
|
||||
value={assignSearch}
|
||||
onChange={e => setAssignSearch(e.target.value)}
|
||||
autoFocus
|
||||
style={{ width: '100%', marginBottom: 10 }}
|
||||
/>
|
||||
{/* Désassigner */}
|
||||
{thread?.ticket.assigned_to && (
|
||||
<button
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '8px 12px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--danger)', borderRadius: 6, marginBottom: 4 }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
onClick={async () => { await assignTicket(null); setShowAssignModal(false); }}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
Retirer l'assignation
|
||||
</button>
|
||||
)}
|
||||
{/* Liste admins */}
|
||||
<div style={{ maxHeight: 260, overflowY: 'auto' }}>
|
||||
{users
|
||||
.filter(u => !assignSearch.trim() || (u.display_name ?? u.email ?? '').toLowerCase().includes(assignSearch.toLowerCase()))
|
||||
.map(u => {
|
||||
const isAssigned = thread?.ticket.assigned_to === u.id;
|
||||
return (
|
||||
<button
|
||||
key={u.id}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '8px 12px', background: isAssigned ? 'var(--surface-2)' : 'none', border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text)', borderRadius: 6, textAlign: 'left' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = isAssigned ? 'var(--surface-2)' : 'none'}
|
||||
onClick={async () => { await assignTicket(u.id); setShowAssignModal(false); }}
|
||||
>
|
||||
<UserAvatar name={u.display_name ?? u.email} isAdmin size={30} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{u.display_name ?? u.email}</div>
|
||||
{u.display_name && <div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{u.email}</div>}
|
||||
</div>
|
||||
{isAssigned && (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--primary)" strokeWidth="2.5" style={{ marginLeft: 'auto' }}><polyline points="20 6 9 17 4 12"/></svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Lightbox image ── */}
|
||||
{lightbox && (
|
||||
<div className="comm-lightbox" onClick={() => setLightbox(null)}>
|
||||
|
||||
Reference in New Issue
Block a user