mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-04 00:47:02 +01:00
fixed ticket view
This commit is contained in:
1 parent
20e9d8ba1f
commit
f0f0a93e0b
6 files changed
+119
-29
No files matched your search
@@ -124,12 +124,14 @@ class Notification(models.Model):
|
||||
TYPE_CHOICES = [
|
||||
('reply', 'Reply'),
|
||||
('mention', 'Mention'),
|
||||
('ticket', 'Ticket Update'),
|
||||
]
|
||||
recipient = models.ForeignKey(User, related_name='notifications', on_delete=models.CASCADE)
|
||||
actor = models.ForeignKey(User, related_name='actions', on_delete=models.CASCADE)
|
||||
notification_type = models.CharField(max_length=10, choices=TYPE_CHOICES)
|
||||
rating = models.ForeignKey(Rating, on_delete=models.CASCADE, null=True, blank=True)
|
||||
reply = models.ForeignKey(Reply, on_delete=models.CASCADE, null=True, blank=True)
|
||||
ticket = models.ForeignKey('Ticket', on_delete=models.CASCADE, null=True, blank=True)
|
||||
is_read = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
|
||||
@@ -119,13 +119,23 @@ class TicketMessageSerializer(serializers.ModelSerializer):
|
||||
|
||||
class TicketSerializer(serializers.ModelSerializer):
|
||||
username = serializers.CharField(source='user.username', read_only=True)
|
||||
user_email = serializers.EmailField(source='user.email', read_only=True)
|
||||
user_avatar = serializers.SerializerMethodField()
|
||||
messages = TicketMessageSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Ticket
|
||||
fields = ['id', 'user', 'username', 'subject', 'description', 'status', 'created_at', 'updated_at', 'messages']
|
||||
fields = ['id', 'user', 'username', 'user_email', 'user_avatar', 'subject', 'description', 'status', 'created_at', 'updated_at', 'messages']
|
||||
read_only_fields = ['status', 'user']
|
||||
|
||||
def get_user_avatar(self, obj):
|
||||
if obj.user.avatar:
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
return request.build_absolute_uri(obj.user.avatar.url)
|
||||
return obj.user.avatar.url
|
||||
return None
|
||||
|
||||
class AdminUserListSerializer(serializers.ModelSerializer):
|
||||
ips = serializers.SerializerMethodField()
|
||||
|
||||
@@ -155,7 +165,7 @@ class NotificationSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Notification
|
||||
fields = ['id', 'actor_username', 'actor_avatar', 'notification_type', 'rating', 'reply', 'is_read', 'created_at', 'flavor_name', 'flavor_id']
|
||||
fields = ['id', 'actor_username', 'actor_avatar', 'notification_type', 'rating', 'reply', 'ticket', 'is_read', 'created_at', 'flavor_name', 'flavor_id']
|
||||
|
||||
def get_actor_avatar(self, obj):
|
||||
if obj.actor.avatar:
|
||||
|
||||
+32
-1
@@ -576,7 +576,16 @@ class TicketViewSet(viewsets.ModelViewSet):
|
||||
return Ticket.objects.filter(user=self.request.user).prefetch_related('messages', 'messages__user').order_by('-updated_at')
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(user=self.request.user)
|
||||
ticket = serializer.save(user=self.request.user)
|
||||
# Notify all superusers about new ticket
|
||||
admins = User.objects.filter(is_superuser=True).exclude(pk=self.request.user.pk)
|
||||
for admin in admins:
|
||||
Notification.objects.create(
|
||||
recipient=admin,
|
||||
actor=self.request.user,
|
||||
notification_type='ticket',
|
||||
ticket=ticket
|
||||
)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def add_message(self, request, pk=None):
|
||||
@@ -586,6 +595,28 @@ class TicketViewSet(viewsets.ModelViewSet):
|
||||
return Response({'error': 'Text is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
msg = TicketMessage.objects.create(ticket=ticket, user=request.user, text=text)
|
||||
|
||||
# Notification logic
|
||||
if request.user.is_superuser:
|
||||
# Notify the user who opened the ticket
|
||||
if ticket.user != request.user:
|
||||
Notification.objects.create(
|
||||
recipient=ticket.user,
|
||||
actor=request.user,
|
||||
notification_type='ticket',
|
||||
ticket=ticket
|
||||
)
|
||||
else:
|
||||
# Notify all superusers
|
||||
admins = User.objects.filter(is_superuser=True).exclude(pk=request.user.pk)
|
||||
for admin in admins:
|
||||
Notification.objects.create(
|
||||
recipient=admin,
|
||||
actor=request.user,
|
||||
notification_type='ticket',
|
||||
ticket=ticket
|
||||
)
|
||||
|
||||
return Response(TicketMessageSerializer(msg).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAdminUser])
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '@mui/material';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import EmailIcon from '@mui/icons-material/Email';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../api';
|
||||
@@ -86,6 +87,14 @@ const AdminDashboard: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => navigate(-1)}
|
||||
sx={{ mb: 4, borderRadius: 2 }}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Typography variant={isMobile ? "h4" : "h3"} sx={{ fontWeight: 'bold', mb: 4 }}>{t('admin.title')}</Typography>
|
||||
|
||||
<Grid container spacing={isMobile ? 2 : 3} sx={{ mb: 6 }}>
|
||||
|
||||
@@ -121,7 +121,7 @@ const AdminUserDetail: React.FC = () => {
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => navigate('/admin-panel')}
|
||||
onClick={() => navigate(-1)}
|
||||
sx={{ mb: 4, borderRadius: 2 }}
|
||||
>
|
||||
{t('common.back')}
|
||||
|
||||
@@ -2,12 +2,14 @@ import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Container, Typography, Box, Card, CardContent, Button,
|
||||
TextField, Stack, Chip, Divider, Collapse, Paper, alpha, CircularProgress,
|
||||
IconButton
|
||||
IconButton, Avatar
|
||||
} from '@mui/material';
|
||||
import SendIcon from '@mui/icons-material/Send';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
import { formatDate } from '../utils/date';
|
||||
@@ -22,7 +24,10 @@ interface Message {
|
||||
|
||||
interface Ticket {
|
||||
id: number;
|
||||
user: number;
|
||||
username: string;
|
||||
user_email: string;
|
||||
user_avatar: string | null;
|
||||
subject: string;
|
||||
description: string;
|
||||
status: 'open' | 'in_progress' | 'resolved' | 'closed';
|
||||
@@ -32,6 +37,7 @@ interface Ticket {
|
||||
|
||||
const Support: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
useTitle(t('support.title'));
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -122,6 +128,14 @@ const Support: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Container maxWidth="md" sx={{ py: 4 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => navigate(-1)}
|
||||
sx={{ mb: 4, borderRadius: 2 }}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 4 }}>
|
||||
<Typography variant="h3" sx={{ fontWeight: 'bold' }}>{t('support.title')}</Typography>
|
||||
<Button
|
||||
@@ -167,40 +181,62 @@ const Support: React.FC = () => {
|
||||
<CardContent sx={{ p: 0 }}>
|
||||
<Box
|
||||
onClick={() => setExpandedId(expandedId === ticket.id ? null : ticket.id)}
|
||||
sx={{ p: 2, cursor: 'pointer', display: 'flex', justifyContent: 'space-between', alignItems: 'center', '&:hover': { bgcolor: 'action.hover' } }}
|
||||
sx={{ p: 2, cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
|
||||
{ticket.subject}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>{ticket.subject}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{formatDate(ticket.created_at)}</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Chip label={t(`support.${ticket.status}`)} color={getStatusColor(ticket.status) as any} size="small" sx={{ fontWeight: 'bold' }} />
|
||||
{currentUser?.is_superuser && (
|
||||
<Typography component="span" variant="caption" sx={{ ml: 1, color: 'primary.main' }}>
|
||||
({ticket.username})
|
||||
</Typography>
|
||||
<IconButton size="small" color="error" onClick={(e) => { e.stopPropagation(); handleDeleteTicket(ticket.id); }}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{formatDate(ticket.created_at)}</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Chip label={t(`support.${ticket.status}`)} color={getStatusColor(ticket.status) as any} size="small" sx={{ fontWeight: 'bold' }} />
|
||||
{currentUser?.is_superuser && (
|
||||
<IconButton size="small" color="error" onClick={(e) => { e.stopPropagation(); handleDeleteTicket(ticket.id); }}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{currentUser?.is_superuser && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: 1, p: 1, borderRadius: 2, bgcolor: alpha('#000', 0.03) }}>
|
||||
<Avatar src={ticket.user_avatar || undefined} sx={{ width: 24, height: 24, fontSize: '0.7rem' }}>
|
||||
{ticket.username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 'bold', display: 'block' }}>
|
||||
{ticket.username} • <span style={{ opacity: 0.7 }}>{ticket.user_email}</span>
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={(e) => { e.stopPropagation(); navigate(`/profile/${ticket.username}`); }}
|
||||
sx={{ minWidth: 0, py: 0, textTransform: 'none', fontSize: '0.7rem' }}
|
||||
>
|
||||
View Profile
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={(e) => { e.stopPropagation(); navigate(`/admin-panel/user/${ticket.user}`); }}
|
||||
sx={{ minWidth: 0, py: 0, textTransform: 'none', fontSize: '0.7rem' }}
|
||||
>
|
||||
Admin User
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Collapse in={expandedId === ticket.id}>
|
||||
<Divider />
|
||||
<Box sx={{ p: 2, bgcolor: (theme) => alpha(theme.palette.background.default, 0.5) }}>
|
||||
{currentUser?.is_superuser && (
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 2, overflowX: 'auto', pb: 1 }}>
|
||||
{['open', 'in_progress', 'resolved', 'closed'].map((s) => (
|
||||
<Button
|
||||
key={s} size="small" variant={ticket.status === s ? "contained" : "outlined"}
|
||||
onClick={() => handleUpdateStatus(ticket.id, s)}
|
||||
color={getStatusColor(s) as any}
|
||||
sx={{ textTransform: 'none', borderRadius: 4, fontSize: '0.7rem' }}
|
||||
sx={{ textTransform: 'none', borderRadius: 4, fontSize: '0.7rem', flexShrink: 0 }}
|
||||
>
|
||||
{t(`support.${s}`)}
|
||||
</Button>
|
||||
@@ -208,7 +244,7 @@ const Support: React.FC = () => {
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Typography variant="body2" paragraph sx={{ whiteSpace: 'pre-wrap', mb: 3 }}>
|
||||
<Typography variant="body2" paragraph sx={{ whiteSpace: 'pre-wrap', mb: 3, bgcolor: 'background.paper', p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||
{ticket.description}
|
||||
</Typography>
|
||||
|
||||
@@ -216,17 +252,18 @@ const Support: React.FC = () => {
|
||||
{ticket.messages.map((msg: Message) => (
|
||||
<Box key={msg.id} sx={{
|
||||
alignSelf: msg.is_admin ? 'flex-start' : 'flex-end',
|
||||
maxWidth: '80%',
|
||||
maxWidth: '85%',
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
bgcolor: msg.is_admin ? 'primary.main' : 'action.selected',
|
||||
color: msg.is_admin ? 'white' : 'inherit'
|
||||
color: msg.is_admin ? 'white' : 'inherit',
|
||||
boxShadow: msg.is_admin ? 2 : 0
|
||||
}}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 'bold', display: 'block' }}>
|
||||
{msg.username} {msg.is_admin && '(Support)'}
|
||||
</Typography>
|
||||
<Typography variant="body2">{msg.text}</Typography>
|
||||
<Typography variant="caption" sx={{ opacity: 0.7 }}>{formatDate(msg.created_at)}</Typography>
|
||||
<Typography variant="body2" sx={{ my: 0.5 }}>{msg.text}</Typography>
|
||||
<Typography variant="caption" sx={{ opacity: 0.7, display: 'block', textAlign: 'right' }}>{formatDate(msg.created_at)}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
@@ -237,6 +274,7 @@ const Support: React.FC = () => {
|
||||
fullWidth size="small" placeholder={t('community.writeReply')}
|
||||
value={replyText[ticket.id] || ''}
|
||||
onChange={(e) => setReplyText({ ...replyText, [ticket.id]: e.target.value })}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleReply(ticket.id)}
|
||||
/>
|
||||
<IconButton color="primary" onClick={() => handleReply(ticket.id)} disabled={!replyText[ticket.id]}>
|
||||
<SendIcon />
|
||||
|
||||
Reference in new issue
Block a user