{{ activityText(item) }}
+ {{ formatDate(item.createdAt) }} +{{ report.description }}
+ +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 @@
+
+
+
+ {{ report.description }} {{ activityText(item) }} {{ t('feedback.review.description') }} {{ report.description }}
+ {{ t('feedback.review.detail.noScreenshot') }}
+ {{ t('feedback.mine.description') }}{{ report.type }}: {{ report.description }}
+
+
+
{{ t('feedback.review.title') }}
+ {{ report.type }}: {{ report.description }}
+
+
+
{{ t('feedback.mine.title') }}
+