Browse Source

Merge pull request #6 from Rox0z/Histórico-de-Discussões-e-ações

adiciona histórico de discussões
pull/2430/head
Leonardo Albudane 3 months ago
committed by GitHub
parent
commit
f616206dc6
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
7 changed files with 768 additions and 328 deletions
  1. 25
      backend/discussions/migrations/0004_chatmessage_reply_to.py
  2. 17
      backend/discussions/migrations/0005_remove_chatmessage_reply_to.py
  3. 35
      backend/discussions/urls.py
  4. 43
      backend/discussions/views.py
  5. 290
      frontend/components/discussions/ChatDiscussion.vue
  6. 300
      frontend/pages/projects/_id/discussions/_discussionId.vue
  7. 386
      frontend/pages/projects/_id/discussions/index.vue

25
backend/discussions/migrations/0004_chatmessage_reply_to.py

@ -0,0 +1,25 @@
# Generated by Django 4.2.15 on 2025-06-09 19:34
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("discussions", "0003_rename_message_chatmessage_alter_chatmessage_options"),
]
operations = [
migrations.AddField(
model_name="chatmessage",
name="reply_to",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="replies",
to="discussions.chatmessage",
),
),
]

17
backend/discussions/migrations/0005_remove_chatmessage_reply_to.py

@ -0,0 +1,17 @@
# Generated by Django 4.2.15 on 2025-06-09 19:38
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("discussions", "0004_chatmessage_reply_to"),
]
operations = [
migrations.RemoveField(
model_name="chatmessage",
name="reply_to",
),
]

35
backend/discussions/urls.py

@ -1,27 +1,50 @@
from django.urls import path
from .views import DiscussionViewSet, ChatMessageViewSet
# Endpoints de discussões
# Mapeia os métodos HTTP para as ações da ViewSet
# GET/POST para a lista de discussões
discussion_list = DiscussionViewSet.as_view({
'get': 'list',
'post': 'create'
})
# GET/PUT/PATCH/DELETE para uma discussão específica
discussion_detail = DiscussionViewSet.as_view({
'get': 'retrieve'
'get': 'retrieve',
'put': 'update',
'patch': 'partial_update',
'delete': 'destroy'
})
# Endpoints de mensagens no chat de uma discussão
# Ação customizada para encerrar uma discussão
discussion_close = DiscussionViewSet.as_view({
'post': 'close_discussion'
})
# Ação customizada para reabrir uma discussão
discussion_reopen = DiscussionViewSet.as_view({
'post': 'reopen_discussion'
})
# GET/POST para a lista de mensagens de um chat
chat_list = ChatMessageViewSet.as_view({
'get': 'list',
'post': 'create'
})
# Lista de todos os endpoints da aplicação 'discussions'
urlpatterns = [
# Discussões por projeto
# Rotas para criar e listar discussões de um projeto
path('projects/<int:project_id>/discussions/', discussion_list, name='discussion-list'),
# Rotas para ver, editar e excluir uma discussão específica
path('projects/<int:project_id>/discussions/<int:pk>/', discussion_detail, name='discussion-detail'),
# Chat da discussão
# Rotas para as ações customizadas
path('projects/<int:project_id>/discussions/<int:pk>/close/', discussion_close, name='discussion-close'),
path('projects/<int:project_id>/discussions/<int:pk>/reopen/', discussion_reopen, name='discussion-reopen'),
# Rota para o chat de uma discussão
path('discussions/<int:discussion_id>/chat/', chat_list, name='chat-list'),
]
]

43
backend/discussions/views.py

@ -1,8 +1,16 @@
from rest_framework import viewsets, permissions
from rest_framework import viewsets, permissions, status
from rest_framework.decorators import action
from rest_framework.response import Response
from django.utils import timezone
from django.shortcuts import get_object_or_404 # Garanta que este import está presente
from .models import Discussion, ChatMessage
from .serializers import DiscussionSerializer, ChatMessageSerializer
from projects.models import Project
class DiscussionViewSet(viewsets.ModelViewSet):
queryset = Discussion.objects.all()
serializer_class = DiscussionSerializer
@ -14,9 +22,40 @@ class DiscussionViewSet(viewsets.ModelViewSet):
def perform_create(self, serializer):
project_id = self.kwargs.get('project_id')
project = Project.objects.get(id=project_id)
project = get_object_or_404(Project, id=project_id)
serializer.save(project=project)
@action(detail=True, methods=['post'], url_path='close')
def close_discussion(self, request, project_id=None, pk=None):
"""
Encerra a discussão atual definindo a data de fim como a data atual.
"""
discussion = self.get_object()
discussion.end_date = timezone.now().date()
discussion.save()
serializer = self.get_serializer(discussion)
return Response(serializer.data, status=status.HTTP_200_OK)
@action(detail=True, methods=['post'], url_path='reopen')
def reopen_discussion(self, request, project_id=None, pk=None):
"""
Reabre uma discussão definindo uma nova data de fim.
"""
discussion = self.get_object()
new_end_date = request.data.get('end_date')
if not new_end_date:
return Response(
{'error': 'A nova data de fim (end_date) é obrigatória.'},
status=status.HTTP_400_BAD_REQUEST
)
discussion.end_date = new_end_date
discussion.save()
# Retorna a discussão atualizada para o frontend
serializer = self.get_serializer(discussion)
return Response(serializer.data, status=status.HTTP_200_OK)
class ChatMessageViewSet(viewsets.ModelViewSet):
serializer_class = ChatMessageSerializer

290
frontend/components/discussions/ChatDiscussion.vue

