feat: add media attachment support for admin ticket replies

Admin can now attach photos, videos, and documents when replying to
tickets. Upload happens on file selection via existing media upload
endpoint. Includes attachment preview with type-specific icons,
upload progress, error handling, race condition protection, and
proper blob URL lifecycle management.

Added video player rendering for video attachments in message thread.
Updated ticket list preview to show [media_type] for media-only messages.
This commit is contained in:
Fringg
2026-03-19 06:39:05 +03:00
parent 6c3286abbe
commit 84f0e4e9b4
6 changed files with 307 additions and 13 deletions

View File

@@ -115,8 +115,15 @@ export const adminApi = {
}, },
// Reply to ticket // Reply to ticket
replyToTicket: async (ticketId: number, message: string): Promise<AdminTicketMessage> => { replyToTicket: async (
const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/reply`, { message }); ticketId: number,
message: string,
media?: { media_type?: string; media_file_id?: string; media_caption?: string },
): Promise<AdminTicketMessage> => {
const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/reply`, {
message,
...media,
});
return response.data; return response.data;
}, },

View File

@@ -1917,6 +1917,12 @@
"adminLabel": "Admin", "adminLabel": "Admin",
"replyPlaceholder": "Type your reply...", "replyPlaceholder": "Type your reply...",
"sendReply": "Send Reply", "sendReply": "Send Reply",
"attachMedia": "Attach media",
"invalidFileType": "Invalid file type. Use images, videos (MP4, WebM) or documents (PDF, DOC, TXT, ZIP).",
"fileTooLarge": "File is too large. Maximum size is 10MB.",
"uploadFailed": "Failed to upload file",
"uploading": "Uploading...",
"uploadComplete": "Uploaded",
"settings": "Settings", "settings": "Settings",
"settingsSubtitle": "Configure ticket system and SLA", "settingsSubtitle": "Configure ticket system and SLA",
"settingsLoadError": "Failed to load settings", "settingsLoadError": "Failed to load settings",

View File

@@ -1579,6 +1579,12 @@
"adminLabel": "مدیر", "adminLabel": "مدیر",
"replyPlaceholder": "پاسخ خود را بنویسید...", "replyPlaceholder": "پاسخ خود را بنویسید...",
"sendReply": "ارسال پاسخ", "sendReply": "ارسال پاسخ",
"attachMedia": "پیوست رسانه",
"invalidFileType": "نوع فایل نامعتبر. از تصاویر، ویدئو (MP4, WebM) یا اسناد (PDF, DOC, TXT, ZIP) استفاده کنید.",
"fileTooLarge": "فایل بسیار بزرگ است. حداکثر اندازه ۱۰ مگابایت.",
"uploadFailed": "آپلود فایل ناموفق بود",
"uploading": "در حال آپلود...",
"uploadComplete": "آپلود شد",
"settings": "تنظیمات", "settings": "تنظیمات",
"settingsSubtitle": "پیکربندی سیستم تیکت و SLA", "settingsSubtitle": "پیکربندی سیستم تیکت و SLA",
"settingsLoadError": "خطا در بارگذاری تنظیمات", "settingsLoadError": "خطا در بارگذاری تنظیمات",

View File

@@ -2428,6 +2428,12 @@
"adminLabel": "Админ", "adminLabel": "Админ",
"replyPlaceholder": "Введите ваш ответ...", "replyPlaceholder": "Введите ваш ответ...",
"sendReply": "Отправить", "sendReply": "Отправить",
"attachMedia": "Прикрепить медиа",
"invalidFileType": "Неверный тип файла. Используйте изображения, видео (MP4, WebM) или документы (PDF, DOC, TXT, ZIP).",
"fileTooLarge": "Файл слишком большой. Максимальный размер 10МБ.",
"uploadFailed": "Не удалось загрузить файл",
"uploading": "Загрузка...",
"uploadComplete": "Загружено",
"settings": "Настройки", "settings": "Настройки",
"settingsSubtitle": "Настройка тикетной системы и SLA", "settingsSubtitle": "Настройка тикетной системы и SLA",
"settingsLoadError": "Не удалось загрузить настройки", "settingsLoadError": "Не удалось загрузить настройки",

View File

