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')} +
+ )} +
+ +
+ )} + + + +
+