611 lines
18 KiB
Vue
611 lines
18 KiB
Vue
<template>
|
|
<q-dialog
|
|
:model-value="modelValue"
|
|
@update:model-value="emitUpdate"
|
|
@hide="resetDialog"
|
|
persistent
|
|
maximized
|
|
>
|
|
<q-card>
|
|
<q-card-section class="row items-center q-ma-none">
|
|
<div class="text-h6">
|
|
Mantis Ticket Details - {{ ticket ? ('M' + ticket.id) : '' }}
|
|
</div>
|
|
<q-space />
|
|
<q-btn
|
|
icon="close"
|
|
flat
|
|
round
|
|
dense
|
|
v-close-popup
|
|
/>
|
|
</q-card-section>
|
|
|
|
<q-separator />
|
|
|
|
<q-card-section
|
|
v-if="loading"
|
|
class="text-center"
|
|
>
|
|
<q-spinner
|
|
size="xl"
|
|
color="primary"
|
|
/>
|
|
<p>Loading ticket details...</p>
|
|
</q-card-section>
|
|
|
|
<q-card-section
|
|
v-else-if="error"
|
|
class="text-negative"
|
|
>
|
|
<q-icon
|
|
name="error"
|
|
size="md"
|
|
class="q-mr-sm"
|
|
/>
|
|
{{ error }}
|
|
</q-card-section>
|
|
|
|
<q-card-section
|
|
v-else-if="ticket"
|
|
class="q-pa-none"
|
|
>
|
|
<q-tabs
|
|
v-model="tab"
|
|
dense
|
|
class="text-grey"
|
|
active-color="primary"
|
|
indicator-color="primary"
|
|
align="justify"
|
|
narrow-indicator
|
|
>
|
|
<q-tab
|
|
name="details"
|
|
label="Details"
|
|
/>
|
|
<q-tab
|
|
name="files"
|
|
label="Files"
|
|
/>
|
|
<q-tab
|
|
name="notes"
|
|
label="Internal Notes"
|
|
/>
|
|
</q-tabs>
|
|
|
|
<q-separator />
|
|
|
|
<q-tab-panels
|
|
v-model="tab"
|
|
animated
|
|
class="scroll"
|
|
style="max-height: calc(100vh - 103px)"
|
|
>
|
|
<q-tab-panel
|
|
name="details"
|
|
class="q-pa-md"
|
|
>
|
|
<!-- Basic Info List (Moved Here) -->
|
|
<q-list
|
|
bordered
|
|
separator
|
|
class="q-mb-md"
|
|
>
|
|
<q-item>
|
|
<q-item-section>
|
|
<q-item-label overline>
|
|
ID
|
|
</q-item-label>
|
|
<q-item-label>
|
|
<a
|
|
:href="`//styletech.mantishub.io/view.php?id=${ticket.id}`"
|
|
target="_blank"
|
|
>{{ ticket.id }}</a>
|
|
</q-item-label>
|
|
</q-item-section>
|
|
</q-item>
|
|
<q-item>
|
|
<q-item-section>
|
|
<q-item-label overline>
|
|
Status
|
|
</q-item-label>
|
|
<q-item-label>
|
|
<q-badge
|
|
:color="getStatusColor(ticket.status)"
|
|
:label="ticket.status"
|
|
/>
|
|
</q-item-label>
|
|
</q-item-section>
|
|
</q-item>
|
|
<q-item>
|
|
<q-item-section>
|
|
<q-item-label overline>
|
|
Priority
|
|
</q-item-label>
|
|
<q-item-label>
|
|
<q-badge
|
|
:color="getPriorityColor(ticket.priority)"
|
|
:label="ticket.priority"
|
|
/>
|
|
</q-item-label>
|
|
</q-item-section>
|
|
</q-item>
|
|
<q-item>
|
|
<q-item-section>
|
|
<q-item-label overline>
|
|
Severity
|
|
</q-item-label>
|
|
<q-item-label>
|
|
<q-badge
|
|
:color="getSeverityColor(ticket.severity)"
|
|
:label="ticket.severity"
|
|
/>
|
|
</q-item-label>
|
|
</q-item-section>
|
|
</q-item>
|
|
<q-item>
|
|
<q-item-section>
|
|
<q-item-label overline>
|
|
Reporter
|
|
</q-item-label>
|
|
<q-item-label>{{ ticket.reporterUsername }}</q-item-label>
|
|
</q-item-section>
|
|
</q-item>
|
|
<q-item>
|
|
<q-item-section>
|
|
<q-item-label overline>
|
|
Created At
|
|
</q-item-label>
|
|
<q-item-label>{{ new Date(ticket.createdAt).toLocaleString() }}</q-item-label>
|
|
</q-item-section>
|
|
</q-item>
|
|
<q-item>
|
|
<q-item-section>
|
|
<q-item-label overline>
|
|
Last Updated
|
|
</q-item-label>
|
|
<q-item-label>{{ new Date(ticket.updatedAt).toLocaleString() }}</q-item-label>
|
|
</q-item-section>
|
|
</q-item>
|
|
</q-list>
|
|
|
|
<!-- Title, Description, Comments -->
|
|
<div>
|
|
<div class="text-h6 q-mb-sm">
|
|
{{ ticket.title }}
|
|
</div>
|
|
<q-separator class="q-mb-md" />
|
|
|
|
<div class="text-subtitle1 q-mb-xs">
|
|
Description
|
|
</div>
|
|
<div
|
|
v-html="ticket.description"
|
|
class="q-mb-md mantis-description"
|
|
/>
|
|
<!-- Note: v-html can be risky if content isn't sanitized server-side -->
|
|
|
|
<q-separator class="q-mb-md" />
|
|
|
|
<div class="text-subtitle1 q-mb-xs">
|
|
Comments ({{ ticket.comments?.length || 0 }})
|
|
</div>
|
|
<q-list
|
|
bordered
|
|
separator
|
|
v-if="ticket.comments && ticket.comments.length > 0"
|
|
>
|
|
<template
|
|
v-for="comment in ticket.comments"
|
|
:key="comment.id"
|
|
>
|
|
<q-item
|
|
class="q-mb-sm"
|
|
>
|
|
<q-item-section>
|
|
<q-item-label
|
|
overline
|
|
>
|
|
<strong class="text-subtitle1">{{ comment.senderUsername }}</strong> - {{ new Date(comment.createdAt).toLocaleString() }}
|
|
</q-item-label>
|
|
<q-item-label
|
|
caption
|
|
>
|
|
<div
|
|
class="text-body1 comment-text"
|
|
v-html="sanitiseComment(comment.comment)"
|
|
/>
|
|
</q-item-label>
|
|
<q-item-section
|
|
v-if="comment.attachments && comment.attachments.length > 0"
|
|
>
|
|
<q-separator class="q-mt-sm q-mb-lg" />
|
|
<q-item-label
|
|
overline
|
|
class="q-mb-sm"
|
|
>
|
|
Attachments:
|
|
</q-item-label>
|
|
<q-list
|
|
bordered
|
|
separator
|
|
style="background-color: rgba(0,0,0,0.25);"
|
|
>
|
|
<q-item
|
|
v-for="attachment in comment.attachments"
|
|
:key="attachment.id"
|
|
>
|
|
<q-item-section>
|
|
<q-item-label>
|
|
<a
|
|
:href="`/api/${attachment.url}`"
|
|
target="_blank"
|
|
>
|
|
{{ attachment.filename }}
|
|
</a>
|
|
<!-- Add MSG preview button for .msg files -->
|
|
<q-btn
|
|
v-if="isMsgFile(attachment.filename)"
|
|
flat
|
|
round
|
|
color="primary"
|
|
icon="email"
|
|
size="sm"
|
|
class="q-ml-sm"
|
|
@click="previewMsgFile(ticket.id, attachment.url.split('/')[attachment.url.split('/').length - 1], attachment.filename)"
|
|
:loading="loadingMsgId === attachment.id"
|
|
>
|
|
<q-tooltip>Preview Email</q-tooltip>
|
|
</q-btn>
|
|
</q-item-label>
|
|
<!-- Add image preview if it's an image file -->
|
|
<q-item-label
|
|
v-if="isImageFile(attachment.filename)"
|
|
class="q-mt-sm"
|
|
>
|
|
<img
|
|
:src="`/api/${attachment.url}`"
|
|
style="max-width: 100%; max-height: 200px; border-radius: 4px;"
|
|
@click="openImageFullscreen(`/api/${attachment.url}`, attachment.filename)"
|
|
>
|
|
</q-item-label>
|
|
</q-item-section>
|
|
</q-item>
|
|
</q-list>
|
|
</q-item-section>
|
|
</q-item-section>
|
|
<!-- Add card for each attachment -->
|
|
</q-item>
|
|
<q-item style="background-color: rgba(0,0,0,0.25);" />
|
|
</template>
|
|
</q-list>
|
|
<div
|
|
v-else
|
|
class="text-grey q-mb-md"
|
|
>
|
|
No comments found.
|
|
</div>
|
|
</div>
|
|
</q-tab-panel>
|
|
|
|
<q-tab-panel
|
|
name="files"
|
|
class="q-pa-md"
|
|
>
|
|
<!-- Content for Files tab goes here -->
|
|
<div class="text-grey">
|
|
Files content will be added here.
|
|
</div>
|
|
</q-tab-panel>
|
|
|
|
<q-tab-panel
|
|
name="notes"
|
|
class="q-pa-md"
|
|
>
|
|
<!-- Content for Internal Notes tab goes here -->
|
|
<div class="text-grey">
|
|
Internal Notes content will be added here.
|
|
</div>
|
|
</q-tab-panel>
|
|
</q-tab-panels>
|
|
</q-card-section>
|
|
</q-card>
|
|
</q-dialog>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, watch, defineProps, defineEmits } from 'vue';
|
|
import axios from 'axios';
|
|
import { useQuasar } from 'quasar';
|
|
import DOMPurify from 'dompurify';
|
|
|
|
import {usePreferencesStore} from 'stores/preferences.js';
|
|
|
|
const preferencesStore = usePreferencesStore();
|
|
|
|
const props = defineProps({
|
|
modelValue: Boolean, // Controls dialog visibility (v-model)
|
|
ticketId: {
|
|
type: [Number, null],
|
|
'default': null
|
|
}
|
|
});
|
|
|
|
const emit = defineEmits(['update:modelValue', 'close']);
|
|
|
|
const $q = useQuasar();
|
|
const ticket = ref(null);
|
|
const loading = ref(false);
|
|
const error = ref(null);
|
|
const tab = ref('details'); // Add state for the active tab
|
|
const loadingMsgId = ref(null); // Track which MSG attachment is currently loading
|
|
|
|
const sanitiseComment = (comment) =>
|
|
{
|
|
//Replace all URLs with anchor tags
|
|
comment = comment.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" target="_blank">$1</a>');
|
|
comment = comment.replaceAll('\r\n', '<br />');
|
|
comment = comment.replaceAll('\n', '<br />');
|
|
comment = comment.replaceAll('\r', '<br />');
|
|
comment = comment.replaceAll('\t', ' ');
|
|
|
|
return DOMPurify.sanitize(comment, {
|
|
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
|
|
ALLOWED_ATTR: ['href']
|
|
});
|
|
};
|
|
|
|
// Function to preview MSG file content
|
|
const previewMsgFile = async(ticketId, attachmentId, filename) =>
|
|
{
|
|
loadingMsgId.value = attachmentId;
|
|
try
|
|
{
|
|
const response = await axios.get(`/api/mantis/msg-extract/${ticketId}/${attachmentId}`);
|
|
|
|
// Display email content in a dialog
|
|
$q.dialog({
|
|
title: `Email: ${filename}`,
|
|
message: createEmailPreview(response.data, ticketId, attachmentId),
|
|
html: true,
|
|
style: 'min-width: 70vw; min-height: 60vh;',
|
|
maximized: $q.screen.lt.md,
|
|
persistent: true
|
|
});
|
|
}
|
|
catch (err)
|
|
{
|
|
console.error('Error fetching MSG file content:', err);
|
|
$q.notify({
|
|
type: 'negative',
|
|
message: `Failed to load email preview: ${err.response?.data?.error || err.message}`
|
|
});
|
|
}
|
|
finally
|
|
{
|
|
loadingMsgId.value = null;
|
|
}
|
|
};
|
|
|
|
// Format the email content for display
|
|
const createEmailPreview = (emailData, ticketId, attachmentId) =>
|
|
{
|
|
if (!emailData) return '<p>No email data available</p>';
|
|
|
|
let { subject, senderName, recipients, body, headers, attachments } = emailData;
|
|
|
|
body = body.trim();
|
|
|
|
// Collapse all whitespace in the body, preserving newlines
|
|
body = body.replace(/\r\n/g, '\n'); // Normalize line endings
|
|
body = body.replace(/\n+/g, '\n'); // Collapse multiple newlines
|
|
|
|
// Extract date from headers if available
|
|
const dateMatch = headers && headers.match(/Date:\s*([^\r\n]+)/i);
|
|
const date = dateMatch ? dateMatch[1] : null;
|
|
|
|
// Create recipient string from recipients array
|
|
const recipientStr = recipients && recipients.length > 0
|
|
? recipients.map(recipient => recipient.name).join(', ')
|
|
: 'Unknown';
|
|
|
|
let html = `
|
|
<div style="font-family: Arial, sans-serif; padding: 15px;">
|
|
<div style="margin-bottom: 20px; border-bottom: 1px solid #ddd; padding-bottom: 10px;">
|
|
<p><strong>Subject:</strong> ${subject || 'No subject'}</p>
|
|
<p><strong>From:</strong> ${senderName || 'Unknown'}</p>
|
|
<p><strong>To:</strong> ${recipientStr}</p>
|
|
${date ? `<p><strong>Date:</strong> ${date}</p>` : ''}
|
|
</div>
|
|
<div style="line-height: 1.5; margin-bottom: 20px;">
|
|
${body ? DOMPurify.sanitize(body.replace(/\n/g, '<br>')) : 'No content'}
|
|
</div>`;
|
|
|
|
// Add attachments section if there are any
|
|
if (attachments && attachments.length > 0)
|
|
{
|
|
html += `
|
|
<div style="border-top: 1px solid #ddd; padding-top: 10px;">
|
|
<p><strong>Attachments (${attachments.length}):</strong></p>
|
|
<ul>
|
|
${attachments.map(att =>
|
|
{
|
|
const index = attachments.indexOf(att);
|
|
return `<li><a href="/api/mantis/msg-extract/${ticketId}/${attachmentId}/${index}" target="_blank">${att.fileName}</a> (${formatFileSize(att.contentLength)})</li>`;
|
|
}).join('')}
|
|
</ul>
|
|
</div>`;
|
|
}
|
|
|
|
html += '</div>';
|
|
|
|
return html;
|
|
};
|
|
|
|
// Format file size in bytes to human-readable format
|
|
const formatFileSize = (bytes) =>
|
|
{
|
|
if (!bytes || bytes === 0) return '0 Bytes';
|
|
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i];
|
|
};
|
|
|
|
const fetchTicketDetails = async(id) =>
|
|
{
|
|
if (!id) return;
|
|
loading.value = true;
|
|
error.value = null;
|
|
ticket.value = null;
|
|
try
|
|
{
|
|
const response = await axios.get(`/api/mantis/${id}`);
|
|
ticket.value = response.data;
|
|
|
|
//Check user preference for comment order
|
|
if(preferencesStore.values.mantisCommentsOrder === 'newest')
|
|
{
|
|
ticket.value.comments.reverse();
|
|
}
|
|
}
|
|
catch (err)
|
|
{
|
|
console.error(`Error fetching Mantis ticket ${id}:`, err);
|
|
error.value = `Failed to load ticket details. ${err.response?.data?.error || err.message}`;
|
|
$q.notify({
|
|
type: 'negative',
|
|
message: error.value
|
|
});
|
|
}
|
|
finally
|
|
{
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const resetDialog = () =>
|
|
{
|
|
ticket.value = null;
|
|
loading.value = false;
|
|
error.value = null;
|
|
// Emit close event if needed, though v-close-popup handles visibility
|
|
emit('close');
|
|
};
|
|
|
|
const emitUpdate = (value) =>
|
|
{
|
|
emit('update:modelValue', value);
|
|
};
|
|
|
|
// Watch for changes in ticketId and fetch data when the dialog is shown
|
|
watch(() => props.ticketId, (newId) =>
|
|
{
|
|
if (props.modelValue && newId)
|
|
{
|
|
fetchTicketDetails(newId);
|
|
}
|
|
});
|
|
|
|
// Also fetch when the dialog becomes visible if ticketId is already set
|
|
watch(() => props.modelValue, (isVisible) =>
|
|
{
|
|
if (isVisible && props.ticketId && !ticket.value && !loading.value)
|
|
{
|
|
fetchTicketDetails(props.ticketId);
|
|
}
|
|
});
|
|
|
|
// Helper functions for badge colors (copied from MantisPage for consistency)
|
|
const getStatusColor = (status) =>
|
|
{
|
|
const lowerStatus = status?.toLowerCase();
|
|
if (lowerStatus === 'new') return 'blue';
|
|
if (lowerStatus === 'feedback') return 'orange';
|
|
if (lowerStatus === 'acknowledged') return 'purple';
|
|
if (lowerStatus === 'confirmed') return 'cyan';
|
|
if (lowerStatus === 'assigned') return 'teal';
|
|
if (lowerStatus === 'resolved') return 'green';
|
|
if (lowerStatus === 'closed') return 'grey';
|
|
return 'primary';
|
|
};
|
|
|
|
const getPriorityColor = (priority) =>
|
|
{
|
|
const lowerPriority = priority?.toLowerCase();
|
|
if (lowerPriority === 'none') return 'grey';
|
|
if (lowerPriority === 'low') return 'blue';
|
|
if (lowerPriority === 'normal') return 'green';
|
|
if (lowerPriority === 'high') return 'orange';
|
|
if (lowerPriority === 'urgent') return 'red';
|
|
if (lowerPriority === 'immediate') return 'purple';
|
|
return 'primary';
|
|
};
|
|
|
|
const getSeverityColor = (severity) =>
|
|
{
|
|
const lowerSeverity = severity?.toLowerCase();
|
|
if (lowerSeverity === 'feature') return 'info';
|
|
if (lowerSeverity === 'trivial') return 'grey';
|
|
if (lowerSeverity === 'text') return 'blue-grey';
|
|
if (lowerSeverity === 'tweak') return 'light-blue';
|
|
if (lowerSeverity === 'minor') return 'lime';
|
|
if (lowerSeverity === 'major') return 'amber';
|
|
if (lowerSeverity === 'crash') return 'deep-orange';
|
|
if (lowerSeverity === 'block') return 'red';
|
|
return 'primary';
|
|
};
|
|
|
|
const isImageFile = (filename) =>
|
|
{
|
|
return (/\.(jpg|jpeg|png|gif|bmp|webp)$/i).test(filename);
|
|
};
|
|
|
|
const isMsgFile = (filename) =>
|
|
{
|
|
return (/\.(msg)$/i).test(filename);
|
|
};
|
|
|
|
const openImageFullscreen = (src, filename) =>
|
|
{
|
|
$q.dialog({
|
|
title: filename,
|
|
style: 'min-width: 300px; min-height: 300px;',
|
|
message: `<img src="${src}" style="width: 100%; height: auto;" />`,
|
|
html: true,
|
|
position: {
|
|
x: 'center',
|
|
y: 'middle'
|
|
},
|
|
persistent: true
|
|
});
|
|
};
|
|
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
/* Scoped styles might not apply to v-html content easily */
|
|
/* Use a more global style or target specifically if needed */
|
|
.mantis-description {
|
|
white-space: pre-wrap; /* Preserve whitespace and newlines */
|
|
word-wrap: break-word;
|
|
padding: 10px;
|
|
border: 1px solid #eee;
|
|
border-radius: 4px;
|
|
max-height: 300px; /* Limit height if descriptions are long */
|
|
overflow-y: auto;
|
|
}
|
|
|
|
a, .comment-text:deep(a) {
|
|
color: $primary;
|
|
text-decoration: none;
|
|
}
|
|
|
|
a:hover, .comment-text:deep(a:hover) {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
a:visited, .comment-text:deep(a:visited) {
|
|
color: $blue-5;
|
|
}
|
|
</style>
|