diff --git a/frontend/src/features/feedback/stores/developerFeedbackStore.js b/frontend/src/features/feedback/stores/developerFeedbackStore.js new file mode 100644 index 0000000..6febc7d --- /dev/null +++ b/frontend/src/features/feedback/stores/developerFeedbackStore.js @@ -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(); +} diff --git a/frontend/src/features/feedback/stores/myFeedbackStore.js b/frontend/src/features/feedback/stores/myFeedbackStore.js new file mode 100644 index 0000000..ff67338 --- /dev/null +++ b/frontend/src/features/feedback/stores/myFeedbackStore.js @@ -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(); +} diff --git a/frontend/src/features/feedback/views/DeveloperFeedbackDetailView.vue b/frontend/src/features/feedback/views/DeveloperFeedbackDetailView.vue new file mode 100644 index 0000000..7693170 --- /dev/null +++ b/frontend/src/features/feedback/views/DeveloperFeedbackDetailView.vue @@ -0,0 +1,623 @@ + + + + + diff --git a/frontend/src/features/feedback/views/DeveloperFeedbackListView.vue b/frontend/src/features/feedback/views/DeveloperFeedbackListView.vue new file mode 100644 index 0000000..5be8fa2 --- /dev/null +++ b/frontend/src/features/feedback/views/DeveloperFeedbackListView.vue @@ -0,0 +1,450 @@ + + + + + diff --git a/frontend/src/features/feedback/views/MyFeedbackDetailView.vue b/frontend/src/features/feedback/views/MyFeedbackDetailView.vue new file mode 100644 index 0000000..ec93b9b --- /dev/null +++ b/frontend/src/features/feedback/views/MyFeedbackDetailView.vue @@ -0,0 +1,394 @@ + + + + + diff --git a/frontend/src/features/feedback/views/MyFeedbackListView.vue b/frontend/src/features/feedback/views/MyFeedbackListView.vue new file mode 100644 index 0000000..75a38ef --- /dev/null +++ b/frontend/src/features/feedback/views/MyFeedbackListView.vue @@ -0,0 +1,311 @@ + + + + + diff --git a/frontend/src/features/notifications/notificationRoutes.js b/frontend/src/features/notifications/notificationRoutes.js new file mode 100644 index 0000000..f50ef5a --- /dev/null +++ b/frontend/src/features/notifications/notificationRoutes.js @@ -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; + } +} diff --git a/frontend/src/features/notifications/stores/notificationsStore.js b/frontend/src/features/notifications/stores/notificationsStore.js index 85d6c44..6603487 100644 --- a/frontend/src/features/notifications/stores/notificationsStore.js +++ b/frontend/src/features/notifications/stores/notificationsStore.js @@ -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, }; }); diff --git a/frontend/src/layouts/main/AppSidebar.vue b/frontend/src/layouts/main/AppSidebar.vue index 0908d81..24866bf 100644 --- a/frontend/src/layouts/main/AppSidebar.vue +++ b/frontend/src/layouts/main/AppSidebar.vue @@ -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 @@