@ -1,87 +1,107 @@
<template>
<v-card outlined class="chat-container">
<v-card-title>
💬 Chat de Discussão
💬 Discussion Chat
</v-card-title>
<!-- Campo de filtro -->
<v-text-field
v-model="filterTerm"
label="🔍 Filtrar mensagens..."
label="🔍 Filter messages..."
prepend-icon="mdi-magnify"
class="px-4 pb-2"
/>
<v-card-text class="chat-messages">
<v-card-text class="chat-messages" ref="chatMessagesContainer">
<div v-if="filteredMessages.length === 0" class="text-center pa-4">
<p>Nenhuma mensagem encontrada.</p>
<p>No messages found.</p>
</div>
<div v-else class="message-list">
<div
v-for="(message, index) in filteredMessages"
:key="index"
:class="['message', (message.user || message.userId) === currentUserId ? 'message-own' : 'message-other']"
:style="(message.user || message.userId) !== currentUserId ? { backgroundColor: getColorForUser(message.userId || message.user) } : {}"
>
<div class="message-header">
<strong>{{ message.username }}</strong>
<small>{{ formatDate(message.timestamp) }}</small>
<v-btn icon small @click="startReply(message)">
<v-icon>mdi-reply</v-icon>
</v-btn>
</div>
<div class="message-content">
<p>{{ message.text }}</p>
v-for="(message, index) in filteredMessages"
:key="message.id || index"
class="message-row"
:class="{ 'own-row': (message.user || message.userId) === currentUserId }"
>
<div
class="message"
:class="[(message.user || message.userId) === currentUserId ? 'message-own' : 'message-other']"
:style="(message.user || message.userId) !== currentUserId ? { backgroundColor: getColorForUser(message.userId || message.user) } : {}"
>
<div class="message-header">
<strong>{{ message.username }}</strong>
<small>{{ formatDate(message.timestamp) }}</small>
<v-spacer></v-spacer>
<div v-if="message.status" class="d-flex align-center">
<v-icon v-if="message.status === 'sending'" small color="grey" title="Sending...">mdi-clock-outline</v-icon>
<div v-if="message.status === 'failed'" class="d-flex align-center">
<span class="error--text text--darken-1 mr-2">Failed</span>
<v-tooltip bottom>
<template v-slot:activator="{ on, attrs }">
<v-btn
text
x-small
color="error"
@click="retryMessage(message)"
v-bind="attrs"
v-on="on"
style="min-width: 24px; padding: 0 4px;"
>
</v-btn>
</template>
<span>Try again</span>
</v-tooltip>
</div>
</div>
<v-btn v-else-if="!readOnly" icon small @click="startReply(message)" title="Reply">
<span style="font-size: 1.5rem; line-height: 1;"></span>
</v-btn>
</div>
<div class="message-content">
<p>{{ message.text }}</p>
</div>
</div>
</div>
</div>
</v-card-text>
<!-- Mensagem que está sendo respondida -->
<div v-if="replyTo" class="reply-preview px-4 pb-2">
<small>Respondendo a: <strong>{{ replyTo.username }}</strong> {{ replyTo.text }}</small>
<v-btn icon small @click="cancelReply"><v-icon>mdi-close</v-icon></v-btn>
</div>
<div v-if="!readOnly">
<div v-if="replyTo" class="reply-preview px-4 pb-2">
<small>Replying to: <strong>{{ replyTo.username }}</strong> {{ replyTo.text }}</small>
<v-btn icon small @click="cancelReply"><v-icon>mdi-close</v-icon></v-btn>
</div>
<v-card-actions>
<v-textarea
v-model="newMessage"
outlined
rows="3"
placeholder="Digite sua mensagem..."
hide-details
class="chat-input"
@keydown.enter.prevent="sendMessage"
/>
<v-btn
color="primary"
:disabled="!newMessage.trim() || isSending"
@click="sendMessage"
>
Enviar
</v-btn>
</v-card-actions>
<!-- Diálogo de erro -->
<v-dialog v-model="showError" max-width="500">
<v-card color="red darken-2" dark>
<v-card-title class="headline"> Erro ao enviar mensagem</v-card-title>
<v-card-text>{{ errorMessage }}</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn text @click="showError = false">OK</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-card-actions>
<v-textarea
v-model="newMessage"
outlined
rows="3"
placeholder="Type your message..."
hide-details
class="chat-input"
@keydown.enter.prevent="sendMessage"
/>
<v-btn
color="primary"
:disabled="!newMessage.trim()"
@click="sendMessage"
>
Send
</v-btn>
</v-card-actions>
</div>
</v-card>
</template>
<script lang="ts">
import Vue from 'vue'
import axios from 'axios'
type Message = {
id: number
@ -90,11 +110,11 @@ type Message = {
username: string
text: string
timestamp: Date
status?: 'sending' | 'failed'
}
export default Vue.extend({
name: 'ChatDiscussion',
props: {
currentUserId: {
type: Number,
@ -104,16 +124,16 @@ export default Vue.extend({
messages: {
type: Array as () => Message[],
default: () => []
},
readOnly: {
type: Boolean,
default: false
}
},
data() {
return {
newMessage: '',
isSending: false,
localMessages: [] as Message[],
showError: false,
errorMessage: '',
filterTerm: '',
replyTo: null as Message | null
}
@ -121,13 +141,10 @@ export default Vue.extend({
computed: {
filteredMessages(): Message[] {
// Se temos mensagens como prop, usamos elas, senão usamos as mensagens locais
const messagesToFilter = this.messages && this.messages.length > 0 ? this.messages : this.localMessages;
if (!this.filterTerm.trim()) return messagesToFilter;
if (!this.filterTerm.trim()) return this.messages;
const term = this.filterTerm.toLowerCase();
return messagesToFilter.filter((msg) =>
return this.messages.filter((msg) =>
msg.text.toLowerCase().includes(term) ||
msg.username.toLowerCase().includes(term)
);
@ -136,58 +153,33 @@ export default Vue.extend({
watch: {
messages: {
handler(newMessages) {
if (newMessages && newMessages.length > 0) {
this.$nextTick(() => {
const container = this.$el.querySelector('.chat-messages');
if (container) {
container.scrollTop = container.scrollHeight;
}
});
}
handler() {
this.$nextTick(() => {
const container = this.$refs.chatMessagesContainer as HTMLElement;
if (container) {
container.scrollTop = container.scrollHeight;
}
});
},
deep: true,
immediate: true
}
},
mounted() {
// Se não recebemos mensagens como prop, carregamos do backend
if ((!this.messages || this.messages.length === 0) && this.$route.params.discussionId) {
const discussionId = this.$route.params.discussionId;
axios.get(`/v1/discussions/${discussionId}/chat/`)
.then((response) => {
this.localMessages = Array.isArray(response.data)
? response.data
: [];
this.$nextTick(() => {
const container = this.$el.querySelector('.chat-messages');
if (container) {
container.scrollTop = container.scrollHeight;
}
});
})
.catch((error) => {
console.error('Erro ao carregar mensagens:', error);
});
}
},
methods: {
formatDate(date: Date): string {
if (!date) return ''
const d = new Date(date)
const dateStr = d.toLocaleDateString('pt-PT', { day: '2-digit', month: '2-digit', year: '2-digit' })
// Locale alterada para formato de data inglês
const dateStr = d.toLocaleDateString('en-US', { day: '2-digit', month: '2-digit', year: '2-digit' })
const timeStr = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
return `${dateStr} ${timeStr}`
},
getColorForUser(userId: number): string {
const colors = ['#FFCDD2', '#F8BBD0', '#E1BEE7', '#BBDEFB', '#C8E6C9', '#FFF9C4', '#FFE0B2', '#D7CCC8'];
return colors[userId % colors.length];
},
const colors = ['#FFCDD2', '#F8BBD0', '#E1BEE7', '#BBDEFB', '#C8E6C9', '#FFF9C4', '#FFE0B2', '#D7CCC8'];
return colors[userId % colors.length];
},
startReply(message: Message) {
this.replyTo = message;
@ -197,57 +189,21 @@ export default Vue.extend({
this.replyTo = null;
},
async sendMessage() {
if (!this.newMessage.trim()) return;
this.isSending = true;
const messageText = this.replyTo ?
`${this.replyTo.username}: ${this.replyTo.text}\n${this.newMessage}` :
this.newMessage;
try {
// Se temos mensagens como prop, emitimos o evento para o componente pai
if (this.messages && this.messages.length > 0) {
this.$emit('send-message', messageText);
this.newMessage = '';
this.replyTo = null;
} else {
// Caso contrário, enviamos diretamente para o backend
const discussionId = this.$route.params.discussionId;
const response = await axios.post(`/v1/discussions/${discussionId}/chat/`, {
text: messageText
});
sendMessage() {
if (!this.newMessage.trim() || this.readOnly) return;
const savedMessage = response.data;
this.localMessages.push(savedMessage);
this.newMessage = '';
this.replyTo = null;
}
const messageText = this.replyTo
? `${this.replyTo.username}: ${this.replyTo.text}\n${this.newMessage}`
: this.newMessage;
this.$nextTick(() => {
const container = this.$el.querySelector('.chat-messages');
if (container) {
container.scrollTop = container.scrollHeight;
}
});
this.$emit('send-message', messageText);
} catch (error: any) {
let msg = 'Erro desconhecido.';
if (!error.response) {
msg = 'Você não está ligado à base de dados.';
} else if (error.response.status === 500) {
msg = 'Você não está ligado à base de dados.';
} else if (error.response?.data?.detail) {
msg = error.response.data.detail;
} else if (error.message) {
msg = error.message;
}
this.errorMessage = msg;
this.showError = true;
} finally {
this.isSending = false;
}
this.newMessage = '';
this.replyTo = null;
},
retryMessage(message: Message) {
this.$emit('retry-message', message);
}
}
})
@ -259,11 +215,11 @@ export default Vue.extend({
flex-direction: column;
height: 500px;
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
box-shadow: 0 4px 24px rgba(0,0,0,0.08);
}
.chat-messages {
height: 400px;
flex-grow: 1;
overflow-y: auto;
background-color: #f9f9f9;
padding: 10px;
@ -276,27 +232,31 @@ export default Vue.extend({
gap: 12px;
}
/* Removido o estilo do ícone fora do balão para corresponder ao pedido */
.message-row {
display: flex;
justify-content: flex-start;
}
.message-row.own-row {
justify-content: flex-end;
}
.message {
padding: 12px 16px;
border-radius: 16px;
max-width: 70%;
max-width: 80%;
font-size: 15px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
transition: background-color 0.3s ease;
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
}
.message-own {
align-self: flex-end;
background-color: #cdeffd;
color: #003344;
border-radius: 16px 16px 0 16px;
margin-left: 40px;
margin-right: 0;
text-align: right;
}
.message-other {
align-self: flex-start;
background-color: #f1f1f1;
color: #333;
border-radius: 16px 16px 16px 0;
@ -306,6 +266,7 @@ export default Vue.extend({
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-bottom: 6px;
font-size: 0.85rem;
color: #666;
@ -315,6 +276,7 @@ export default Vue.extend({
.message-content {
word-break: break-word;
font-weight: 400;
white-space: pre-wrap;
}
.chat-input {

300
frontend/pages/projects/_id/discussions/_discussionId.vue

@ -1,30 +1,90 @@
<template>
<v-container>
<v-container style="padding-top: 100px;">
<v-row>
<v-col cols="12">
<h2>💬 {{ discussion?.title || 'Discussão' }}</h2>
<p v-if="discussion"> {{ formatDate(discussion.start_date) }} {{ formatDate(discussion.end_date) }}</p>
</v-col>
<v-col cols="12" class="d-flex justify-space-between align-center">
<div>
<h2 v-if="!$fetchState.pending && discussion">💬 {{ discussion.title }}</h2>
<h2 v-else>💬 Discussion</h2>
<p v-if="!$fetchState.pending && discussion" class="grey--text text--darken-1 mb-0">
{{ formatDate(discussion.start_date) }} {{ formatDate(discussion.end_date) }}
</p>
</div>
<v-col cols="12">
<v-alert
v-if="isDiscussionClosed"
type="info"
border="left"
color="grey lighten-3"
<v-btn
text
color="primary"
@click="closeChat"
>
Esta discussão foi encerrada e não permite mais mensagens.
Back to List
</v-btn>
</v-col>
<v-col cols="12">
<div v-if="$fetchState.pending" class="text-center pa-12">
<v-progress-circular
indeterminate
color="primary"
size="64"
></v-progress-circular>
<p class="mt-4 text--secondary">Loading chat...</p>
</div>
<v-alert v-else-if="$fetchState.error" type="error" class="mt-4">
Could not load the discussion. Please try again.
</v-alert>
<chat-discussion
v-else
:current-user-id="currentUserId"
:messages="messages"
@send-message="handleSendMessage"
/>
<div v-else>
<v-alert
v-if="isDiscussionClosed && !isProjectAdmin"
type="info"
border="left"
color="grey lighten-3"
text
class="mt-4"
>
This discussion has been closed and no longer allows messages.
</v-alert>
<chat-discussion
v-if="!isDiscussionClosed || isProjectAdmin"
:current-user-id="currentUserId"
:messages="messages"
:read-only="isDiscussionClosed"
class="mt-4"
@send-message="handleSendMessage"
@retry-message="handleRetryMessage"
/>
</div>
</v-col>
</v-row>
<v-dialog v-model="showUnsavedDialog" max-width="500" persistent>
<v-card>
<v-card-title class="headline">Are you sure you want to leave?</v-card-title>
<v-card-text class="body-1 pt-4">
You have unsent messages. If you leave now,
<strong class="error--text">these messages will be lost</strong>.
<br><br>
You can also "Stay on Page" and wait for the connection to be re-established to resend them.
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
text
@click="cancelExit"
>
Stay on Page
</v-btn>
<v-btn
color="error"
text
@click="confirmExit"
>
Leave and Discard
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
@ -39,6 +99,7 @@ type Message = {
username: string
text: string
timestamp: Date
status?: 'sending' | 'failed'
}
export default defineComponent({
@ -53,7 +114,12 @@ export default defineComponent({
data() {
return {
discussion: null as any,
messages: [] as Message[]
messages: [] as Message[],
isProjectAdmin: false,
isOnline: true,
pollingInterval: null as any,
connectionCheckInterval: null as any,
showUnsavedDialog: false,
}
},
@ -73,28 +139,53 @@ export default defineComponent({
},
isDiscussionClosed(): boolean {
if (!this.discussion) return false
const now = new Date()
return new Date(this.discussion.end_date) < now
const todayString = new Date().toISOString().substr(0, 10);
return this.discussion.end_date <= todayString;
}
},
async fetch() {
await this.loadDiscussion()
await this.loadMessages()
try {
await Promise.all([
this.loadDiscussion(),
this.loadMessages(),
this.loadAdminStatus()
]);
} catch (e) {
console.error("Falha ao carregar dados da página de discussão:", e)
}
},
mounted() {
this.checkConnectionStatus();
this.connectionCheckInterval = setInterval(this.checkConnectionStatus, 10000);
this.pollingInterval = setInterval(this.pollForNewMessages, 5000);
window.addEventListener('beforeunload', this.handleBeforeUnload);
},
beforeDestroy() {
clearInterval(this.connectionCheckInterval);
clearInterval(this.pollingInterval);
window.removeEventListener('beforeunload', this.handleBeforeUnload);
},
methods: {
formatDate(date: string): string {
return new Date(date).toLocaleDateString('pt-PT')
formatDate(dateString: string): string {
if (!dateString) return '';
const date = new Date(dateString + 'T00:00:00Z');
return date.toLocaleDateString('pt-PT', {
timeZone: 'UTC',
});
},
async loadAdminStatus() {
const member = await this.$repositories.member.fetchMyRole(this.projectId)
this.isProjectAdmin = member.isProjectAdmin
},
async loadDiscussion() {
try {
const res = await this.$axios.get(`/projects/${this.projectId}/discussions/${this.discussionId}`)
this.discussion = res.data
} catch (err) {
console.error('Erro ao carregar discussão:', err)
}
const res = await this.$axios.get(`/v1/projects/${this.projectId}/discussions/${this.discussionId}/`)
this.discussion = res.data
},
async loadMessages() {
@ -107,33 +198,142 @@ export default defineComponent({
text: msg.text,
timestamp: new Date(msg.timestamp)
}))
} catch (err) {
console.error('Erro ao carregar mensagens:', err)
} catch (e) {
console.error("Erro ao carregar mensagens:", e);
this.isOnline = false;
}
},
async checkConnectionStatus() {
try {
await this.$axios.$head(`/v1/projects/${this.projectId}/discussions/`);
if (!this.isOnline) {
console.log('✅ Conexão restabelecida. Pode reenviar as mensagens que falharam.');
this.isOnline = true;
}
} catch (error) {
if (this.isOnline) {
console.warn('❌ Conexão perdida.');
this.isOnline = false;
}
}
},
async handleSendMessage(text: string) {
async pollForNewMessages() {
if (!this.isOnline || !this.messages) return;
try {
const res = await this.$axios.post(
`/v1/discussions/${this.discussionId}/chat/`,
{
text
}
)
const newMsg: Message = {
id: res.data.id,
userId: this.currentUserId,
username: this.currentUsername,
text,
timestamp: new Date(res.data.timestamp || Date.now())
const serverMessages = await this.$axios.$get(`/v1/discussions/${this.discussionId}/chat/`);
const currentMessageIds = new Set(this.messages.map(m => m.id).filter(id => typeof id === 'number' && id < Date.now()));
const newMessages = serverMessages.filter((msg: Message) => !currentMessageIds.has(msg.id));
if (newMessages.length > 0) {
this.messages.push(...newMessages);
}
} catch (error) {
// Silencioso
}
},
async handleSendMessage(text: string) {
const optimisticMessage: Message = {
id: Date.now(),
userId: this.currentUserId,
username: this.currentUsername,
text,
timestamp: new Date(),
status: 'sending'
};
this.messages.push(optimisticMessage);
if (this.isOnline) {
try {
const savedMessage = await this.$axios.$post(`/v1/discussions/${this.discussionId}/chat/`, { text });
const index = this.messages.findIndex(m => m.id === optimisticMessage.id);
if (index !== -1) this.$set(this.messages, index, savedMessage);
} catch (error) {
this.handleSendFailure(optimisticMessage);
}
} else {
this.handleSendFailure(optimisticMessage);
}
},
handleSendFailure(message: Message) {
const index = this.messages.findIndex(m => m.id === message.id);
if (index !== -1) {
this.messages[index].status = 'failed';
this.queueOfflineMessage(this.messages[index]);
}
},
queueOfflineMessage(message: Message) {
const queue = JSON.parse(localStorage.getItem(`offline_queue_${this.discussionId}`) || '[]');
queue.push({ tempId: message.id, text: message.text });
localStorage.setItem(`offline_queue_${this.discussionId}`, JSON.stringify(queue));
},
async handleRetryMessage(messageToRetry: Message) {
if (!this.isOnline) {
console.warn("Tentativa de reenvio falhou: sem conexão.");
return;
}
const index = this.messages.findIndex(m => m.id === messageToRetry.id);
if (index === -1) return;
this.messages[index].status = 'sending';
this.messages.push(newMsg)
} catch (err) {
console.error('Erro ao enviar mensagem:', err)
try {
const savedMessage = await this.$axios.$post(
`/v1/discussions/${this.discussionId}/chat/`,
{ text: messageToRetry.text }
);
this.$set(this.messages, index, savedMessage);
this.removeMessageFromQueue(messageToRetry.id);
} catch (error) {
console.error("O reenvio falhou:", error);
this.messages[index].status = 'failed';
}
},
removeMessageFromQueue(tempId: number) {
let queue = JSON.parse(localStorage.getItem(`offline_queue_${this.discussionId}`) || '[]');
queue = queue.filter((item: any) => item.tempId !== tempId);
localStorage.setItem(`offline_queue_${this.discussionId}`, JSON.stringify(queue));
},
handleBeforeUnload(event: BeforeUnloadEvent) {
const queue = JSON.parse(localStorage.getItem(`offline_queue_${this.discussionId}`) || '[]');
if (queue.length > 0) {
const confirmationMessage = 'As suas mensagens não salvas serão perdidas se sair da página.';
event.returnValue = confirmationMessage;
return confirmationMessage;
}
},
closeChat() {
const queue = JSON.parse(localStorage.getItem(`offline_queue_${this.discussionId}`) || '[]');
if (queue.length > 0) {
this.showUnsavedDialog = true;
} else {
this.$router.push(`/projects/${this.projectId}/discussions/`);
}
},
cancelExit() {
this.showUnsavedDialog = false;
},
confirmExit() {
localStorage.removeItem(`offline_queue_${this.discussionId}`);
this.messages = this.messages.filter(m => m.status !== 'failed');
this.showUnsavedDialog = false;
this.$router.push(`/projects/${this.projectId}/discussions/`);
}
}
})
</script>
<style scoped>
/* Nenhum estilo extra é necessário aqui */
</style>

386
frontend/pages/projects/_id/discussions/index.vue

@ -1,23 +1,15 @@
<template>
<v-container class="pt-16">
<v-row>
<v-col cols="12" class="d-flex justify-space-between align-center">
<h2>📋 Discussões em aberto</h2>
<v-btn color="primary" @click="showCreateDialog = true">
Criar nova discussão
<h2>📋 Discussions</h2>
<v-btn v-if="isProjectAdmin" color="primary" @click="showCreateDialog = true">
Create new discussion
</v-btn>
</v-col>
<!-- Mensagem de fallback -->
<v-col v-if="openDiscussions.length === 0 && !showCreateDialog" cols="12">
<v-alert type="info" text>
Nenhuma discussão em aberto.
</v-alert>
</v-col>
<!-- Tabela de discussões em aberto -->
<v-col v-if="openDiscussions.length > 0" cols="12">
<v-col v-if="openDiscussions.length > 0" cols="12" class="mt-4">
<h3>Open</h3>
<v-data-table
:headers="headers"
:items="openDiscussions"
@ -29,27 +21,43 @@
<td>{{ item.title }}</td>
<td>{{ formatDate(item.start_date) }} {{ formatDate(item.end_date) }}</td>
<td class="text-center">
<v-btn
color="primary"
small
@click="goToDiscussion(item.id)"
>
Discutir
</v-btn>
<v-btn color="primary" small class="mx-2" @click="goToDiscussion(item.id)">Discuss</v-btn>
<v-btn v-if="isProjectAdmin" color="orange" dark small class="mx-2" @click="confirmClose(item)">Close</v-btn>
<v-btn v-if="isProjectAdmin" text small color="primary" class="mx-2" @click="openEditDialog(item)">Edit</v-btn>
<v-btn v-if="isProjectAdmin" text small color="error" class="mx-2" @click="confirmDelete(item)">Delete</v-btn>
</td>
</tr>
</template>
</v-data-table>
</v-col>
<v-col v-if="futureDiscussions.length > 0" cols="12" class="mt-4">
<h3>📅 Upcoming</h3>
<v-data-table
:headers="headers"
:items="futureDiscussions"
:items-per-page="5"
class="elevation-1"
>
<template #item="{ item }">
<tr>
<td>{{ item.title }}</td>
<td>{{ formatDate(item.start_date) }} {{ formatDate(item.end_date) }}</td>
<td class="text-center">
<v-btn v-if="isProjectAdmin" text small color="primary" class="mx-2" @click="openEditDialog(item)">Edit</v-btn>
<v-btn v-if="isProjectAdmin" text small color="error" class="mx-2" @click="confirmDelete(item)">Delete</v-btn>
</td>
</tr>
</template>
</v-data-table>
</v-col>
<!-- Alternador para discussões encerradas -->
<v-col v-if="isProjectAdmin" cols="12">
<v-col v-if="isProjectAdmin" cols="12" class="mt-4">
<v-btn text @click="showClosed = !showClosed">
{{ showClosed ? '🔼 Ocultar' : '📁 Ver discussões encerradas' }}
{{ showClosed ? '🔼 Hide' : '📁 View closed discussions' }}
</v-btn>
</v-col>
<!-- Tabela de discussões encerradas -->
<v-col v-if="showClosed && closedDiscussions.length > 0" cols="12">
<v-data-table
:headers="headers"
@ -62,54 +70,42 @@
<td>{{ item.title }}</td>
<td>{{ formatDate(item.start_date) }} {{ formatDate(item.end_date) }}</td>
<td class="text-center">
<v-btn
color="secondary"
small
@click="goToDiscussion(item.id)"
>
Visualizar
</v-btn>
<v-btn color="secondary" small class="mx-2" @click="goToDiscussion(item.id)">View</v-btn>
<v-btn v-if="isProjectAdmin" color="success" small class="mx-2" @click="confirmReopen(item)">Reopen</v-btn>
<v-btn text small color="primary" class="mx-2" @click="openEditDialog(item)">Edit</v-btn>
<v-btn v-if="isProjectAdmin" text small color="error" class="mx-2" @click="confirmDelete(item)">Delete</v-btn>
</td>
</tr>
</template>
</v-data-table>
</v-col>
<v-col v-if="!openDiscussions.length && !futureDiscussions.length && !showCreateDialog" cols="12">
<v-alert type="info" text>
No discussions scheduled. Create one to get started!
</v-alert>
</v-col>
</v-row>
<!-- Modal de criação de discussão -->
<v-dialog v-model="showCreateDialog" max-width="500px">
<v-dialog v-model="showCreateDialog" max-width="500px" persistent>
<v-card>
<v-card-title>Criar nova discussão</v-card-title>
<v-card-title>Criate new discussion</v-card-title>
<v-card-text>
<v-text-field v-model="newDiscussion.title" label="Título" />
<v-text-field v-model="newDiscussion.title" label="Discussion name" />
<v-menu v-model="menu1" :close-on-content-click="false" transition="scale-transition">
<template #activator="{ on, attrs }">
<v-text-field
v-model="newDiscussion.start_date"
label="Início"
readonly
v-bind="attrs"
v-on="on"
/>
<v-text-field v-model="newDiscussion.start_date" label="Start date" readonly v-bind="attrs" v-on="on" />
</template>
<v-date-picker v-model="newDiscussion.start_date" @input="menu1 = false" />
</v-menu>
<v-menu v-model="menu2" :close-on-content-click="false" transition="scale-transition">
<template #activator="{ on, attrs }">
<v-text-field
v-model="newDiscussion.end_date"
label="Fim"
readonly
v-bind="attrs"
v-on="on"
/>
<v-text-field v-model="newDiscussion.end_date" label="End date" readonly v-bind="attrs" v-on="on" />
</template>
<v-date-picker v-model="newDiscussion.end_date" @input="menu2 = false" />
</v-menu>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn text @click="showCreateDialog = false">Cancelar</v-btn>
@ -118,13 +114,86 @@
</v-card>
</v-dialog>
<!-- Snackbar para notificações -->
<v-snackbar
v-model="snackbar.show"
:color="snackbar.color"
:timeout="snackbar.timeout"
top
>
<v-dialog v-model="showEditDialog" max-width="500px" persistent>
<v-card>
<v-card-title>Edit Discussion</v-card-title>
<v-card-text>
<v-text-field v-model="editingDiscussion.title" label="Discussion Name" />
<v-menu v-model="editMenu1" :close-on-content-click="false" transition="scale-transition">
<template #activator="{ on, attrs }">
<v-text-field v-model="editingDiscussion.start_date" label="Start date" readonly v-bind="attrs" v-on="on" />
</template>
<v-date-picker v-model="editingDiscussion.start_date" @input="editMenu1 = false" />
</v-menu>
<v-menu v-model="editMenu2" :close-on-content-click="false" transition="scale-transition">
<template #activator="{ on, attrs }">
<v-text-field v-model="editingDiscussion.end_date" label="End date" readonly v-bind="attrs" v-on="on" />
</template>
<v-date-picker v-model="editingDiscussion.end_date" @input="editMenu2 = false" />
</v-menu>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn text @click="showEditDialog = false">Cancelar</v-btn>
<v-btn color="primary" @click="saveDiscussion">Salvar</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="showCloseDialog" max-width="500px" persistent>
<v-card>
<v-card-title class="headline">Close Discussion?</v-card-title>
<v-card-text class="body-1 mt-4">
Are you sure you want to close the discussion <strong>"{{ selectedDiscussion?.title }}"</strong>?
The end date will be set to today.
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn text @click="showCloseDialog = false">Cancelar</v-btn>
<v-btn color="orange" dark @click="closeDiscussion">Encerrar</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="showDeleteDialog" max-width="500px" persistent>
<v-card>
<v-card-title class="headline error--text">Delete Discussion?</v-card-title>
<v-card-text>
<p class="body-1 mt-4">
Are you sure you want to permanently delete the discussion <strong>"{{ selectedDiscussion?.title }}"</strong>?
</p>
<p class="font-weight-bold">All chat messages will also be deleted and this action cannot be undone.</p>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn text @click="showDeleteDialog = false">Cancel</v-btn>
<v-btn color="error" @click="deleteDiscussion">Delete</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="showReopenDialog" max-width="500px" persistent>
<v-card>
<v-card-title class="headline">Reopen discussion</v-card-title>
<v-card-text>
Select a new end date for the discussion
<strong>"{{ selectedDiscussion?.title }}"</strong>:
<v-menu v-model="reopenMenu" :close-on-content-click="false" transition="scale-transition">
<template #activator="{ on, attrs }">
<v-text-field v-model="newEndDate" label="New end date" readonly v-bind="attrs" v-on="on" />
</template>
<v-date-picker v-model="newEndDate" @input="reopenMenu = false" />
</v-menu>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn text @click="showReopenDialog = false">Cancelar</v-btn>
<v-btn color="primary" @click="reopenDiscussion">Confirmar</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="snackbar.timeout" top>
{{ snackbar.text }}
<template #action="{ attrs }">
<v-btn text v-bind="attrs" @click="snackbar.show = false">
@ -135,7 +204,6 @@
</v-container>
</template>
<script lang="ts">
export default {
name: 'DiscussionListPage',
@ -147,20 +215,34 @@ export default {
discussions: [] as any[],
showClosed: false,
showCreateDialog: false,
showCloseDialog: false,
showReopenDialog: false,
showEditDialog: false,
showDeleteDialog: false,
reopenMenu: false,
editMenu1: false,
editMenu2: false,
newEndDate: '',
selectedDiscussion: null as any,
newDiscussion: {
title: '',
start_date: new Date().toISOString().substr(0, 10),
end_date: ''
},
editingDiscussion: {
id: null,
title: '',
start_date: '',
end_date: ''
},
menu1: false,
menu2: false,
isProjectAdmin: false,
headers: [
{ text: 'Título', value: 'title', sortable: true },
{ text: 'Período', value: 'dates', sortable: false },
{ text: 'Ações', value: 'actions', sortable: false, align: 'center' }
{ text: 'Discussion Name', value: 'title', sortable: true },
{ text: 'Date', value: 'dates', sortable: false },
{ text: 'Actions', value: 'actions', sortable: false, align: 'center' }
],
// Adicionando estado para controlar a notificação
snackbar: {
show: false,
text: '',
@ -175,8 +257,6 @@ export default {
const res = await this.$axios.get(`/v1/projects/${this.projectId}/discussions/`)
this.discussions = Array.isArray(res.data?.results) ? res.data.results : []
// Buscar informação se o usuário é admin do projeto
const member = await this.$repositories.member.fetchMyRole(this.projectId)
this.isProjectAdmin = member.isProjectAdmin
} catch (err) {
@ -189,74 +269,168 @@ export default {
projectId(): string {
return this.$route.params.id
},
openDiscussions() {
if (!Array.isArray(this.discussions)) return []
const now = new Date()
return this.discussions.filter(d => new Date(d.end_date) >= now)
futureDiscussions() {
const todayString = new Date().toISOString().substr(0, 10);
return this.discussions.filter(d => d.start_date > todayString);
},
closedDiscussions() {
if (!Array.isArray(this.discussions)) return []
const now = new Date()
return this.discussions.filter(d => new Date(d.end_date) < now)
const todayString = new Date().toISOString().substr(0, 10);
return this.discussions.filter(d => d.end_date <= todayString);
},
openDiscussions() {
const todayString = new Date().toISOString().substr(0, 10);
return this.discussions.filter(d =>
d.start_date <= todayString && d.end_date > todayString
);
}
},
mounted() {
console.log('🟢 Página de discussões carregada. Projeto ID:', this.projectId)
// Definir uma data de fim padrão (7 dias a partir de hoje)
const defaultEndDate = new Date()
defaultEndDate.setDate(defaultEndDate.getDate() + 7)
this.newDiscussion.end_date = defaultEndDate.toISOString().substr(0, 10)
},
methods: {
formatDate(date: string): string {
return new Date(date).toLocaleDateString('pt-PT')
formatDate(dateString: string): string {
if (!dateString) return '';
const date = new Date(dateString + 'T00:00:00Z');
return date.toLocaleDateString('pt-PT', {
timeZone: 'UTC',
});
},
async createDiscussion() {
if (!this.newDiscussion.title) {
alert('Por favor, informe um título para a discussão')
return
alert('Por favor, informe um título para a discussão');
return;
}
try {
const res = await this.$axios.post(`/v1/projects/${this.projectId}/discussions/`, this.newDiscussion)
this.discussions.push(res.data)
this.showCreateDialog = false
const res = await this.$axios.post(`/v1/projects/${this.projectId}/discussions/`, this.newDiscussion);
this.discussions.push(res.data);
this.showCreateDialog = false;
this.snackbar = { show: true, text: 'Discussão criada com sucesso!', color: 'success', timeout: 3000 };
// Não redirecionar mais para a discussão, apenas mostrar mensagem de sucesso
this.snackbar = {
show: true,
text: 'Discussão criada com sucesso!',
color: 'success',
timeout: 3000
}
// Limpar o formulário para próxima criação
const defaultEndDate = new Date();
defaultEndDate.setDate(defaultEndDate.getDate() + 7);
this.newDiscussion = {
title: '',
start_date: new Date().toISOString().substr(0, 10),
end_date: ''
end_date: defaultEndDate.toISOString().substr(0, 10)
};
} catch (err) {
console.error('Erro ao criar discussão:', err);
this.snackbar = { show: true, text: 'Erro ao criar discussão. Tente novamente.', color: 'error', timeout: 3000 };
}
},
openEditDialog(item: any) {
this.editingDiscussion = JSON.parse(JSON.stringify(item));
this.showEditDialog = true;
},
async saveDiscussion() {
if (!this.editingDiscussion.title) {
alert('O título não pode ficar em branco.');
return;
}
try {
const res = await this.$axios.patch(
`/v1/projects/${this.projectId}/discussions/${this.editingDiscussion.id}/`,
this.editingDiscussion
);
const index = this.discussions.findIndex(d => d.id === this.editingDiscussion.id);
if (index !== -1) {
this.$set(this.discussions, index, res.data);
}
// Definir data de fim padrão (7 dias a partir de hoje)
const defaultEndDate = new Date()
defaultEndDate.setDate(defaultEndDate.getDate() + 7)
this.newDiscussion.end_date = defaultEndDate.toISOString().substr(0, 10)
this.showEditDialog = false;
this.snackbar = { show: true, text: 'Discussão atualizada com sucesso!', color: 'success', timeout: 3000 };
} catch (err) {
console.error('Erro ao criar discussão:', err)
this.snackbar = {
show: true,
text: 'Erro ao criar discussão. Tente novamente.',
color: 'error',
timeout: 3000
console.error('Erro ao atualizar discussão:', err);
this.snackbar = { show: true, text: 'Erro ao atualizar discussão.', color: 'error', timeout: 3000 };
}
},
confirmDelete(item: any) {
this.selectedDiscussion = item;
this.showDeleteDialog = true;
},
async deleteDiscussion() {
if (!this.selectedDiscussion) return;
try {
await this.$axios.delete(
`/v1/projects/${this.projectId}/discussions/${this.selectedDiscussion.id}/`
);
const index = this.discussions.findIndex(d => d.id === this.selectedDiscussion.id);
if (index !== -1) {
this.discussions.splice(index, 1);
}
this.showDeleteDialog = false;
this.snackbar = { show: true, text: 'Discussão excluída com sucesso!', color: 'success', timeout: 3000 };
} catch (err) {
console.error('Erro ao excluir discussão:', err);
this.snackbar = { show: true, text: 'Erro ao excluir discussão.', color: 'error', timeout: 3000 };
}
},
goToDiscussion(id: number) {
this.$router.push(`/projects/${this.projectId}/discussions/${id}`)
this.$router.push(`/projects/${this.projectId}/discussions/${id}`);
},
confirmClose(discussion: any) {
this.selectedDiscussion = discussion;
this.showCloseDialog = true;
},
async closeDiscussion() {
if (!this.selectedDiscussion) return;
try {
const res = await this.$axios.post(
`/v1/projects/${this.projectId}/discussions/${this.selectedDiscussion.id}/close/`
);
const index = this.discussions.findIndex(d => d.id === this.selectedDiscussion.id);
if (index !== -1) {
this.$set(this.discussions, index, res.data);
}
this.showCloseDialog = false;
this.snackbar = { show: true, text: 'Discussão encerrada com sucesso!', color: 'success', timeout: 3000 };
} catch (err) {
console.error('Erro ao encerrar discussão:', err);
this.snackbar = { show: true, text: 'Erro ao encerrar discussão.', color: 'error', timeout: 3000 };
}
},
confirmReopen(discussion: any) {
this.selectedDiscussion = discussion;
const defaultEndDate = new Date();
defaultEndDate.setDate(defaultEndDate.getDate() + 7);
this.newEndDate = defaultEndDate.toISOString().substr(0, 10);
this.showReopenDialog = true;
},
async reopenDiscussion() {
if (!this.selectedDiscussion || !this.newEndDate) return;
try {
const res = await this.$axios.post(
`/v1/projects/${this.projectId}/discussions/${this.selectedDiscussion.id}/reopen/`,
{ end_date: this.newEndDate }
);
const index = this.discussions.findIndex(d => d.id === this.selectedDiscussion.id);
if (index !== -1) {
this.$set(this.discussions, index, res.data);
}
this.showReopenDialog = false;
this.snackbar = { show: true, text: 'Discussão reaberta com sucesso!', color: 'success', timeout: 3000 };
} catch (err) {
console.error('Erro ao reabrir discussão:', err);
this.snackbar = { show: true, text: 'Erro ao reabrir discussão. Verifique a data e tente novamente.', color: 'error', timeout: 3000 };
}
}
}
}
@ -268,4 +442,4 @@ export default {
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
</style>
</style>
Loading…
Cancel
Save