espace-paie-odentas/components/TicketConversation.tsx
2025-10-12 17:05:46 +02:00

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>
);
}