feat: add feedback review notification UI
This commit is contained in:
290
frontend/src/features/feedback/stores/developerFeedbackStore.js
Normal file
290
frontend/src/features/feedback/stores/developerFeedbackStore.js
Normal file
@@ -0,0 +1,290 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
|
||||
const DEFAULT_FILTERS = Object.freeze({
|
||||
type: '',
|
||||
status: '',
|
||||
tag: '',
|
||||
reporter: '',
|
||||
workspace: '',
|
||||
fromDate: '',
|
||||
toDate: '',
|
||||
search: '',
|
||||
sort: 'lastActivity',
|
||||
});
|
||||
|
||||
export const FEEDBACK_TYPES = ['Bug', 'Suggestion', 'Request'];
|
||||
export const FEEDBACK_STATUSES = ['New', 'Planned', 'Resolved', "Won't Do", 'Cancelled'];
|
||||
export const FEEDBACK_DEVELOPER_STATUSES = ['New', 'Planned', 'Resolved', "Won't Do"];
|
||||
|
||||
export const useDeveloperFeedbackStore = defineStore('developer-feedback', () => {
|
||||
const client = useClient();
|
||||
const reports = ref([]);
|
||||
const selectedReport = ref(null);
|
||||
const screenshotPreviewUrl = ref('');
|
||||
const tags = ref([]);
|
||||
const filters = ref({ ...DEFAULT_FILTERS });
|
||||
const isLoading = ref(false);
|
||||
const isDetailLoading = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const isCommenting = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
const filteredReports = computed(() => {
|
||||
const query = filters.value.search.trim().toLowerCase();
|
||||
const reporter = filters.value.reporter.trim().toLowerCase();
|
||||
const workspace = filters.value.workspace.trim().toLowerCase();
|
||||
const fromDate = filters.value.fromDate ? new Date(`${filters.value.fromDate}T00:00:00`) : null;
|
||||
const toDate = filters.value.toDate ? new Date(`${filters.value.toDate}T23:59:59`) : null;
|
||||
|
||||
const rows = reports.value.filter(report => {
|
||||
if (filters.value.type && report.type !== filters.value.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.value.status && report.status !== filters.value.status) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.value.tag && !(report.tags ?? []).includes(filters.value.tag)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (reporter) {
|
||||
const reporterText = `${report.reporterDisplayName ?? ''} ${report.reporterEmail ?? ''}`.toLowerCase();
|
||||
if (!reporterText.includes(reporter)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (workspace) {
|
||||
const workspaceText = `${report.context?.workspaceName ?? ''} ${report.context?.workspaceId ?? ''}`.toLowerCase();
|
||||
if (!workspaceText.includes(workspace)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (fromDate || toDate) {
|
||||
const createdAt = report.createdAt ? new Date(report.createdAt) : null;
|
||||
if (!createdAt || (fromDate && createdAt < fromDate) || (toDate && createdAt > toDate)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (query) {
|
||||
const haystack = [
|
||||
report.description,
|
||||
report.type,
|
||||
report.status,
|
||||
report.reporterDisplayName,
|
||||
report.reporterEmail,
|
||||
report.metadata?.submittedPath,
|
||||
report.context?.workspaceName,
|
||||
report.context?.clientName,
|
||||
report.context?.projectName,
|
||||
report.context?.contentItemTitle,
|
||||
...(report.tags ?? []),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
if (!haystack.includes(query)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return rows.sort((a, b) => {
|
||||
if (filters.value.sort === 'oldest') {
|
||||
return compareDates(a.createdAt, b.createdAt);
|
||||
}
|
||||
|
||||
if (filters.value.sort === 'newest') {
|
||||
return compareDates(b.createdAt, a.createdAt);
|
||||
}
|
||||
|
||||
return compareDates(b.lastActivityAt, a.lastActivityAt);
|
||||
});
|
||||
});
|
||||
|
||||
const tagOptions = computed(() => {
|
||||
const fromReports = reports.value.flatMap(report => report.tags ?? []);
|
||||
return [...new Set([...tags.value, ...fromReports])]
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
});
|
||||
|
||||
async function loadReports() {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const [reportsResponse, tagsResponse] = await Promise.all([
|
||||
client.get('/api/feedback'),
|
||||
client.get('/api/feedback/tags'),
|
||||
]);
|
||||
|
||||
reports.value = reportsResponse.data ?? [];
|
||||
tags.value = tagsResponse.data ?? [];
|
||||
} catch (loadError) {
|
||||
console.error('Failed to load developer feedback:', loadError);
|
||||
error.value = 'feedback.review.errors.loadFailed';
|
||||
throw loadError;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadReport(id) {
|
||||
isDetailLoading.value = true;
|
||||
error.value = null;
|
||||
clearScreenshotPreview();
|
||||
|
||||
try {
|
||||
const response = await client.get(`/api/feedback/${id}`);
|
||||
selectedReport.value = response.data;
|
||||
await loadTimeline(id);
|
||||
await loadScreenshotPreview();
|
||||
return selectedReport.value;
|
||||
} catch (loadError) {
|
||||
console.error('Failed to load feedback report:', loadError);
|
||||
error.value = 'feedback.review.errors.detailFailed';
|
||||
throw loadError;
|
||||
} finally {
|
||||
isDetailLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTimeline(id) {
|
||||
const response = await client.get(`/api/feedback/${id}/timeline`);
|
||||
if (selectedReport.value?.id === id) {
|
||||
selectedReport.value = {
|
||||
...selectedReport.value,
|
||||
timeline: response.data ?? [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function updateReport(id, payload) {
|
||||
isSaving.value = true;
|
||||
|
||||
try {
|
||||
const response = await client.patch(`/api/feedback/${id}`, payload);
|
||||
selectedReport.value = {
|
||||
...response.data,
|
||||
timeline: selectedReport.value?.timeline ?? response.data?.timeline ?? [],
|
||||
};
|
||||
|
||||
reports.value = reports.value.map(report =>
|
||||
report.id === id ? { ...report, ...response.data } : report
|
||||
);
|
||||
|
||||
await loadTimeline(id);
|
||||
return selectedReport.value;
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addComment(id, body) {
|
||||
isCommenting.value = true;
|
||||
|
||||
try {
|
||||
await client.post(`/api/feedback/${id}/comments`, { body });
|
||||
await loadReport(id);
|
||||
} finally {
|
||||
isCommenting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadScreenshotPreview() {
|
||||
if (!selectedReport.value?.screenshot?.downloadPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await client.get(selectedReport.value.screenshot.downloadPath, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
screenshotPreviewUrl.value = URL.createObjectURL(response.data);
|
||||
}
|
||||
|
||||
async function downloadScreenshot() {
|
||||
if (!selectedReport.value?.screenshot?.downloadPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await client.get(selectedReport.value.screenshot.downloadPath, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
const url = URL.createObjectURL(response.data);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = selectedReport.value.screenshot.fileName || 'feedback-screenshot';
|
||||
link.rel = 'noopener';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function openScreenshot() {
|
||||
if (!selectedReport.value?.screenshot?.downloadPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await client.get(selectedReport.value.screenshot.downloadPath, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
const url = URL.createObjectURL(response.data);
|
||||
window.open(url, '_blank', 'noopener');
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.value = { ...DEFAULT_FILTERS };
|
||||
}
|
||||
|
||||
function clearSelectedReport() {
|
||||
selectedReport.value = null;
|
||||
clearScreenshotPreview();
|
||||
}
|
||||
|
||||
function clearScreenshotPreview() {
|
||||
if (screenshotPreviewUrl.value) {
|
||||
URL.revokeObjectURL(screenshotPreviewUrl.value);
|
||||
screenshotPreviewUrl.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
reports,
|
||||
selectedReport,
|
||||
screenshotPreviewUrl,
|
||||
tags,
|
||||
filters,
|
||||
filteredReports,
|
||||
tagOptions,
|
||||
isLoading,
|
||||
isDetailLoading,
|
||||
isSaving,
|
||||
isCommenting,
|
||||
error,
|
||||
loadReports,
|
||||
loadReport,
|
||||
updateReport,
|
||||
addComment,
|
||||
downloadScreenshot,
|
||||
openScreenshot,
|
||||
resetFilters,
|
||||
clearSelectedReport,
|
||||
clearScreenshotPreview,
|
||||
};
|
||||
});
|
||||
|
||||
function compareDates(left, right) {
|
||||
return new Date(left ?? 0).getTime() - new Date(right ?? 0).getTime();
|
||||
}
|
||||
179
frontend/src/features/feedback/stores/myFeedbackStore.js
Normal file
179
frontend/src/features/feedback/stores/myFeedbackStore.js
Normal file
@@ -0,0 +1,179 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
import { useNotificationsStore } from '@/features/notifications/stores/notificationsStore.js';
|
||||
|
||||
const DEFAULT_FILTERS = Object.freeze({
|
||||
type: '',
|
||||
status: '',
|
||||
sort: 'lastActivity',
|
||||
});
|
||||
|
||||
export const MY_FEEDBACK_DEFAULT_STATUSES = ['New', 'Planned'];
|
||||
|
||||
export const useMyFeedbackStore = defineStore('my-feedback', () => {
|
||||
const client = useClient();
|
||||
const notificationsStore = useNotificationsStore();
|
||||
|
||||
const reports = ref([]);
|
||||
const selectedReport = ref(null);
|
||||
const screenshotPreviewUrl = ref('');
|
||||
const filters = ref({ ...DEFAULT_FILTERS });
|
||||
const isLoading = ref(false);
|
||||
const isDetailLoading = ref(false);
|
||||
const isCommenting = ref(false);
|
||||
const isCancelling = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
const unreadReportIds = computed(() => notificationsStore.unreadFeedbackReportIds);
|
||||
|
||||
const filteredReports = computed(() => {
|
||||
const rows = reports.value.filter(report => {
|
||||
if (filters.value.type && report.type !== filters.value.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.value.status) {
|
||||
return report.status === filters.value.status;
|
||||
}
|
||||
|
||||
return MY_FEEDBACK_DEFAULT_STATUSES.includes(report.status);
|
||||
});
|
||||
|
||||
return rows.sort((a, b) => {
|
||||
if (filters.value.sort === 'newest') {
|
||||
return compareDates(b.createdAt, a.createdAt);
|
||||
}
|
||||
|
||||
return compareDates(b.lastActivityAt, a.lastActivityAt);
|
||||
});
|
||||
});
|
||||
|
||||
async function loadReports() {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await client.get('/api/my-feedback');
|
||||
reports.value = response.data ?? [];
|
||||
} catch (loadError) {
|
||||
console.error('Failed to load my feedback:', loadError);
|
||||
error.value = 'feedback.mine.errors.loadFailed';
|
||||
throw loadError;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadReport(id) {
|
||||
isDetailLoading.value = true;
|
||||
error.value = null;
|
||||
clearScreenshotPreview();
|
||||
|
||||
try {
|
||||
const response = await client.get(`/api/my-feedback/${id}`);
|
||||
selectedReport.value = response.data;
|
||||
await loadTimeline(id);
|
||||
await loadScreenshotPreview();
|
||||
await notificationsStore.markFeedbackReportAsRead(id);
|
||||
return selectedReport.value;
|
||||
} catch (loadError) {
|
||||
console.error('Failed to load my feedback report:', loadError);
|
||||
error.value = 'feedback.mine.errors.detailFailed';
|
||||
throw loadError;
|
||||
} finally {
|
||||
isDetailLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTimeline(id) {
|
||||
const response = await client.get(`/api/my-feedback/${id}/timeline`);
|
||||
if (selectedReport.value?.id === id) {
|
||||
selectedReport.value = {
|
||||
...selectedReport.value,
|
||||
timeline: response.data ?? [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function addComment(id, body) {
|
||||
isCommenting.value = true;
|
||||
|
||||
try {
|
||||
await client.post(`/api/my-feedback/${id}/comments`, { body });
|
||||
await loadReport(id);
|
||||
await loadReports();
|
||||
} finally {
|
||||
isCommenting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelReport(id, reason) {
|
||||
isCancelling.value = true;
|
||||
|
||||
try {
|
||||
const response = await client.post(`/api/my-feedback/${id}/cancel`, { reason });
|
||||
selectedReport.value = {
|
||||
...response.data,
|
||||
timeline: selectedReport.value?.timeline ?? response.data?.timeline ?? [],
|
||||
};
|
||||
reports.value = reports.value.map(report => report.id === id ? { ...report, ...response.data } : report);
|
||||
await loadTimeline(id);
|
||||
return selectedReport.value;
|
||||
} finally {
|
||||
isCancelling.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadScreenshotPreview() {
|
||||
if (!selectedReport.value?.screenshot?.downloadPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await client.get(selectedReport.value.screenshot.downloadPath, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
screenshotPreviewUrl.value = URL.createObjectURL(response.data);
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.value = { ...DEFAULT_FILTERS };
|
||||
}
|
||||
|
||||
function clearSelectedReport() {
|
||||
selectedReport.value = null;
|
||||
clearScreenshotPreview();
|
||||
}
|
||||
|
||||
function clearScreenshotPreview() {
|
||||
if (screenshotPreviewUrl.value) {
|
||||
URL.revokeObjectURL(screenshotPreviewUrl.value);
|
||||
screenshotPreviewUrl.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
reports,
|
||||
selectedReport,
|
||||
screenshotPreviewUrl,
|
||||
filters,
|
||||
unreadReportIds,
|
||||
filteredReports,
|
||||
isLoading,
|
||||
isDetailLoading,
|
||||
isCommenting,
|
||||
isCancelling,
|
||||
error,
|
||||
loadReports,
|
||||
loadReport,
|
||||
addComment,
|
||||
cancelReport,
|
||||
resetFilters,
|
||||
clearSelectedReport,
|
||||
clearScreenshotPreview,
|
||||
};
|
||||
});
|
||||
|
||||
function compareDates(left, right) {
|
||||
return new Date(left ?? 0).getTime() - new Date(right ?? 0).getTime();
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useToast } from 'vue-toastification';
|
||||
import { FEEDBACK_DEVELOPER_STATUSES, FEEDBACK_TYPES, useDeveloperFeedbackStore } from '@/features/feedback/stores/developerFeedbackStore.js';
|
||||
import {
|
||||
mdiArrowLeft,
|
||||
mdiDownloadOutline,
|
||||
mdiOpenInNew,
|
||||
mdiTagOutline,
|
||||
} from '@mdi/js';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const feedbackStore = useDeveloperFeedbackStore();
|
||||
const commentBody = ref('');
|
||||
const form = ref({
|
||||
type: '',
|
||||
status: '',
|
||||
tags: [],
|
||||
});
|
||||
|
||||
const report = computed(() => feedbackStore.selectedReport);
|
||||
const canSubmitComment = computed(() =>
|
||||
commentBody.value.trim().length > 0 && !feedbackStore.isCommenting
|
||||
);
|
||||
const statusOptions = computed(() => {
|
||||
if (report.value?.status && !FEEDBACK_DEVELOPER_STATUSES.includes(report.value.status)) {
|
||||
return [report.value.status, ...FEEDBACK_DEVELOPER_STATUSES];
|
||||
}
|
||||
|
||||
return FEEDBACK_DEVELOPER_STATUSES;
|
||||
});
|
||||
const metadataRows = computed(() => {
|
||||
const current = report.value;
|
||||
if (!current) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
[t('feedback.review.detail.metadata.path'), current.metadata?.submittedPath],
|
||||
[t('feedback.review.detail.metadata.userAgent'), current.metadata?.browserUserAgent],
|
||||
[t('feedback.review.detail.metadata.viewport'), formatViewport(current.metadata)],
|
||||
[t('feedback.review.detail.metadata.appVersion'), current.metadata?.appVersion],
|
||||
[t('feedback.review.detail.metadata.created'), formatDate(current.createdAt)],
|
||||
[t('feedback.review.detail.metadata.lastActivity'), formatDate(current.lastActivityAt)],
|
||||
];
|
||||
});
|
||||
const contextRows = computed(() => {
|
||||
const context = report.value?.context;
|
||||
return [
|
||||
[t('feedback.review.detail.context.workspace'), context?.workspaceName ?? context?.workspaceId],
|
||||
[t('feedback.review.detail.context.client'), context?.clientName ?? context?.clientId],
|
||||
[t('feedback.review.detail.context.project'), context?.projectName ?? context?.projectId],
|
||||
[t('feedback.review.detail.context.contentItem'), context?.contentItemTitle ?? context?.contentItemId],
|
||||
];
|
||||
});
|
||||
const timeline = computed(() =>
|
||||
[...(report.value?.timeline ?? [])].sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
feedbackStore.loadReport(route.params.id);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
feedbackStore.clearSelectedReport();
|
||||
});
|
||||
|
||||
watch(report, nextReport => {
|
||||
if (!nextReport) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.value = {
|
||||
type: nextReport.type ?? '',
|
||||
status: nextReport.status ?? '',
|
||||
tags: [...(nextReport.tags ?? [])],
|
||||
};
|
||||
}, { immediate: true });
|
||||
|
||||
async function saveReviewChanges() {
|
||||
try {
|
||||
const payload = {};
|
||||
|
||||
if (form.value.type !== report.value.type) {
|
||||
payload.type = form.value.type;
|
||||
}
|
||||
|
||||
if (form.value.status !== report.value.status && FEEDBACK_DEVELOPER_STATUSES.includes(form.value.status)) {
|
||||
payload.status = form.value.status;
|
||||
}
|
||||
|
||||
if (JSON.stringify(form.value.tags) !== JSON.stringify(report.value.tags ?? [])) {
|
||||
payload.tags = form.value.tags;
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length > 0) {
|
||||
await feedbackStore.updateReport(report.value.id, payload);
|
||||
}
|
||||
|
||||
toast.success(t('feedback.review.detail.saved'));
|
||||
} catch (error) {
|
||||
console.error('Failed to save feedback review changes:', error);
|
||||
toast.error(t('feedback.review.detail.saveFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function submitComment() {
|
||||
if (!canSubmitComment.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await feedbackStore.addComment(report.value.id, commentBody.value.trim());
|
||||
commentBody.value = '';
|
||||
toast.success(t('feedback.review.detail.commentAdded'));
|
||||
} catch (error) {
|
||||
console.error('Failed to add developer feedback comment:', error);
|
||||
toast.error(t('feedback.review.detail.commentFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) {
|
||||
return t('feedback.review.emptyValue');
|
||||
}
|
||||
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function formatViewport(metadata) {
|
||||
if (!metadata?.viewportWidth || !metadata?.viewportHeight) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${metadata.viewportWidth} x ${metadata.viewportHeight}`;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (!bytes) {
|
||||
return t('feedback.review.emptyValue');
|
||||
}
|
||||
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${Math.round(bytes / 1024)} KB`;
|
||||
}
|
||||
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function activityText(item) {
|
||||
if (item.kind === 'Comment') {
|
||||
return item.body;
|
||||
}
|
||||
|
||||
if (item.activityType === 'StatusChanged') {
|
||||
return t('feedback.review.detail.activity.statusChanged', {
|
||||
from: item.fromValue,
|
||||
to: item.toValue,
|
||||
});
|
||||
}
|
||||
|
||||
if (item.activityType === 'TypeChanged') {
|
||||
return t('feedback.review.detail.activity.typeChanged', {
|
||||
from: item.fromValue,
|
||||
to: item.toValue,
|
||||
});
|
||||
}
|
||||
|
||||
if (item.activityType === 'TagsChanged') {
|
||||
return t('feedback.review.detail.activity.tagsChanged', {
|
||||
from: item.fromValue || t('feedback.review.emptyValue'),
|
||||
to: item.toValue || t('feedback.review.emptyValue'),
|
||||
});
|
||||
}
|
||||
|
||||
return item.note || item.activityType || t('feedback.review.detail.activity.updated');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="feedback-detail-page">
|
||||
<button
|
||||
class="back-button"
|
||||
type="button"
|
||||
@click="router.push({ name: 'developer-feedback' })"
|
||||
>
|
||||
<v-icon :icon="mdiArrowLeft" />
|
||||
{{ t('feedback.review.detail.back') }}
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="feedbackStore.isDetailLoading"
|
||||
class="page-message"
|
||||
>
|
||||
{{ t('feedback.review.loading') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="feedbackStore.error"
|
||||
class="page-message page-message-error"
|
||||
>
|
||||
{{ t(feedbackStore.error) }}
|
||||
</div>
|
||||
|
||||
<template v-else-if="report">
|
||||
<header class="detail-header">
|
||||
<div>
|
||||
<div class="eyebrow">{{ t('feedback.review.detail.eyebrow') }}</div>
|
||||
<h1>{{ report.type }}: {{ report.description }}</h1>
|
||||
<div class="header-meta">
|
||||
<span>{{ report.status }}</span>
|
||||
<span>{{ report.reporterDisplayName }}</span>
|
||||
<span>{{ formatDate(report.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="detail-grid">
|
||||
<main class="detail-main">
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<strong>{{ t('feedback.review.detail.report') }}</strong>
|
||||
</div>
|
||||
<p class="description">{{ report.description }}</p>
|
||||
<a
|
||||
v-if="report.metadata?.submittedPath"
|
||||
class="path-link"
|
||||
:href="report.metadata.submittedPath"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<v-icon :icon="mdiOpenInNew" />
|
||||
{{ report.metadata.submittedPath }}
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<strong>{{ t('feedback.review.detail.screenshot') }}</strong>
|
||||
<button
|
||||
v-if="report.screenshot"
|
||||
class="small-button"
|
||||
type="button"
|
||||
@click="feedbackStore.downloadScreenshot"
|
||||
>
|
||||
<v-icon :icon="mdiDownloadOutline" />
|
||||
{{ t('feedback.review.detail.download') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="report.screenshot"
|
||||
class="small-button"
|
||||
type="button"
|
||||
@click="feedbackStore.openScreenshot"
|
||||
>
|
||||
<v-icon :icon="mdiOpenInNew" />
|
||||
{{ t('feedback.review.detail.openOriginal') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="feedbackStore.screenshotPreviewUrl"
|
||||
class="screenshot-frame"
|
||||
>
|
||||
<img
|
||||
:src="feedbackStore.screenshotPreviewUrl"
|
||||
:alt="t('feedback.review.detail.screenshotAlt')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="empty-block"
|
||||
>
|
||||
{{ t('feedback.review.detail.noScreenshot') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="report.screenshot"
|
||||
class="file-meta"
|
||||
>
|
||||
<span>{{ report.screenshot.fileName }}</span>
|
||||
<span>{{ report.screenshot.contentType }}</span>
|
||||
<span>{{ formatFileSize(report.screenshot.sizeBytes) }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<strong>{{ t('feedback.review.detail.timeline') }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="timeline">
|
||||
<article
|
||||
v-for="item in timeline"
|
||||
:key="item.id"
|
||||
class="timeline-item"
|
||||
:class="{ 'timeline-comment': item.kind === 'Comment' }"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ item.actorDisplayName }}</strong>
|
||||
<span>{{ item.actorRole || t('feedback.review.detail.activityLabel') }}</span>
|
||||
</div>
|
||||
<p>{{ activityText(item) }}</p>
|
||||
<small>{{ formatDate(item.createdAt) }}</small>
|
||||
</article>
|
||||
|
||||
<div
|
||||
v-if="!timeline.length"
|
||||
class="empty-block"
|
||||
>
|
||||
{{ t('feedback.review.detail.noTimeline') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="comment-form"
|
||||
@submit.prevent="submitComment"
|
||||
>
|
||||
<v-textarea
|
||||
v-model="commentBody"
|
||||
:label="t('feedback.review.detail.commentLabel')"
|
||||
rows="3"
|
||||
auto-grow
|
||||
variant="outlined"
|
||||
hide-details
|
||||
/>
|
||||
<button
|
||||
class="primary-button"
|
||||
type="submit"
|
||||
:disabled="!canSubmitComment"
|
||||
>
|
||||
{{ feedbackStore.isCommenting ? t('feedback.review.detail.commenting') : t('feedback.review.detail.addComment') }}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<aside class="detail-side">
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<strong>{{ t('feedback.review.detail.reviewControls') }}</strong>
|
||||
</div>
|
||||
|
||||
<v-select
|
||||
v-model="form.type"
|
||||
:items="FEEDBACK_TYPES"
|
||||
:label="t('feedback.review.filters.type')"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
<v-select
|
||||
v-model="form.status"
|
||||
:items="statusOptions"
|
||||
:label="t('feedback.review.filters.status')"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
<v-combobox
|
||||
v-model="form.tags"
|
||||
:items="feedbackStore.tagOptions"
|
||||
:label="t('feedback.review.filters.tag')"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
>
|
||||
<template #chip="{ props, item }">
|
||||
<v-chip
|
||||
v-bind="props"
|
||||
size="small"
|
||||
>
|
||||
<v-icon
|
||||
start
|
||||
:icon="mdiTagOutline"
|
||||
/>
|
||||
{{ item.title }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-combobox>
|
||||
|
||||
<button
|
||||
class="primary-button"
|
||||
type="button"
|
||||
:disabled="feedbackStore.isSaving"
|
||||
@click="saveReviewChanges"
|
||||
>
|
||||
{{ feedbackStore.isSaving ? t('common.saving') : t('save') }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<strong>{{ t('feedback.review.detail.reporter') }}</strong>
|
||||
</div>
|
||||
<dl class="info-list">
|
||||
<div>
|
||||
<dt>{{ t('name') }}</dt>
|
||||
<dd>{{ report.reporterDisplayName }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('email') }}</dt>
|
||||
<dd>{{ report.reporterEmail }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<strong>{{ t('feedback.review.detail.metadata.title') }}</strong>
|
||||
</div>
|
||||
<dl class="info-list">
|
||||
<div
|
||||
v-for="[label, value] in metadataRows"
|
||||
:key="label"
|
||||
>
|
||||
<dt>{{ label }}</dt>
|
||||
<dd>{{ value || t('feedback.review.emptyValue') }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<strong>{{ t('feedback.review.detail.context.title') }}</strong>
|
||||
</div>
|
||||
<dl class="info-list">
|
||||
<div
|
||||
v-for="[label, value] in contextRows"
|
||||
:key="label"
|
||||
>
|
||||
<dt>{{ label }}</dt>
|
||||
<dd>{{ value || t('feedback.review.emptyValue') }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.feedback-detail-page {
|
||||
@apply mx-auto flex w-full max-w-7xl flex-col gap-5 px-5 py-8 md:px-8;
|
||||
}
|
||||
|
||||
.back-button,
|
||||
.small-button,
|
||||
.primary-button {
|
||||
@apply inline-flex w-fit items-center justify-center gap-2 rounded-lg border px-4 py-2 text-sm font-bold transition-colors;
|
||||
}
|
||||
|
||||
.back-button,
|
||||
.small-button {
|
||||
border-color: rgba(23, 32, 51, 0.12);
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.back-button:hover,
|
||||
.small-button:hover {
|
||||
background: #172033;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
border-color: #0f766e;
|
||||
background: #0f766e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.primary-button:disabled {
|
||||
@apply cursor-not-allowed opacity-50;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
@apply rounded-lg border p-5;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
@apply text-xs font-bold uppercase tracking-[0.22em];
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.detail-header h1 {
|
||||
@apply mt-2 line-clamp-3 text-2xl font-black md:text-3xl;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.header-meta,
|
||||
.file-meta {
|
||||
@apply mt-3 flex flex-wrap gap-2;
|
||||
}
|
||||
|
||||
.header-meta span,
|
||||
.file-meta span {
|
||||
@apply rounded-md px-2.5 py-1 text-xs font-bold;
|
||||
background: rgba(15, 118, 110, 0.08);
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
@apply grid gap-5 xl:grid-cols-[minmax(0,1fr)_22rem];
|
||||
}
|
||||
|
||||
.detail-main,
|
||||
.detail-side {
|
||||
@apply flex min-w-0 flex-col gap-5;
|
||||
}
|
||||
|
||||
.panel {
|
||||
@apply rounded-lg border p-5;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
@apply mb-4 flex flex-wrap items-center justify-between gap-3;
|
||||
}
|
||||
|
||||
.panel-header strong {
|
||||
@apply text-base font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.description {
|
||||
@apply whitespace-pre-wrap text-sm leading-7;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.path-link {
|
||||
@apply mt-4 inline-flex max-w-full items-center gap-2 break-all rounded-lg px-3 py-2 text-sm font-semibold;
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.screenshot-frame {
|
||||
@apply overflow-hidden rounded-lg border;
|
||||
border-color: rgba(23, 32, 51, 0.1);
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.screenshot-frame img {
|
||||
@apply block max-h-[38rem] w-full object-contain;
|
||||
}
|
||||
|
||||
.empty-block,
|
||||
.page-message {
|
||||
@apply rounded-lg border p-4 text-sm font-semibold;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
background: rgba(248, 250, 252, 0.9);
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.page-message-error {
|
||||
border-color: rgba(220, 38, 38, 0.24);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
@apply flex flex-col gap-3;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
@apply rounded-lg border p-4;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
background: rgba(248, 250, 252, 0.78);
|
||||
}
|
||||
|
||||
.timeline-comment {
|
||||
background: rgba(15, 118, 110, 0.06);
|
||||
}
|
||||
|
||||
.timeline-item div {
|
||||
@apply flex flex-wrap items-center gap-2;
|
||||
}
|
||||
|
||||
.timeline-item strong {
|
||||
@apply text-sm font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.timeline-item span,
|
||||
.timeline-item small {
|
||||
@apply text-xs font-semibold;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.timeline-item p {
|
||||
@apply mt-2 whitespace-pre-wrap text-sm leading-6;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.comment-form {
|
||||
@apply mt-5 flex flex-col gap-3;
|
||||
}
|
||||
|
||||
.info-list {
|
||||
@apply flex flex-col gap-3;
|
||||
}
|
||||
|
||||
.info-list div {
|
||||
@apply min-w-0;
|
||||
}
|
||||
|
||||
.info-list dt {
|
||||
@apply text-xs font-bold uppercase tracking-[0.14em];
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.info-list dd {
|
||||
@apply mt-1 break-words text-sm font-semibold;
|
||||
color: #172033;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,450 @@
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { FEEDBACK_STATUSES, FEEDBACK_TYPES, useDeveloperFeedbackStore } from '@/features/feedback/stores/developerFeedbackStore.js';
|
||||
import {
|
||||
mdiFilterOffOutline,
|
||||
mdiImageOutline,
|
||||
mdiMagnify,
|
||||
mdiMessageTextOutline,
|
||||
mdiRefresh,
|
||||
mdiTagOutline,
|
||||
} from '@mdi/js';
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const feedbackStore = useDeveloperFeedbackStore();
|
||||
|
||||
const sortOptions = computed(() => [
|
||||
{ title: t('feedback.review.sort.lastActivity'), value: 'lastActivity' },
|
||||
{ title: t('feedback.review.sort.newest'), value: 'newest' },
|
||||
{ title: t('feedback.review.sort.oldest'), value: 'oldest' },
|
||||
]);
|
||||
|
||||
const summary = computed(() => ({
|
||||
total: feedbackStore.reports.length,
|
||||
visible: feedbackStore.filteredReports.length,
|
||||
newCount: feedbackStore.reports.filter(report => report.status === 'New').length,
|
||||
plannedCount: feedbackStore.reports.filter(report => report.status === 'Planned').length,
|
||||
}));
|
||||
|
||||
onMounted(() => {
|
||||
feedbackStore.loadReports();
|
||||
});
|
||||
|
||||
function openReport(report) {
|
||||
router.push({ name: 'developer-feedback-detail', params: { id: report.id } });
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) {
|
||||
return t('feedback.review.emptyValue');
|
||||
}
|
||||
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function reportContext(report) {
|
||||
return [
|
||||
report.context?.workspaceName,
|
||||
report.context?.clientName,
|
||||
report.context?.projectName,
|
||||
report.context?.contentItemTitle,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' / ') || t('feedback.review.noContext');
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
return `status-${String(status ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="feedback-review-page">
|
||||
<header class="review-header">
|
||||
<div>
|
||||
<div class="eyebrow">{{ t('feedback.review.eyebrow') }}</div>
|
||||
<h1>{{ t('feedback.review.title') }}</h1>
|
||||
<p>{{ t('feedback.review.description') }}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="icon-button"
|
||||
type="button"
|
||||
:title="t('feedback.review.refresh')"
|
||||
@click="feedbackStore.loadReports"
|
||||
>
|
||||
<v-icon :icon="mdiRefresh" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="metric-grid">
|
||||
<article class="metric">
|
||||
<span>{{ t('feedback.review.metrics.total') }}</span>
|
||||
<strong>{{ summary.total }}</strong>
|
||||
</article>
|
||||
<article class="metric">
|
||||
<span>{{ t('feedback.review.metrics.visible') }}</span>
|
||||
<strong>{{ summary.visible }}</strong>
|
||||
</article>
|
||||
<article class="metric">
|
||||
<span>{{ t('feedback.review.metrics.new') }}</span>
|
||||
<strong>{{ summary.newCount }}</strong>
|
||||
</article>
|
||||
<article class="metric">
|
||||
<span>{{ t('feedback.review.metrics.planned') }}</span>
|
||||
<strong>{{ summary.plannedCount }}</strong>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="filter-panel">
|
||||
<label class="filter-search">
|
||||
<v-icon :icon="mdiMagnify" />
|
||||
<input
|
||||
v-model="feedbackStore.filters.search"
|
||||
type="search"
|
||||
:placeholder="t('feedback.review.filters.search')"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<v-select
|
||||
v-model="feedbackStore.filters.type"
|
||||
:items="FEEDBACK_TYPES"
|
||||
:label="t('feedback.review.filters.type')"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
clearable
|
||||
/>
|
||||
|
||||
<v-select
|
||||
v-model="feedbackStore.filters.status"
|
||||
:items="FEEDBACK_STATUSES"
|
||||
:label="t('feedback.review.filters.status')"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
clearable
|
||||
/>
|
||||
|
||||
<v-select
|
||||
v-model="feedbackStore.filters.tag"
|
||||
:items="feedbackStore.tagOptions"
|
||||
:label="t('feedback.review.filters.tag')"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
clearable
|
||||
/>
|
||||
|
||||
<input
|
||||
v-model="feedbackStore.filters.reporter"
|
||||
class="field"
|
||||
type="text"
|
||||
:placeholder="t('feedback.review.filters.reporter')"
|
||||
/>
|
||||
|
||||
<input
|
||||
v-model="feedbackStore.filters.workspace"
|
||||
class="field"
|
||||
type="text"
|
||||
:placeholder="t('feedback.review.filters.workspace')"
|
||||
/>
|
||||
|
||||
<input
|
||||
v-model="feedbackStore.filters.fromDate"
|
||||
class="field"
|
||||
type="date"
|
||||
:aria-label="t('feedback.review.filters.fromDate')"
|
||||
/>
|
||||
|
||||
<input
|
||||
v-model="feedbackStore.filters.toDate"
|
||||
class="field"
|
||||
type="date"
|
||||
:aria-label="t('feedback.review.filters.toDate')"
|
||||
/>
|
||||
|
||||
<v-select
|
||||
v-model="feedbackStore.filters.sort"
|
||||
:items="sortOptions"
|
||||
:label="t('feedback.review.filters.sort')"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
/>
|
||||
|
||||
<button
|
||||
class="filter-reset"
|
||||
type="button"
|
||||
:title="t('feedback.review.filters.clear')"
|
||||
@click="feedbackStore.resetFilters"
|
||||
>
|
||||
<v-icon :icon="mdiFilterOffOutline" />
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div
|
||||
v-if="feedbackStore.isLoading"
|
||||
class="page-message"
|
||||
>
|
||||
{{ t('feedback.review.loading') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="feedbackStore.error"
|
||||
class="page-message page-message-error"
|
||||
>
|
||||
{{ t(feedbackStore.error) }}
|
||||
</div>
|
||||
|
||||
<section
|
||||
v-else
|
||||
class="report-table"
|
||||
>
|
||||
<button
|
||||
v-for="report in feedbackStore.filteredReports"
|
||||
:key="report.id"
|
||||
class="report-row"
|
||||
type="button"
|
||||
@click="openReport(report)"
|
||||
>
|
||||
<span class="report-main">
|
||||
<span class="report-title">
|
||||
<span
|
||||
class="status-dot"
|
||||
:class="statusClass(report.status)"
|
||||
></span>
|
||||
<strong>{{ report.type }}</strong>
|
||||
<em>{{ report.status }}</em>
|
||||
</span>
|
||||
<span class="report-description">{{ report.description }}</span>
|
||||
<span class="report-tags">
|
||||
<span
|
||||
v-for="tag in report.tags"
|
||||
:key="tag"
|
||||
>
|
||||
<v-icon :icon="mdiTagOutline" />
|
||||
{{ tag }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="report-secondary">
|
||||
<span>{{ report.reporterDisplayName }}</span>
|
||||
<small>{{ report.reporterEmail }}</small>
|
||||
</span>
|
||||
|
||||
<span class="report-context">{{ reportContext(report) }}</span>
|
||||
|
||||
<span class="report-activity">
|
||||
<span>{{ t('feedback.review.lastActivity') }}</span>
|
||||
<strong>{{ formatDate(report.lastActivityAt) }}</strong>
|
||||
<small>
|
||||
<v-icon
|
||||
v-if="report.screenshot"
|
||||
:icon="mdiImageOutline"
|
||||
/>
|
||||
<v-icon
|
||||
v-if="report.timeline?.length"
|
||||
:icon="mdiMessageTextOutline"
|
||||
/>
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="!feedbackStore.filteredReports.length"
|
||||
class="page-message"
|
||||
>
|
||||
{{ t('feedback.review.empty') }}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.feedback-review-page {
|
||||
@apply mx-auto flex w-full max-w-7xl flex-col gap-5 px-5 py-8 md:px-8;
|
||||
}
|
||||
|
||||
.review-header {
|
||||
@apply flex flex-col justify-between gap-4 md:flex-row md:items-start;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
@apply text-xs font-bold uppercase tracking-[0.22em];
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.review-header h1 {
|
||||
@apply mt-2 text-3xl font-black md:text-4xl;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.review-header p {
|
||||
@apply mt-2 max-w-3xl text-sm leading-6;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.icon-button,
|
||||
.filter-reset {
|
||||
@apply inline-flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-lg border transition-colors;
|
||||
border-color: rgba(23, 32, 51, 0.12);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.icon-button:hover,
|
||||
.filter-reset:hover {
|
||||
background: #172033;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
@apply grid gap-3 sm:grid-cols-2 xl:grid-cols-4;
|
||||
}
|
||||
|
||||
.metric {
|
||||
@apply rounded-lg border p-4;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
|
||||
.metric span {
|
||||
@apply text-xs font-bold uppercase tracking-[0.16em];
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.metric strong {
|
||||
@apply mt-2 block text-3xl font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
@apply grid gap-3 rounded-lg border p-4 lg:grid-cols-[minmax(15rem,1.5fr)_repeat(4,minmax(9rem,1fr))_repeat(2,minmax(8rem,0.8fr))_minmax(10rem,1fr)_auto];
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.filter-search,
|
||||
.field {
|
||||
@apply flex h-10 items-center gap-2 rounded-lg border px-3 text-sm;
|
||||
border-color: rgba(23, 32, 51, 0.16);
|
||||
background: white;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.filter-search input {
|
||||
@apply min-w-0 flex-1 bg-transparent outline-none;
|
||||
}
|
||||
|
||||
.field {
|
||||
@apply w-full outline-none;
|
||||
}
|
||||
|
||||
.report-table {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.report-row {
|
||||
@apply grid gap-4 rounded-lg border p-4 text-left transition-colors lg:grid-cols-[minmax(0,1.55fr)_minmax(12rem,0.8fr)_minmax(12rem,0.8fr)_minmax(12rem,0.7fr)] lg:items-center;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
|
||||
.report-row:hover {
|
||||
border-color: rgba(15, 118, 110, 0.36);
|
||||
background: white;
|
||||
}
|
||||
|
||||
.report-main,
|
||||
.report-secondary,
|
||||
.report-activity {
|
||||
@apply flex min-w-0 flex-col gap-1;
|
||||
}
|
||||
|
||||
.report-title {
|
||||
@apply flex flex-wrap items-center gap-2;
|
||||
}
|
||||
|
||||
.report-title strong {
|
||||
@apply text-sm font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.report-title em {
|
||||
@apply rounded-md px-2 py-1 text-xs font-bold not-italic;
|
||||
background: rgba(15, 118, 110, 0.08);
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
@apply h-2.5 w-2.5 rounded-full;
|
||||
background: #64748b;
|
||||
}
|
||||
|
||||
.status-new {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.status-planned {
|
||||
background: #0f766e;
|
||||
}
|
||||
|
||||
.status-resolved {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
.status-won-t-do,
|
||||
.status-cancelled {
|
||||
background: #64748b;
|
||||
}
|
||||
|
||||
.report-description {
|
||||
@apply line-clamp-2 text-sm leading-6;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.report-tags {
|
||||
@apply mt-1 flex flex-wrap gap-1.5;
|
||||
}
|
||||
|
||||
.report-tags span {
|
||||
@apply inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-semibold;
|
||||
background: rgba(23, 32, 51, 0.06);
|
||||
color: #44516a;
|
||||
}
|
||||
|
||||
.report-secondary span,
|
||||
.report-context,
|
||||
.report-activity strong {
|
||||
@apply text-sm font-semibold;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.report-secondary small,
|
||||
.report-activity span,
|
||||
.report-activity small {
|
||||
@apply text-xs;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.report-activity small {
|
||||
@apply flex gap-1;
|
||||
}
|
||||
|
||||
.page-message {
|
||||
@apply rounded-lg border p-4 text-sm font-semibold;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.page-message-error {
|
||||
border-color: rgba(220, 38, 38, 0.24);
|
||||
color: #b91c1c;
|
||||
}
|
||||
</style>
|
||||
394
frontend/src/features/feedback/views/MyFeedbackDetailView.vue
Normal file
394
frontend/src/features/feedback/views/MyFeedbackDetailView.vue
Normal file
@@ -0,0 +1,394 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useToast } from 'vue-toastification';
|
||||
import { useMyFeedbackStore } from '@/features/feedback/stores/myFeedbackStore.js';
|
||||
import { mdiArrowLeft, mdiCancel, mdiOpenInNew, mdiTagOutline } from '@mdi/js';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const feedbackStore = useMyFeedbackStore();
|
||||
const commentBody = ref('');
|
||||
|
||||
const report = computed(() => feedbackStore.selectedReport);
|
||||
const timeline = computed(() =>
|
||||
[...(report.value?.timeline ?? [])].sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
|
||||
);
|
||||
const canSubmitComment = computed(() =>
|
||||
commentBody.value.trim().length > 0 && !feedbackStore.isCommenting
|
||||
);
|
||||
const canCancel = computed(() => report.value && !['Resolved', "Won't Do", 'Cancelled'].includes(report.value.status));
|
||||
|
||||
onMounted(() => {
|
||||
feedbackStore.loadReport(route.params.id);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
feedbackStore.clearSelectedReport();
|
||||
});
|
||||
|
||||
async function submitComment() {
|
||||
if (!canSubmitComment.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await feedbackStore.addComment(report.value.id, commentBody.value.trim());
|
||||
commentBody.value = '';
|
||||
toast.success(t('feedback.mine.detail.commentAdded'));
|
||||
} catch (error) {
|
||||
console.error('Failed to add feedback comment:', error);
|
||||
toast.error(t('feedback.mine.detail.commentFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelReport() {
|
||||
if (!canCancel.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reason = window.prompt(t('feedback.mine.detail.cancelPrompt'));
|
||||
if (reason === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await feedbackStore.cancelReport(report.value.id, reason.trim());
|
||||
toast.success(t('feedback.mine.detail.cancelled'));
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel feedback:', error);
|
||||
toast.error(t('feedback.mine.detail.cancelFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
return value ? new Date(value).toLocaleString() : t('feedback.review.emptyValue');
|
||||
}
|
||||
|
||||
function activityText(item) {
|
||||
if (item.kind === 'Comment') {
|
||||
return item.body;
|
||||
}
|
||||
|
||||
if (item.activityType === 'StatusChanged') {
|
||||
return t('feedback.review.detail.activity.statusChanged', {
|
||||
from: item.fromValue,
|
||||
to: item.toValue,
|
||||
});
|
||||
}
|
||||
|
||||
if (item.activityType === 'TypeChanged') {
|
||||
return t('feedback.review.detail.activity.typeChanged', {
|
||||
from: item.fromValue,
|
||||
to: item.toValue,
|
||||
});
|
||||
}
|
||||
|
||||
if (item.activityType === 'TagsChanged') {
|
||||
return t('feedback.review.detail.activity.tagsChanged', {
|
||||
from: item.fromValue || t('feedback.review.emptyValue'),
|
||||
to: item.toValue || t('feedback.review.emptyValue'),
|
||||
});
|
||||
}
|
||||
|
||||
return item.note || item.activityType || t('feedback.review.detail.activity.updated');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="feedback-detail-page">
|
||||
<button
|
||||
class="back-button"
|
||||
type="button"
|
||||
@click="router.push({ name: 'my-feedback' })"
|
||||
>
|
||||
<v-icon :icon="mdiArrowLeft" />
|
||||
{{ t('feedback.mine.detail.back') }}
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="feedbackStore.isDetailLoading"
|
||||
class="page-message"
|
||||
>
|
||||
{{ t('feedback.review.loading') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="feedbackStore.error"
|
||||
class="page-message page-message-error"
|
||||
>
|
||||
{{ t(feedbackStore.error) }}
|
||||
</div>
|
||||
|
||||
<template v-else-if="report">
|
||||
<header class="detail-header">
|
||||
<div>
|
||||
<div class="eyebrow">{{ t('feedback.mine.detail.eyebrow') }}</div>
|
||||
<h1>{{ report.type }}: {{ report.description }}</h1>
|
||||
<div class="header-meta">
|
||||
<span>{{ report.status }}</span>
|
||||
<span>{{ formatDate(report.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="canCancel"
|
||||
class="cancel-button"
|
||||
type="button"
|
||||
:disabled="feedbackStore.isCancelling"
|
||||
@click="cancelReport"
|
||||
>
|
||||
<v-icon :icon="mdiCancel" />
|
||||
{{ feedbackStore.isCancelling ? t('common.saving') : t('feedback.mine.detail.cancel') }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="detail-grid">
|
||||
<main class="detail-main">
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<strong>{{ t('feedback.review.detail.report') }}</strong>
|
||||
</div>
|
||||
<p class="description">{{ report.description }}</p>
|
||||
<a
|
||||
v-if="report.metadata?.submittedPath"
|
||||
class="path-link"
|
||||
:href="report.metadata.submittedPath"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<v-icon :icon="mdiOpenInNew" />
|
||||
{{ report.metadata.submittedPath }}
|
||||
</a>
|
||||
<div class="tag-row">
|
||||
<span
|
||||
v-for="tag in report.tags"
|
||||
:key="tag"
|
||||
>
|
||||
<v-icon :icon="mdiTagOutline" />
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<strong>{{ t('feedback.review.detail.screenshot') }}</strong>
|
||||
</div>
|
||||
<img
|
||||
v-if="feedbackStore.screenshotPreviewUrl"
|
||||
class="screenshot-preview"
|
||||
:src="feedbackStore.screenshotPreviewUrl"
|
||||
:alt="t('feedback.review.detail.screenshotAlt')"
|
||||
/>
|
||||
<p
|
||||
v-else
|
||||
class="muted"
|
||||
>
|
||||
{{ t('feedback.review.detail.noScreenshot') }}
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<aside class="detail-side">
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<strong>{{ t('feedback.review.detail.timeline') }}</strong>
|
||||
</div>
|
||||
|
||||
<ol
|
||||
v-if="timeline.length"
|
||||
class="timeline"
|
||||
>
|
||||
<li
|
||||
v-for="item in timeline"
|
||||
:key="item.id"
|
||||
>
|
||||
<strong>{{ item.actorDisplayName }}</strong>
|
||||
<span>{{ item.actorRole || t('feedback.review.detail.activityLabel') }}</span>
|
||||
<p>{{ activityText(item) }}</p>
|
||||
<small>{{ formatDate(item.createdAt) }}</small>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p
|
||||
v-else
|
||||
class="muted"
|
||||
>
|
||||
{{ t('feedback.review.detail.noTimeline') }}
|
||||
</p>
|
||||
|
||||
<v-textarea
|
||||
v-model="commentBody"
|
||||
class="mt-4"
|
||||
:label="t('feedback.mine.detail.commentLabel')"
|
||||
rows="3"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
/>
|
||||
|
||||
<button
|
||||
class="primary-button"
|
||||
type="button"
|
||||
:disabled="!canSubmitComment"
|
||||
@click="submitComment"
|
||||
>
|
||||
{{ feedbackStore.isCommenting ? t('feedback.review.detail.commenting') : t('feedback.review.detail.addComment') }}
|
||||
</button>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.feedback-detail-page {
|
||||
@apply mx-auto flex w-full max-w-7xl flex-col gap-5 px-5 py-8 md:px-8;
|
||||
}
|
||||
|
||||
.back-button,
|
||||
.cancel-button,
|
||||
.primary-button {
|
||||
@apply inline-flex w-fit items-center gap-2 rounded-lg px-4 py-2 text-sm font-bold transition-colors;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
color: #0f766e;
|
||||
background: rgba(15, 118, 110, 0.08);
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
border: 1px solid rgba(220, 38, 38, 0.24);
|
||||
color: #b91c1c;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
@apply mt-3 justify-center;
|
||||
background: #172033;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.primary-button:disabled,
|
||||
.cancel-button:disabled {
|
||||
@apply cursor-not-allowed opacity-60;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
@apply flex flex-col justify-between gap-4 md:flex-row md:items-start;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
@apply text-xs font-bold uppercase tracking-[0.22em];
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.detail-header h1 {
|
||||
@apply mt-2 max-w-4xl text-2xl font-black leading-tight md:text-4xl;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.header-meta {
|
||||
@apply mt-3 flex flex-wrap gap-2 text-xs font-bold;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.header-meta span {
|
||||
@apply rounded-md px-2 py-1;
|
||||
background: rgba(23, 32, 51, 0.06);
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
@apply grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(20rem,0.42fr)];
|
||||
}
|
||||
|
||||
.detail-main,
|
||||
.detail-side {
|
||||
@apply flex flex-col gap-5;
|
||||
}
|
||||
|
||||
.panel,
|
||||
.page-message {
|
||||
@apply rounded-lg border p-4;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
@apply mb-3 flex items-center justify-between gap-3;
|
||||
}
|
||||
|
||||
.panel-header strong {
|
||||
@apply text-sm font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.description {
|
||||
@apply whitespace-pre-wrap text-sm leading-6;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.path-link {
|
||||
@apply mt-3 inline-flex max-w-full items-center gap-2 truncate rounded-md px-2 py-1 text-sm font-semibold;
|
||||
background: rgba(15, 118, 110, 0.08);
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.tag-row {
|
||||
@apply mt-4 flex flex-wrap gap-1.5;
|
||||
}
|
||||
|
||||
.tag-row span {
|
||||
@apply inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-semibold;
|
||||
background: rgba(23, 32, 51, 0.06);
|
||||
color: #44516a;
|
||||
}
|
||||
|
||||
.screenshot-preview {
|
||||
@apply max-h-[34rem] w-full rounded-lg object-contain;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
@apply flex list-none flex-col gap-3 p-0;
|
||||
}
|
||||
|
||||
.timeline li {
|
||||
@apply rounded-lg border p-3;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
background: rgba(248, 250, 252, 0.75);
|
||||
}
|
||||
|
||||
.timeline strong,
|
||||
.timeline span,
|
||||
.timeline small {
|
||||
@apply block;
|
||||
}
|
||||
|
||||
.timeline strong {
|
||||
@apply text-sm font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.timeline span,
|
||||
.timeline small,
|
||||
.muted,
|
||||
.page-message {
|
||||
@apply text-sm;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.timeline p {
|
||||
@apply my-2 whitespace-pre-wrap text-sm leading-6;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.page-message-error {
|
||||
border-color: rgba(220, 38, 38, 0.24);
|
||||
color: #b91c1c;
|
||||
}
|
||||
</style>
|
||||
311
frontend/src/features/feedback/views/MyFeedbackListView.vue
Normal file
311
frontend/src/features/feedback/views/MyFeedbackListView.vue
Normal file
@@ -0,0 +1,311 @@
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { FEEDBACK_STATUSES, FEEDBACK_TYPES } from '@/features/feedback/stores/developerFeedbackStore.js';
|
||||
import { useMyFeedbackStore } from '@/features/feedback/stores/myFeedbackStore.js';
|
||||
import { mdiFilterOffOutline, mdiRefresh, mdiTagOutline } from '@mdi/js';
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const feedbackStore = useMyFeedbackStore();
|
||||
|
||||
const sortOptions = computed(() => [
|
||||
{ title: t('feedback.review.sort.lastActivity'), value: 'lastActivity' },
|
||||
{ title: t('feedback.review.sort.newest'), value: 'newest' },
|
||||
]);
|
||||
|
||||
const summary = computed(() => ({
|
||||
active: feedbackStore.reports.filter(report => ['New', 'Planned'].includes(report.status)).length,
|
||||
unread: feedbackStore.reports.filter(report => feedbackStore.unreadReportIds.has(report.id)).length,
|
||||
visible: feedbackStore.filteredReports.length,
|
||||
}));
|
||||
|
||||
onMounted(() => {
|
||||
feedbackStore.loadReports();
|
||||
});
|
||||
|
||||
function openReport(report) {
|
||||
router.push({ name: 'my-feedback-detail', params: { id: report.id } });
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
return value ? new Date(value).toLocaleString() : t('feedback.review.emptyValue');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="my-feedback-page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<div class="eyebrow">{{ t('feedback.mine.eyebrow') }}</div>
|
||||
<h1>{{ t('feedback.mine.title') }}</h1>
|
||||
<p>{{ t('feedback.mine.description') }}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="icon-button"
|
||||
type="button"
|
||||
:title="t('feedback.mine.refresh')"
|
||||
@click="feedbackStore.loadReports"
|
||||
>
|
||||
<v-icon :icon="mdiRefresh" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="metric-grid">
|
||||
<article class="metric">
|
||||
<span>{{ t('feedback.mine.metrics.active') }}</span>
|
||||
<strong>{{ summary.active }}</strong>
|
||||
</article>
|
||||
<article class="metric">
|
||||
<span>{{ t('feedback.mine.metrics.unread') }}</span>
|
||||
<strong>{{ summary.unread }}</strong>
|
||||
</article>
|
||||
<article class="metric">
|
||||
<span>{{ t('feedback.mine.metrics.visible') }}</span>
|
||||
<strong>{{ summary.visible }}</strong>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="filter-panel">
|
||||
<v-select
|
||||
v-model="feedbackStore.filters.type"
|
||||
:items="FEEDBACK_TYPES"
|
||||
:label="t('feedback.review.filters.type')"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
clearable
|
||||
/>
|
||||
|
||||
<v-select
|
||||
v-model="feedbackStore.filters.status"
|
||||
:items="FEEDBACK_STATUSES"
|
||||
:label="t('feedback.review.filters.status')"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
clearable
|
||||
/>
|
||||
|
||||
<v-select
|
||||
v-model="feedbackStore.filters.sort"
|
||||
:items="sortOptions"
|
||||
:label="t('feedback.review.filters.sort')"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
/>
|
||||
|
||||
<button
|
||||
class="icon-button"
|
||||
type="button"
|
||||
:title="t('feedback.review.filters.clear')"
|
||||
@click="feedbackStore.resetFilters"
|
||||
>
|
||||
<v-icon :icon="mdiFilterOffOutline" />
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div
|
||||
v-if="feedbackStore.isLoading"
|
||||
class="page-message"
|
||||
>
|
||||
{{ t('feedback.review.loading') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="feedbackStore.error"
|
||||
class="page-message page-message-error"
|
||||
>
|
||||
{{ t(feedbackStore.error) }}
|
||||
</div>
|
||||
|
||||
<section
|
||||
v-else
|
||||
class="report-list"
|
||||
>
|
||||
<button
|
||||
v-for="report in feedbackStore.filteredReports"
|
||||
:key="report.id"
|
||||
class="report-row"
|
||||
:class="{ 'report-row-unread': feedbackStore.unreadReportIds.has(report.id) }"
|
||||
type="button"
|
||||
@click="openReport(report)"
|
||||
>
|
||||
<span
|
||||
v-if="feedbackStore.unreadReportIds.has(report.id)"
|
||||
class="unread-dot"
|
||||
:title="t('feedback.mine.unread')"
|
||||
></span>
|
||||
<span class="report-main">
|
||||
<span class="report-title">
|
||||
<strong>{{ report.type }}</strong>
|
||||
<em>{{ report.status }}</em>
|
||||
</span>
|
||||
<span class="report-description">{{ report.description }}</span>
|
||||
<span class="report-tags">
|
||||
<span
|
||||
v-for="tag in report.tags"
|
||||
:key="tag"
|
||||
>
|
||||
<v-icon :icon="mdiTagOutline" />
|
||||
{{ tag }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="report-activity">
|
||||
<span>{{ t('feedback.review.lastActivity') }}</span>
|
||||
<strong>{{ formatDate(report.lastActivityAt) }}</strong>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="!feedbackStore.filteredReports.length"
|
||||
class="page-message"
|
||||
>
|
||||
{{ t('feedback.mine.empty') }}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.my-feedback-page {
|
||||
@apply mx-auto flex w-full max-w-6xl flex-col gap-5 px-5 py-8 md:px-8;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
@apply flex flex-col justify-between gap-4 md:flex-row md:items-start;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
@apply text-xs font-bold uppercase tracking-[0.22em];
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
@apply mt-2 text-3xl font-black md:text-4xl;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
@apply mt-2 max-w-3xl text-sm leading-6;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
@apply grid gap-3 md:grid-cols-3;
|
||||
}
|
||||
|
||||
.metric,
|
||||
.filter-panel,
|
||||
.report-row,
|
||||
.page-message {
|
||||
@apply rounded-lg border;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.metric {
|
||||
@apply p-4;
|
||||
}
|
||||
|
||||
.metric span {
|
||||
@apply text-xs font-bold uppercase tracking-[0.16em];
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.metric strong {
|
||||
@apply mt-2 block text-3xl font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
@apply grid gap-3 p-4 md:grid-cols-[repeat(3,minmax(10rem,1fr))_auto];
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
@apply inline-flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-lg border transition-colors;
|
||||
border-color: rgba(23, 32, 51, 0.12);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
background: #172033;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.report-list {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.report-row {
|
||||
@apply grid gap-4 p-4 text-left transition-colors md:grid-cols-[auto_minmax(0,1fr)_minmax(12rem,0.35fr)] md:items-center;
|
||||
}
|
||||
|
||||
.report-row:hover,
|
||||
.report-row-unread {
|
||||
border-color: rgba(15, 118, 110, 0.36);
|
||||
background: white;
|
||||
}
|
||||
|
||||
.unread-dot {
|
||||
@apply h-2.5 w-2.5 rounded-full;
|
||||
background: #0f766e;
|
||||
}
|
||||
|
||||
.report-main,
|
||||
.report-activity {
|
||||
@apply flex min-w-0 flex-col gap-1;
|
||||
}
|
||||
|
||||
.report-title {
|
||||
@apply flex flex-wrap items-center gap-2;
|
||||
}
|
||||
|
||||
.report-title strong,
|
||||
.report-activity strong {
|
||||
@apply text-sm font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.report-title em {
|
||||
@apply rounded-md px-2 py-1 text-xs font-bold not-italic;
|
||||
background: rgba(15, 118, 110, 0.08);
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.report-description {
|
||||
@apply line-clamp-2 text-sm leading-6;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.report-tags {
|
||||
@apply mt-1 flex flex-wrap gap-1.5;
|
||||
}
|
||||
|
||||
.report-tags span {
|
||||
@apply inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-semibold;
|
||||
background: rgba(23, 32, 51, 0.06);
|
||||
color: #44516a;
|
||||
}
|
||||
|
||||
.report-activity span {
|
||||
@apply text-xs;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.page-message {
|
||||
@apply p-4 text-sm font-semibold;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.page-message-error {
|
||||
border-color: rgba(220, 38, 38, 0.24);
|
||||
color: #b91c1c;
|
||||
}
|
||||
</style>
|
||||
45
frontend/src/features/notifications/notificationRoutes.js
Normal file
45
frontend/src/features/notifications/notificationRoutes.js
Normal file
@@ -0,0 +1,45 @@
|
||||
export function getNotificationRoute(notification, authStore) {
|
||||
const metadataRoute = getMetadataRoute(notification);
|
||||
if (metadataRoute) {
|
||||
return metadataRoute;
|
||||
}
|
||||
|
||||
if (isFeedbackNotification(notification)) {
|
||||
return {
|
||||
name: authStore.hasAnyRole(['Developer']) ? 'developer-feedback-detail' : 'my-feedback-detail',
|
||||
params: { id: notification.entityId },
|
||||
};
|
||||
}
|
||||
|
||||
if (notification.contentItemId) {
|
||||
return {
|
||||
name: 'content-item-detail',
|
||||
params: { id: notification.contentItemId },
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isFeedbackNotification(notification) {
|
||||
return notification.entityType === 'FeedbackReport' ||
|
||||
notification.eventType?.startsWith('Feedback.') ||
|
||||
getMetadata(notification)?.isFeedbackNotification === true;
|
||||
}
|
||||
|
||||
function getMetadataRoute(notification) {
|
||||
const route = getMetadata(notification)?.route;
|
||||
return typeof route === 'string' && route.startsWith('/app/') ? route : null;
|
||||
}
|
||||
|
||||
function getMetadata(notification) {
|
||||
if (!notification.metadataJson) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(notification.metadataJson);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { defineStore } from 'pinia';
|
||||
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||
import { useClient } from '@/plugins/api.js';
|
||||
import { isFeedbackNotification } from '@/features/notifications/notificationRoutes.js';
|
||||
|
||||
export const useNotificationsStore = defineStore('notifications', () => {
|
||||
const authStore = useAuthStore();
|
||||
@@ -18,6 +19,13 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
||||
);
|
||||
|
||||
const recentItems = computed(() => items.value.slice(0, 6));
|
||||
const unreadFeedbackReportIds = computed(() =>
|
||||
new Set(
|
||||
items.value
|
||||
.filter(item => !item.readAt && isFeedbackNotification(item) && item.entityId)
|
||||
.map(item => item.entityId)
|
||||
)
|
||||
);
|
||||
|
||||
function reset() {
|
||||
items.value = [];
|
||||
@@ -25,7 +33,7 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
||||
}
|
||||
|
||||
async function fetchNotifications() {
|
||||
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
|
||||
if (!authStore.isAuthenticated) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
@@ -34,11 +42,7 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await client.get('/api/notifications', {
|
||||
params: {
|
||||
workspaceId: workspaceStore.activeWorkspaceId,
|
||||
},
|
||||
});
|
||||
const response = await client.get('/api/notifications');
|
||||
|
||||
items.value = response.data ?? [];
|
||||
} catch (fetchError) {
|
||||
@@ -63,10 +67,18 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function markFeedbackReportAsRead(reportId) {
|
||||
const unreadNotifications = items.value.filter(item =>
|
||||
!item.readAt && isFeedbackNotification(item) && item.entityId === reportId
|
||||
);
|
||||
|
||||
await Promise.all(unreadNotifications.map(item => markAsRead(item.id)));
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [authStore.isAuthenticated, workspaceStore.activeWorkspaceId],
|
||||
async ([isAuthenticated, workspaceId]) => {
|
||||
if (!isAuthenticated || !workspaceId) {
|
||||
if (!isAuthenticated) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
@@ -79,11 +91,13 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
||||
return {
|
||||
items,
|
||||
recentItems,
|
||||
unreadFeedbackReportIds,
|
||||
unreadCount,
|
||||
isLoading,
|
||||
error,
|
||||
reset,
|
||||
fetchNotifications,
|
||||
markAsRead,
|
||||
markFeedbackReportAsRead,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
|
||||
import { useLanguageStore } from '@/stores/languageStore.js';
|
||||
import { useNotificationsStore } from '@/features/notifications/stores/notificationsStore.js';
|
||||
import { getNotificationRoute } from '@/features/notifications/notificationRoutes.js';
|
||||
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
|
||||
import { useProjectsStore } from '@/features/projects/stores/projectsStore.js';
|
||||
import { useUserProfileStore } from '@/features/user-profile/stores/userProfileStore.js';
|
||||
@@ -21,6 +22,7 @@
|
||||
mdiLan,
|
||||
mdiMagnify,
|
||||
mdiPlus,
|
||||
mdiBugOutline,
|
||||
} from '@mdi/js';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -53,8 +55,13 @@
|
||||
{ to: '/app/dashboard', labelKey: 'nav.overview', icon: mdiHomeOutline },
|
||||
{ to: '/app/workspace', labelKey: 'nav.workspacePlan', icon: mdiCalendarMonthOutline },
|
||||
{ to: '/app/media-library', labelKey: 'nav.mediaLibrary', icon: mdiImageMultipleOutline },
|
||||
{ to: '/app/my-feedback', labelKey: 'nav.myFeedback', icon: mdiBugOutline },
|
||||
{ to: '/app/feedback', labelKey: 'nav.feedbackReview', icon: mdiBugOutline, roles: ['Developer'] },
|
||||
{ to: '/app/workspace-settings', labelKey: 'nav.settings', icon: mdiCogOutline },
|
||||
];
|
||||
const visiblePrimaryLinks = computed(() =>
|
||||
primaryLinks.filter(link => !link.roles || authStore.hasAnyRole(link.roles))
|
||||
);
|
||||
|
||||
const openSections = ref({
|
||||
channels: false,
|
||||
@@ -111,6 +118,10 @@
|
||||
'content-item.status.updated': t('notifications.events.statusUpdated'),
|
||||
'asset.google-drive-linked': t('notifications.events.assetLinked'),
|
||||
'asset.revision.created': t('notifications.events.assetRevisionCreated'),
|
||||
'Feedback.ReportCreated': t('notifications.events.feedbackReportCreated'),
|
||||
'Feedback.DeveloperCommented': t('notifications.events.feedbackDeveloperCommented'),
|
||||
'Feedback.StatusChanged': t('notifications.events.feedbackStatusChanged'),
|
||||
'Feedback.ReporterCommented': t('notifications.events.feedbackReporterCommented'),
|
||||
}));
|
||||
|
||||
function toggleSection(sectionName) {
|
||||
@@ -159,8 +170,9 @@
|
||||
|
||||
isNotificationsOpen.value = false;
|
||||
|
||||
if (notification.contentItemId) {
|
||||
await router.push({ name: 'content-item-detail', params: { id: notification.contentItemId } });
|
||||
const notificationRoute = getNotificationRoute(notification, authStore);
|
||||
if (notificationRoute) {
|
||||
await router.push(notificationRoute);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,7 +384,7 @@
|
||||
|
||||
<div class="sidebar-section">
|
||||
<router-link
|
||||
v-for="link in primaryLinks"
|
||||
v-for="link in visiblePrimaryLinks"
|
||||
:key="link.to"
|
||||
:to="link.to"
|
||||
class="sidebar-link"
|
||||
|
||||
@@ -71,6 +71,8 @@
|
||||
"overview": "Overview",
|
||||
"workspacePlan": "Content",
|
||||
"mediaLibrary": "Media Library",
|
||||
"myFeedback": "My Feedback",
|
||||
"feedbackReview": "Feedback Review",
|
||||
"channels": "Channels",
|
||||
"projects": "Campaigns",
|
||||
"reviewQueue": "Review Queue",
|
||||
@@ -96,7 +98,11 @@
|
||||
"revisionCreated": "Revision created",
|
||||
"statusUpdated": "Status updated",
|
||||
"assetLinked": "Asset linked",
|
||||
"assetRevisionCreated": "Asset revision created"
|
||||
"assetRevisionCreated": "Asset revision created",
|
||||
"feedbackReportCreated": "New feedback report",
|
||||
"feedbackDeveloperCommented": "Developer commented",
|
||||
"feedbackStatusChanged": "Feedback status changed",
|
||||
"feedbackReporterCommented": "Reporter replied"
|
||||
}
|
||||
},
|
||||
"feedback": {
|
||||
@@ -132,6 +138,116 @@
|
||||
"text": "Text label",
|
||||
"undo": "Undo",
|
||||
"clear": "Clear and reset"
|
||||
},
|
||||
"review": {
|
||||
"eyebrow": "Developer review",
|
||||
"title": "Product feedback",
|
||||
"description": "Review submitted bugs, suggestions, and requests across all workspaces.",
|
||||
"refresh": "Refresh feedback",
|
||||
"loading": "Loading feedback...",
|
||||
"empty": "No feedback matches the current filters.",
|
||||
"emptyValue": "Not captured",
|
||||
"noContext": "No workspace context",
|
||||
"lastActivity": "Last activity",
|
||||
"metrics": {
|
||||
"total": "Total reports",
|
||||
"visible": "Visible",
|
||||
"new": "New",
|
||||
"planned": "Planned"
|
||||
},
|
||||
"filters": {
|
||||
"search": "Search feedback",
|
||||
"type": "Type",
|
||||
"status": "Status",
|
||||
"tag": "Tag",
|
||||
"reporter": "Reporter",
|
||||
"workspace": "Workspace",
|
||||
"fromDate": "From date",
|
||||
"toDate": "To date",
|
||||
"sort": "Sort",
|
||||
"clear": "Clear filters"
|
||||
},
|
||||
"sort": {
|
||||
"lastActivity": "Last activity",
|
||||
"newest": "Newest",
|
||||
"oldest": "Oldest"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Feedback could not be loaded.",
|
||||
"detailFailed": "The feedback report could not be loaded."
|
||||
},
|
||||
"detail": {
|
||||
"back": "Back to feedback",
|
||||
"eyebrow": "Feedback detail",
|
||||
"report": "Report",
|
||||
"screenshot": "Screenshot",
|
||||
"download": "Download original",
|
||||
"openOriginal": "Open original",
|
||||
"screenshotAlt": "Feedback screenshot",
|
||||
"noScreenshot": "No screenshot was attached.",
|
||||
"timeline": "Comments and activity",
|
||||
"noTimeline": "No comments or activity yet.",
|
||||
"commentLabel": "Developer comment",
|
||||
"addComment": "Add comment",
|
||||
"commenting": "Adding comment...",
|
||||
"commentAdded": "Comment added.",
|
||||
"commentFailed": "Comment could not be added.",
|
||||
"reviewControls": "Review controls",
|
||||
"saved": "Feedback updated.",
|
||||
"saveFailed": "Feedback could not be updated.",
|
||||
"reporter": "Reporter",
|
||||
"activityLabel": "Activity",
|
||||
"metadata": {
|
||||
"title": "Captured metadata",
|
||||
"path": "Submitted path",
|
||||
"userAgent": "Browser",
|
||||
"viewport": "Viewport",
|
||||
"appVersion": "App version",
|
||||
"created": "Created",
|
||||
"lastActivity": "Last activity"
|
||||
},
|
||||
"context": {
|
||||
"title": "Context",
|
||||
"workspace": "Workspace",
|
||||
"client": "Client",
|
||||
"project": "Campaign",
|
||||
"contentItem": "Content item"
|
||||
},
|
||||
"activity": {
|
||||
"updated": "Updated feedback.",
|
||||
"statusChanged": "Changed status from {from} to {to}.",
|
||||
"typeChanged": "Changed type from {from} to {to}.",
|
||||
"tagsChanged": "Changed tags from {from} to {to}."
|
||||
}
|
||||
}
|
||||
},
|
||||
"mine": {
|
||||
"eyebrow": "My feedback",
|
||||
"title": "My Feedback",
|
||||
"description": "Track the product feedback you have submitted across workspaces.",
|
||||
"refresh": "Refresh my feedback",
|
||||
"empty": "No feedback matches the current filters.",
|
||||
"unread": "Unread feedback activity",
|
||||
"metrics": {
|
||||
"active": "Active reports",
|
||||
"unread": "Unread",
|
||||
"visible": "Visible"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Your feedback could not be loaded.",
|
||||
"detailFailed": "This feedback report could not be loaded."
|
||||
},
|
||||
"detail": {
|
||||
"back": "Back to my feedback",
|
||||
"eyebrow": "My feedback detail",
|
||||
"commentLabel": "Follow-up comment",
|
||||
"commentAdded": "Comment added.",
|
||||
"commentFailed": "Comment could not be added.",
|
||||
"cancel": "Cancel report",
|
||||
"cancelPrompt": "Optional cancellation reason",
|
||||
"cancelled": "Feedback cancelled.",
|
||||
"cancelFailed": "Feedback could not be cancelled."
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
|
||||
@@ -71,6 +71,8 @@
|
||||
"overview": "Vue globale",
|
||||
"workspacePlan": "Contenu",
|
||||
"mediaLibrary": "Bibliotheque media",
|
||||
"myFeedback": "Mon feedback",
|
||||
"feedbackReview": "Revue feedback",
|
||||
"channels": "Canaux",
|
||||
"projects": "Campagnes",
|
||||
"reviewQueue": "File de révision",
|
||||
@@ -96,7 +98,11 @@
|
||||
"revisionCreated": "Révision créée",
|
||||
"statusUpdated": "Statut mis à jour",
|
||||
"assetLinked": "Ressource liée",
|
||||
"assetRevisionCreated": "Révision de ressource créée"
|
||||
"assetRevisionCreated": "Révision de ressource créée",
|
||||
"feedbackReportCreated": "Nouveau rapport de feedback",
|
||||
"feedbackDeveloperCommented": "Commentaire développeur",
|
||||
"feedbackStatusChanged": "Statut du feedback modifié",
|
||||
"feedbackReporterCommented": "Réponse du rapporteur"
|
||||
}
|
||||
},
|
||||
"feedback": {
|
||||
@@ -132,6 +138,116 @@
|
||||
"text": "Texte",
|
||||
"undo": "Annuler",
|
||||
"clear": "Effacer et réinitialiser"
|
||||
},
|
||||
"review": {
|
||||
"eyebrow": "Revue développeur",
|
||||
"title": "Feedback produit",
|
||||
"description": "Passez en revue les bugs, suggestions et demandes soumis dans tous les espaces.",
|
||||
"refresh": "Actualiser le feedback",
|
||||
"loading": "Chargement du feedback...",
|
||||
"empty": "Aucun feedback ne correspond aux filtres actuels.",
|
||||
"emptyValue": "Non capturé",
|
||||
"noContext": "Aucun contexte d'espace",
|
||||
"lastActivity": "Dernière activité",
|
||||
"metrics": {
|
||||
"total": "Rapports totaux",
|
||||
"visible": "Visibles",
|
||||
"new": "Nouveaux",
|
||||
"planned": "Planifiés"
|
||||
},
|
||||
"filters": {
|
||||
"search": "Rechercher du feedback",
|
||||
"type": "Type",
|
||||
"status": "Statut",
|
||||
"tag": "Tag",
|
||||
"reporter": "Rapporteur",
|
||||
"workspace": "Espace",
|
||||
"fromDate": "Date de début",
|
||||
"toDate": "Date de fin",
|
||||
"sort": "Tri",
|
||||
"clear": "Effacer les filtres"
|
||||
},
|
||||
"sort": {
|
||||
"lastActivity": "Dernière activité",
|
||||
"newest": "Plus récent",
|
||||
"oldest": "Plus ancien"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Le feedback n'a pas pu être chargé.",
|
||||
"detailFailed": "Le rapport de feedback n'a pas pu être chargé."
|
||||
},
|
||||
"detail": {
|
||||
"back": "Retour au feedback",
|
||||
"eyebrow": "Détail du feedback",
|
||||
"report": "Rapport",
|
||||
"screenshot": "Capture d'écran",
|
||||
"download": "Télécharger l'original",
|
||||
"openOriginal": "Ouvrir l'original",
|
||||
"screenshotAlt": "Capture d'écran du feedback",
|
||||
"noScreenshot": "Aucune capture n'a été jointe.",
|
||||
"timeline": "Commentaires et activité",
|
||||
"noTimeline": "Aucun commentaire ou activité pour le moment.",
|
||||
"commentLabel": "Commentaire développeur",
|
||||
"addComment": "Ajouter un commentaire",
|
||||
"commenting": "Ajout du commentaire...",
|
||||
"commentAdded": "Commentaire ajouté.",
|
||||
"commentFailed": "Le commentaire n'a pas pu être ajouté.",
|
||||
"reviewControls": "Contrôles de revue",
|
||||
"saved": "Feedback mis à jour.",
|
||||
"saveFailed": "Le feedback n'a pas pu être mis à jour.",
|
||||
"reporter": "Rapporteur",
|
||||
"activityLabel": "Activité",
|
||||
"metadata": {
|
||||
"title": "Métadonnées capturées",
|
||||
"path": "Chemin soumis",
|
||||
"userAgent": "Navigateur",
|
||||
"viewport": "Fenêtre",
|
||||
"appVersion": "Version de l'app",
|
||||
"created": "Créé",
|
||||
"lastActivity": "Dernière activité"
|
||||
},
|
||||
"context": {
|
||||
"title": "Contexte",
|
||||
"workspace": "Espace",
|
||||
"client": "Client",
|
||||
"project": "Campagne",
|
||||
"contentItem": "Élément de contenu"
|
||||
},
|
||||
"activity": {
|
||||
"updated": "Feedback mis à jour.",
|
||||
"statusChanged": "Statut modifié de {from} à {to}.",
|
||||
"typeChanged": "Type modifié de {from} à {to}.",
|
||||
"tagsChanged": "Tags modifiés de {from} à {to}."
|
||||
}
|
||||
}
|
||||
},
|
||||
"mine": {
|
||||
"eyebrow": "Mon feedback",
|
||||
"title": "Mon feedback",
|
||||
"description": "Suivez le feedback produit que vous avez soumis dans tous les espaces.",
|
||||
"refresh": "Actualiser mon feedback",
|
||||
"empty": "Aucun feedback ne correspond aux filtres actuels.",
|
||||
"unread": "Activité de feedback non lue",
|
||||
"metrics": {
|
||||
"active": "Rapports actifs",
|
||||
"unread": "Non lus",
|
||||
"visible": "Visibles"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Votre feedback n'a pas pu être chargé.",
|
||||
"detailFailed": "Ce rapport de feedback n'a pas pu être chargé."
|
||||
},
|
||||
"detail": {
|
||||
"back": "Retour à mon feedback",
|
||||
"eyebrow": "Détail de mon feedback",
|
||||
"commentLabel": "Commentaire de suivi",
|
||||
"commentAdded": "Commentaire ajouté.",
|
||||
"commentFailed": "Le commentaire n'a pas pu être ajouté.",
|
||||
"cancel": "Annuler le rapport",
|
||||
"cancelPrompt": "Raison d'annulation optionnelle",
|
||||
"cancelled": "Feedback annulé.",
|
||||
"cancelFailed": "Le feedback n'a pas pu être annulé."
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
|
||||
@@ -21,6 +21,10 @@ const WorkspaceSettingsView = () => import('@/features/workspaces/views/Workspac
|
||||
const ReviewQueueView = () => import('@/features/reviews/views/ReviewQueueView.vue');
|
||||
const ContentItemsView = () => import('@/features/content/views/ContentItemsView.vue');
|
||||
const ContentItemDetailView = () => import('@/features/content/views/ContentItemDetailView.vue');
|
||||
const MyFeedbackListView = () => import('@/features/feedback/views/MyFeedbackListView.vue');
|
||||
const MyFeedbackDetailView = () => import('@/features/feedback/views/MyFeedbackDetailView.vue');
|
||||
const DeveloperFeedbackListView = () => import('@/features/feedback/views/DeveloperFeedbackListView.vue');
|
||||
const DeveloperFeedbackDetailView = () => import('@/features/feedback/views/DeveloperFeedbackDetailView.vue');
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@@ -74,6 +78,30 @@ const routes = [
|
||||
component: ReviewQueueView,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/app/my-feedback',
|
||||
name: 'my-feedback',
|
||||
component: MyFeedbackListView,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/app/my-feedback/:id',
|
||||
name: 'my-feedback-detail',
|
||||
component: MyFeedbackDetailView,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/app/feedback',
|
||||
name: 'developer-feedback',
|
||||
component: DeveloperFeedbackListView,
|
||||
meta: { requiresAuth: true, roles: ['Developer'] },
|
||||
},
|
||||
{
|
||||
path: '/app/feedback/:id',
|
||||
name: 'developer-feedback-detail',
|
||||
component: DeveloperFeedbackDetailView,
|
||||
meta: { requiresAuth: true, roles: ['Developer'] },
|
||||
},
|
||||
{
|
||||
path: '/app/workspace-settings',
|
||||
name: 'workspace-settings',
|
||||
|
||||
Reference in New Issue
Block a user