236 lines
No EOL
7 KiB
TypeScript
236 lines
No EOL
7 KiB
TypeScript
import React, { useState, useEffect } from "react";
|
|
import TicketTimeline from "./TicketTimeline";
|
|
import MessageCard from "./MessageCard";
|
|
|
|
type Ticket = {
|
|
id: string;
|
|
subject: string;
|
|
status: "open" | "waiting_client" | "waiting_staff" | "closed" | string;
|
|
priority: "low" | "normal" | "high" | "urgent" | string;
|
|
created_at?: string;
|
|
last_message_at?: string;
|
|
message_count?: number;
|
|
unread_by_client?: number;
|
|
};
|
|
|
|
type ApiMessage = {
|
|
id: string;
|
|
body: string;
|
|
author_id: string;
|
|
via: "web" | "staff" | string;
|
|
internal?: boolean;
|
|
created_at: string;
|
|
is_staff?: boolean;
|
|
};
|
|
|
|
type DisplayMessage = {
|
|
id: string;
|
|
content: string;
|
|
author_type: "client" | "staff";
|
|
author_name?: string;
|
|
created_at: string;
|
|
attachments?: Array<{
|
|
filename: string;
|
|
url: string;
|
|
size?: number;
|
|
}>;
|
|
};
|
|
|
|
interface TicketConversationProps {
|
|
ticket: Ticket;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export default function TicketConversation({ ticket, onClose }: TicketConversationProps) {
|
|
const [messages, setMessages] = useState<DisplayMessage[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [expandedMessages, setExpandedMessages] = useState<Set<string>>(new Set());
|
|
const [replyContent, setReplyContent] = useState("");
|
|
const [posting, setPosting] = useState(false);
|
|
|
|
// Déterminer qui a envoyé le dernier message
|
|
const lastMessage = messages[messages.length - 1];
|
|
const lastMessageBy = lastMessage?.author_type;
|
|
|
|
useEffect(() => {
|
|
loadMessages();
|
|
}, [ticket.id]);
|
|
|
|
async function loadMessages() {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const res = await fetch(`/api/tickets/${ticket.id}/messages`, {
|
|
credentials: "include",
|
|
cache: "no-store"
|
|
});
|
|
if (!res.ok) throw new Error(await res.text());
|
|
const data = await res.json();
|
|
// Adapter les données pour MessageCard
|
|
const adaptedMessages: DisplayMessage[] = (data.items || []).map((msg: ApiMessage) => {
|
|
const isStaff = msg.is_staff === true || msg.via === "staff";
|
|
return {
|
|
id: msg.id,
|
|
content: msg.body,
|
|
author_type: isStaff ? "staff" : "client",
|
|
author_name: isStaff ? "Support Odentas" : "Vous",
|
|
created_at: msg.created_at,
|
|
attachments: []
|
|
};
|
|
});
|
|
setMessages(adaptedMessages);
|
|
|
|
// Marquer le ticket comme lu (pour réinitialiser le compteur de messages non lus)
|
|
markTicketAsRead();
|
|
} catch (e: any) {
|
|
setError(e?.message || "Erreur de chargement des messages");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function markTicketAsRead() {
|
|
try {
|
|
await fetch(`/api/tickets/${ticket.id}/read`, {
|
|
method: "POST",
|
|
credentials: "include"
|
|
});
|
|
} catch (error) {
|
|
// Erreur silencieuse, ce n'est pas critique
|
|
console.warn("Impossible de marquer le ticket comme lu:", error);
|
|
}
|
|
}
|
|
|
|
function toggleMessage(messageId: string) {
|
|
const newExpanded = new Set(expandedMessages);
|
|
if (newExpanded.has(messageId)) {
|
|
newExpanded.delete(messageId);
|
|
} else {
|
|
newExpanded.add(messageId);
|
|
}
|
|
setExpandedMessages(newExpanded);
|
|
}
|
|
|
|
async function handleReply(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (!replyContent.trim()) return;
|
|
|
|
setPosting(true);
|
|
try {
|
|
const res = await fetch(`/api/tickets/${ticket.id}/messages`, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
body: replyContent.trim()
|
|
})
|
|
});
|
|
|
|
if (!res.ok) throw new Error(await res.text());
|
|
|
|
setReplyContent("");
|
|
await loadMessages(); // Recharger les messages
|
|
} catch (e: any) {
|
|
setError(e?.message || "Erreur lors de l'envoi du message");
|
|
} finally {
|
|
setPosting(false);
|
|
}
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* En-tête avec bouton fermer */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-xl font-semibold text-slate-900">
|
|
{ticket.subject}
|
|
</h2>
|
|
<p className="text-sm text-slate-600 mt-1">
|
|
Ticket #{ticket.id.slice(0, 8)}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors"
|
|
>
|
|
← Retour à la liste
|
|
</button>
|
|
</div>
|
|
|
|
{/* Timeline */}
|
|
<TicketTimeline
|
|
currentStatus={ticket.status as "open" | "waiting_staff" | "waiting_client" | "closed"}
|
|
lastMessageBy={lastMessageBy}
|
|
/>
|
|
|
|
{error && (
|
|
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Messages */}
|
|
<div className="space-y-3">
|
|
<h3 className="text-lg font-medium text-slate-900">
|
|
Conversation
|
|
</h3>
|
|
|
|
{messages.length > 0 ? (
|
|
<div className="space-y-3">
|
|
{messages.map((message) => (
|
|
<MessageCard
|
|
key={message.id}
|
|
message={message}
|
|
isExpanded={expandedMessages.has(message.id)}
|
|
onToggle={() => toggleMessage(message.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-8 text-slate-500">
|
|
Aucun message pour ce ticket
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Formulaire de réponse (seulement si le ticket n'est pas fermé) */}
|
|
{ticket.status !== "closed" && (
|
|
<div className="border-t border-slate-200 pt-6">
|
|
<h3 className="text-lg font-medium text-slate-900 mb-4">
|
|
Répondre
|
|
</h3>
|
|
|
|
<form onSubmit={handleReply} className="space-y-4">
|
|
<textarea
|
|
value={replyContent}
|
|
onChange={(e) => setReplyContent(e.target.value)}
|
|
placeholder="Écrivez votre message..."
|
|
rows={4}
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg bg-white text-slate-900 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
required
|
|
/>
|
|
|
|
<div className="flex justify-end">
|
|
<button
|
|
type="submit"
|
|
disabled={posting || !replyContent.trim()}
|
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg transition-colors disabled:cursor-not-allowed"
|
|
>
|
|
{posting ? "Envoi..." : "Envoyer"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
} |