462 lines
14 KiB
Vue
462 lines
14 KiB
Vue
<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?.campaignName,
|
|
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>
|
|
|
|
<v-btn variant="text" :ripple="false"
|
|
class="icon-button"
|
|
type="button"
|
|
:title="t('feedback.review.refresh')"
|
|
@click="feedbackStore.loadReports"
|
|
>
|
|
<v-icon :icon="mdiRefresh" />
|
|
</v-btn>
|
|
</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">
|
|
<v-text-field
|
|
v-model="feedbackStore.filters.search"
|
|
:label="t('feedback.review.filters.search')"
|
|
:prepend-inner-icon="mdiMagnify"
|
|
density="compact"
|
|
variant="outlined"
|
|
hide-details
|
|
clearable
|
|
type="search"
|
|
/>
|
|
|
|
<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
|
|
/>
|
|
|
|
<v-text-field
|
|
v-model="feedbackStore.filters.reporter"
|
|
:label="t('feedback.review.filters.reporter')"
|
|
density="compact"
|
|
variant="outlined"
|
|
hide-details
|
|
clearable
|
|
/>
|
|
|
|
<v-text-field
|
|
v-model="feedbackStore.filters.workspace"
|
|
:label="t('feedback.review.filters.workspace')"
|
|
density="compact"
|
|
variant="outlined"
|
|
hide-details
|
|
clearable
|
|
/>
|
|
|
|
<v-text-field
|
|
v-model="feedbackStore.filters.fromDate"
|
|
:label="t('feedback.review.filters.fromDate')"
|
|
density="compact"
|
|
variant="outlined"
|
|
hide-details
|
|
type="date"
|
|
/>
|
|
|
|
<v-text-field
|
|
v-model="feedbackStore.filters.toDate"
|
|
:label="t('feedback.review.filters.toDate')"
|
|
density="compact"
|
|
variant="outlined"
|
|
hide-details
|
|
type="date"
|
|
/>
|
|
|
|
<v-select
|
|
v-model="feedbackStore.filters.sort"
|
|
:items="sortOptions"
|
|
:label="t('feedback.review.filters.sort')"
|
|
density="compact"
|
|
variant="outlined"
|
|
hide-details
|
|
/>
|
|
|
|
<v-btn variant="text" :ripple="false"
|
|
class="filter-reset"
|
|
type="button"
|
|
:title="t('feedback.review.filters.clear')"
|
|
@click="feedbackStore.resetFilters"
|
|
>
|
|
<v-icon :icon="mdiFilterOffOutline" />
|
|
</v-btn>
|
|
</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"
|
|
>
|
|
<v-btn variant="text" :ripple="false"
|
|
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>
|
|
</v-btn>
|
|
|
|
<div
|
|
v-if="!feedbackStore.filteredReports.length"
|
|
class="page-message"
|
|
>
|
|
{{ t('feedback.review.empty') }}
|
|
</div>
|
|
</section>
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
@reference "@/assets/main.css";
|
|
.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: var(--app-color-on-tertiary);
|
|
}
|
|
|
|
.review-header h1 {
|
|
@apply mt-2 text-3xl font-black md:text-4xl;
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.review-header p {
|
|
@apply mt-2 max-w-3xl text-sm leading-6;
|
|
color: var(--app-text-muted);
|
|
}
|
|
|
|
.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: var(--app-border-subtle);
|
|
background: rgba(255, 255, 255, 0.92);
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.icon-button:hover,
|
|
.filter-reset:hover {
|
|
background: var(--app-color-on-surface);
|
|
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: var(--app-border-subtle);
|
|
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: var(--app-color-on-surface);
|
|
}
|
|
|
|
.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: var(--app-border-subtle);
|
|
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: var(--app-color-on-surface);
|
|
}
|
|
|
|
.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: var(--app-border-subtle);
|
|
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: var(--app-color-on-surface);
|
|
}
|
|
|
|
.report-title em {
|
|
@apply rounded-md px-2 py-1 text-xs font-bold not-italic;
|
|
background: rgba(15, 118, 110, 0.08);
|
|
color: var(--app-color-on-tertiary);
|
|
}
|
|
|
|
.status-dot {
|
|
@apply h-2.5 w-2.5 rounded-full;
|
|
background: #64748b;
|
|
}
|
|
|
|
.status-new {
|
|
background: #2563eb;
|
|
}
|
|
|
|
.status-planned {
|
|
background: var(--app-color-on-tertiary);
|
|
}
|
|
|
|
.status-resolved {
|
|
background: #16a34a;
|
|
}
|
|
|
|
.status-won-t-do,
|
|
.status-cancelled {
|
|
background: #64748b;
|
|
}
|
|
|
|
.report-description {
|
|
@apply line-clamp-2 text-sm leading-6;
|
|
color: var(--app-text-muted);
|
|
}
|
|
|
|
.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: var(--app-control-hover);
|
|
color: #44516a;
|
|
}
|
|
|
|
.report-secondary span,
|
|
.report-context,
|
|
.report-activity strong {
|
|
@apply text-sm font-semibold;
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.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: var(--app-border-subtle);
|
|
background: rgba(255, 255, 255, 0.86);
|
|
color: var(--app-text-muted);
|
|
}
|
|
|
|
.page-message-error {
|
|
border-color: rgba(220, 38, 38, 0.24);
|
|
color: var(--app-danger-muted);
|
|
}
|
|
</style>
|