@@ -1617,6 +1617,12 @@
"adminLabel": "管理员", "adminLabel": "管理员",
"replyPlaceholder": "输入您的回复...", "replyPlaceholder": "输入您的回复...",
"sendReply": "发送回复", "sendReply": "发送回复",
"attachMedia": "附加媒体",
"invalidFileType": "文件类型无效。请使用图片、视频 (MP4, WebM) 或文档 (PDF, DOC, TXT, ZIP)。",
"fileTooLarge": "文件太大。最大 10MB。",
"uploadFailed": "上传文件失败",
"uploading": "上传中...",
"uploadComplete": "已上传",
"settings": "设置", "settings": "设置",
"settingsSubtitle": "配置工单系统和SLA", "settingsSubtitle": "配置工单系统和SLA",
"settingsLoadError": "加载设置失败", "settingsLoadError": "加载设置失败",

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -6,6 +6,34 @@ import { adminApi, AdminTicket, AdminTicketDetail, AdminTicketMessage } from '..
import { ticketsApi } from '../api/tickets'; import { ticketsApi } from '../api/tickets';
import { usePlatform } from '../platform/hooks/usePlatform'; import { usePlatform } from '../platform/hooks/usePlatform';
interface MediaAttachment {
file: File;
preview: string;
uploading: boolean;
fileId?: string;
mediaType: string;
error?: string;
}
const ALLOWED_FILE_TYPES: Record<string, string> = {
'image/jpeg': 'photo',
'image/png': 'photo',
'image/gif': 'photo',
'image/webp': 'photo',
'video/mp4': 'video',
'video/webm': 'video',
'video/quicktime': 'video',
'application/pdf': 'document',
'application/msword': 'document',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'document',
'text/plain': 'document',
'application/zip': 'document',
'application/x-rar-compressed': 'document',
};
const ACCEPT_STRING = Object.keys(ALLOWED_FILE_TYPES).join(',');
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
function AdminMessageMedia({ function AdminMessageMedia({
message, message,
t, t,
@@ -80,6 +108,22 @@ function AdminMessageMedia({
); );
} }
if (message.media_type === 'video') {
return (
<div className="mt-3">
<video
src={mediaUrl}
controls
className="max-h-64 max-w-full rounded-lg"
preload="metadata"
/>
{message.media_caption && (
<p className="mt-1 text-xs text-dark-400">{message.media_caption}</p>
)}
</div>
);
}
return ( return (
<div className="mt-3"> <div className="mt-3">
<a <a
@@ -93,9 +137,13 @@ function AdminMessageMedia({
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
strokeWidth={2} strokeWidth={1.5}
> >
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
/>
</svg> </svg>
{message.media_caption || `Download ${message.media_type}`} {message.media_caption || `Download ${message.media_type}`}
</a> </a>
@@ -126,6 +174,20 @@ export default function AdminTickets() {
const [statusFilter, setStatusFilter] = useState<string>(''); const [statusFilter, setStatusFilter] = useState<string>('');
const [replyText, setReplyText] = useState(''); const [replyText, setReplyText] = useState('');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [attachment, setAttachment] = useState<MediaAttachment | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const previewRef = useRef<string | null>(null);
const uploadIdRef = useRef(0);
// Cancel in-flight uploads and cleanup blob URL on unmount
useEffect(() => {
return () => {
uploadIdRef.current++;
if (previewRef.current) {
URL.revokeObjectURL(previewRef.current);
}
};
}, []);
const { data: stats } = useQuery({ const { data: stats } = useQuery({
queryKey: ['admin-ticket-stats'], queryKey: ['admin-ticket-stats'],
@@ -149,10 +211,18 @@ export default function AdminTickets() {
}); });
const replyMutation = useMutation({ const replyMutation = useMutation({
mutationFn: ({ ticketId, message }: { ticketId: number; message: string }) => mutationFn: ({
adminApi.replyToTicket(ticketId, message), ticketId,
message,
media,
}: {
ticketId: number;
message: string;
media?: { media_type?: string; media_file_id?: string; media_caption?: string };
}) => adminApi.replyToTicket(ticketId, message, media),
onSuccess: () => { onSuccess: () => {
setReplyText(''); setReplyText('');
clearAttachment();
queryClient.invalidateQueries({ queryKey: ['admin-ticket', selectedTicketId] }); queryClient.invalidateQueries({ queryKey: ['admin-ticket', selectedTicketId] });
queryClient.invalidateQueries({ queryKey: ['admin-tickets'] }); queryClient.invalidateQueries({ queryKey: ['admin-tickets'] });
queryClient.invalidateQueries({ queryKey: ['admin-ticket-stats'] }); queryClient.invalidateQueries({ queryKey: ['admin-ticket-stats'] });
@@ -169,10 +239,84 @@ export default function AdminTickets() {
}, },
}); });
const clearAttachment = () => {
uploadIdRef.current++;
if (previewRef.current) {
URL.revokeObjectURL(previewRef.current);
previewRef.current = null;
}
setAttachment(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Revoke any existing blob URL before processing new file
if (previewRef.current) {
URL.revokeObjectURL(previewRef.current);
previewRef.current = null;
}
const mediaType = ALLOWED_FILE_TYPES[file.type];
if (!mediaType) {
setAttachment({
file,
preview: '',
uploading: false,
mediaType: 'document',
error: t('admin.tickets.invalidFileType'),
});
return;
}
if (file.size > MAX_FILE_SIZE) {
setAttachment({
file,
preview: '',
uploading: false,
mediaType,
error: t('admin.tickets.fileTooLarge'),
});
return;
}
const preview = mediaType === 'photo' ? URL.createObjectURL(file) : '';
previewRef.current = preview;
const currentUploadId = ++uploadIdRef.current;
setAttachment({ file, preview, uploading: true, mediaType });
try {
const result = await ticketsApi.uploadMedia(file, mediaType);
if (uploadIdRef.current !== currentUploadId) return; // stale upload
setAttachment((prev) =>
prev ? { ...prev, uploading: false, fileId: result.file_id } : null,
);
} catch {
if (uploadIdRef.current !== currentUploadId) return; // stale upload
setAttachment((prev) =>
prev ? { ...prev, uploading: false, error: t('admin.tickets.uploadFailed') } : null,
);
}
};
const handleReply = (e: React.FormEvent) => { const handleReply = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!selectedTicketId || !replyText.trim()) return; if (!selectedTicketId || !replyText.trim()) return;
replyMutation.mutate({ ticketId: selectedTicketId, message: replyText }); if (attachment && (attachment.uploading || attachment.error)) return;
const media = attachment?.fileId
? {
media_type: attachment.mediaType,
media_file_id: attachment.fileId,
}
: undefined;
replyMutation.mutate({ ticketId: selectedTicketId, message: replyText, media });
}; };
const getStatusBadge = (status: string) => { const getStatusBadge = (status: string) => {
@@ -321,7 +465,11 @@ export default function AdminTickets() {
{ticketsData?.items.map((ticket) => ( {ticketsData?.items.map((ticket) => (
<button <button
key={ticket.id} key={ticket.id}
onClick={() => setSelectedTicketId(ticket.id)} onClick={() => {
setSelectedTicketId(ticket.id);
setReplyText('');
clearAttachment();
}}
className={`w-full rounded-xl border p-4 text-left transition-all ${ className={`w-full rounded-xl border p-4 text-left transition-all ${
selectedTicketId === ticket.id selectedTicketId === ticket.id
? 'border-accent-500 bg-accent-500/10' ? 'border-accent-500 bg-accent-500/10'
@@ -359,7 +507,12 @@ export default function AdminTickets() {
{ticket.last_message.is_from_admin {ticket.last_message.is_from_admin
? t('admin.tickets.you') ? t('admin.tickets.you')
: t('admin.tickets.user')} : t('admin.tickets.user')}
: {ticket.last_message.message_text.substring(0, 50)}... :{' '}
{ticket.last_message.message_text
? `${ticket.last_message.message_text.substring(0, 50)}${ticket.last_message.message_text.length > 50 ? '...' : ''}`
: ticket.last_message.has_media
? `[${ticket.last_message.media_type}]`
: '...'}
</div> </div>
)} )}
</button> </button>
@@ -501,7 +654,9 @@ export default function AdminTickets() {
{new Date(msg.created_at).toLocaleString()} {new Date(msg.created_at).toLocaleString()}
</span> </span>
</div> </div>
<p className="whitespace-pre-wrap text-dark-200">{msg.message_text}</p> {msg.message_text && (
<p className="whitespace-pre-wrap text-dark-200">{msg.message_text}</p>
)}
<AdminMessageMedia message={msg} t={t} /> <AdminMessageMedia message={msg} t={t} />
</div> </div>
))} ))}
@@ -517,10 +672,118 @@ export default function AdminTickets() {
rows={3} rows={3}
className="input resize-none" className="input resize-none"
/> />
<div className="mt-3 flex justify-end">
{/* Attachment preview */}
{attachment && (
<div className="mt-2 flex items-center gap-3 rounded-lg border border-dark-700/50 bg-dark-800/50 p-2">
{attachment.mediaType === 'photo' && attachment.preview ? (
<img
src={attachment.preview}
alt="Preview"
className="h-12 w-12 rounded-lg object-cover"
/>
) : (
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-dark-700">
<svg
className="h-6 w-6 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
{attachment.mediaType === 'video' ? (
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z"
/>
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
/>
)}
</svg>
</div>
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-dark-200">{attachment.file.name}</div>
{attachment.uploading && (
<div className="flex items-center gap-1.5 text-xs text-accent-400">
<span className="h-3 w-3 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
{t('admin.tickets.uploading')}
</div>
)}
{attachment.error && (
<div className="text-xs text-red-400">{attachment.error}</div>
)}
{attachment.fileId && !attachment.uploading && (
<div className="text-xs text-success-400">
{t('admin.tickets.uploadComplete')}
</div>
)}
</div>
<button
type="button"
onClick={clearAttachment}
className="shrink-0 rounded-lg p-1 text-dark-500 transition-colors hover:bg-dark-700 hover:text-dark-200"
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept={ACCEPT_STRING}
onChange={handleFileSelect}
className="hidden"
/>
<div className="mt-3 flex items-center justify-between">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={!!attachment?.uploading}
className="flex items-center gap-2 rounded-lg border border-dark-700/50 px-3 py-2 text-sm text-dark-400 transition-colors hover:border-dark-600 hover:text-dark-200 disabled:opacity-50"
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13"
/>
</svg>
{t('admin.tickets.attachMedia')}
</button>
<button <button
type="submit" type="submit"
disabled={!replyText.trim() || replyMutation.isPending} disabled={
!replyText.trim() ||
replyMutation.isPending ||
!!attachment?.uploading ||
!!attachment?.error
}
className="btn-primary" className="btn-primary"
> >
{replyMutation.isPending ? ( {replyMutation.isPending ? (