Fix
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -559,6 +559,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'
|
||||
@@ -1036,9 +1039,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>
|
||||
@@ -1159,19 +1162,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">
|
||||
@@ -1540,6 +1568,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 +1628,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 +1709,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)}>
|
||||
|
||||
Reference in New Issue
Block a user