From 84f0e4e9b4fd6b73444505696d3dd20a30cc0c82 Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 19 Mar 2026 06:39:05 +0300 Subject: [PATCH] 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. --- src/api/admin.ts | 11 +- src/locales/en.json | 6 + src/locales/fa.json | 6 + src/locales/ru.json | 6 + src/locales/zh.json | 6 + src/pages/AdminTickets.tsx | 285 +++++++++++++++++++++++++++++++++++-- 6 files changed, 307 insertions(+), 13 deletions(-) diff --git a/src/api/admin.ts b/src/api/admin.ts index bf09059..26bb21e 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -115,8 +115,15 @@ export const adminApi = { }, // Reply to ticket - replyToTicket: async (ticketId: number, message: string): Promise => { - const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/reply`, { message }); + replyToTicket: async ( + ticketId: number, + message: string, + media?: { media_type?: string; media_file_id?: string; media_caption?: string }, + ): Promise => { + const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/reply`, { + message, + ...media, + }); return response.data; }, diff --git a/src/locales/en.json b/src/locales/en.json index 39a5bb3..3c2fcc8 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1917,6 +1917,12 @@ "adminLabel": "Admin", "replyPlaceholder": "Type your 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", "settingsSubtitle": "Configure ticket system and SLA", "settingsLoadError": "Failed to load settings", diff --git a/src/locales/fa.json b/src/locales/fa.json index 2a24e5a..5ab243d 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1579,6 +1579,12 @@ "adminLabel": "مدیر", "replyPlaceholder": "پاسخ خود را بنویسید...", "sendReply": "ارسال پاسخ", + "attachMedia": "پیوست رسانه", + "invalidFileType": "نوع فایل نامعتبر. از تصاویر، ویدئو (MP4, WebM) یا اسناد (PDF, DOC, TXT, ZIP) استفاده کنید.", + "fileTooLarge": "فایل بسیار بزرگ است. حداکثر اندازه ۱۰ مگابایت.", + "uploadFailed": "آپلود فایل ناموفق بود", + "uploading": "در حال آپلود...", + "uploadComplete": "آپلود شد", "settings": "تنظیمات", "settingsSubtitle": "پیکربندی سیستم تیکت و SLA", "settingsLoadError": "خطا در بارگذاری تنظیمات", diff --git a/src/locales/ru.json b/src/locales/ru.json index 31017be..77a3a6b 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -2428,6 +2428,12 @@ "adminLabel": "Админ", "replyPlaceholder": "Введите ваш ответ...", "sendReply": "Отправить", + "attachMedia": "Прикрепить медиа", + "invalidFileType": "Неверный тип файла. Используйте изображения, видео (MP4, WebM) или документы (PDF, DOC, TXT, ZIP).", + "fileTooLarge": "Файл слишком большой. Максимальный размер 10МБ.", + "uploadFailed": "Не удалось загрузить файл", + "uploading": "Загрузка...", + "uploadComplete": "Загружено", "settings": "Настройки", "settingsSubtitle": "Настройка тикетной системы и SLA", "settingsLoadError": "Не удалось загрузить настройки", diff --git a/src/locales/zh.json b/src/locales/zh.json index d2fea20..05003f7 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1617,6 +1617,12 @@ "adminLabel": "管理员", "replyPlaceholder": "输入您的回复...", "sendReply": "发送回复", + "attachMedia": "附加媒体", + "invalidFileType": "文件类型无效。请使用图片、视频 (MP4, WebM) 或文档 (PDF, DOC, TXT, ZIP)。", + "fileTooLarge": "文件太大。最大 10MB。", + "uploadFailed": "上传文件失败", + "uploading": "上传中...", + "uploadComplete": "已上传", "settings": "设置", "settingsSubtitle": "配置工单系统和SLA", "settingsLoadError": "加载设置失败", diff --git a/src/pages/AdminTickets.tsx b/src/pages/AdminTickets.tsx index 4f37753..4253912 100644 --- a/src/pages/AdminTickets.tsx +++ b/src/pages/AdminTickets.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useRef, useEffect } from 'react'; import { useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; @@ -6,6 +6,34 @@ import { adminApi, AdminTicket, AdminTicketDetail, AdminTicketMessage } from '.. import { ticketsApi } from '../api/tickets'; 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 = { + '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({ message, t, @@ -80,6 +108,22 @@ function AdminMessageMedia({ ); } + if (message.media_type === 'video') { + return ( +
+
+ ); + } + return (
- + {message.media_caption || `Download ${message.media_type}`} @@ -126,6 +174,20 @@ export default function AdminTickets() { const [statusFilter, setStatusFilter] = useState(''); const [replyText, setReplyText] = useState(''); const [page, setPage] = useState(1); + const [attachment, setAttachment] = useState(null); + const fileInputRef = useRef(null); + const previewRef = useRef(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({ queryKey: ['admin-ticket-stats'], @@ -149,10 +211,18 @@ export default function AdminTickets() { }); const replyMutation = useMutation({ - mutationFn: ({ ticketId, message }: { ticketId: number; message: string }) => - adminApi.replyToTicket(ticketId, message), + mutationFn: ({ + ticketId, + message, + media, + }: { + ticketId: number; + message: string; + media?: { media_type?: string; media_file_id?: string; media_caption?: string }; + }) => adminApi.replyToTicket(ticketId, message, media), onSuccess: () => { setReplyText(''); + clearAttachment(); queryClient.invalidateQueries({ queryKey: ['admin-ticket', selectedTicketId] }); queryClient.invalidateQueries({ queryKey: ['admin-tickets'] }); 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) => { + 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) => { e.preventDefault(); 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) => { @@ -321,7 +465,11 @@ export default function AdminTickets() { {ticketsData?.items.map((ticket) => (
)} @@ -501,7 +654,9 @@ export default function AdminTickets() { {new Date(msg.created_at).toLocaleString()} -

{msg.message_text}

+ {msg.message_text && ( +

{msg.message_text}

+ )} ))} @@ -517,10 +672,118 @@ export default function AdminTickets() { rows={3} className="input resize-none" /> -
+ + {/* Attachment preview */} + {attachment && ( +
+ {attachment.mediaType === 'photo' && attachment.preview ? ( + Preview + ) : ( +
+ + {attachment.mediaType === 'video' ? ( + + ) : ( + + )} + +
+ )} +
+
{attachment.file.name}
+ {attachment.uploading && ( +
+ + {t('admin.tickets.uploading')} +
+ )} + {attachment.error && ( +
{attachment.error}
+ )} + {attachment.fileId && !attachment.uploading && ( +
+ {t('admin.tickets.uploadComplete')} +
+ )} +
+ +
+ )} + + + +
+