generation report
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
<template>
|
||||
<div class="grid">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<h5>Генерация отчёта</h5>
|
||||
<p>Заполните форму для создания нового отчёта на основе собранных данных.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 lg:col-8">
|
||||
<div class="card">
|
||||
<form @submit.prevent="handleGenerate" class="p-fluid">
|
||||
<div class="grid">
|
||||
<div class="col-12">
|
||||
<div class="field">
|
||||
<label for="reportTitle">Заголовок отчёта *</label>
|
||||
<InputText id="reportTitle" v-model="formData.reportTitle" :class="{ 'p-invalid': errors.reportTitle }" placeholder="Введите заголовок отчёта" size="large" />
|
||||
<small v-if="errors.reportTitle" class="p-error">{{ errors.reportTitle }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6">
|
||||
<div class="field">
|
||||
<label for="authorName">Автор *</label>
|
||||
<InputText id="authorName" v-model="formData.authorName" :class="{ 'p-invalid': errors.authorName }" placeholder="Введите имя автора" />
|
||||
<small v-if="errors.authorName" class="p-error">{{ errors.authorName }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6">
|
||||
<div class="field">
|
||||
<label for="companyName">Компания *</label>
|
||||
<InputText id="companyName" v-model="formData.companyName" :class="{ 'p-invalid': errors.companyName }" placeholder="Введите название компании" />
|
||||
<small v-if="errors.companyName" class="p-error">{{ errors.companyName }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6">
|
||||
<div class="field">
|
||||
<label for="startDate">Дата начала *</label>
|
||||
<Calendar id="startDate" v-model="formData.startDate" :class="{ 'p-invalid': errors.startDate }" dateFormat="dd.mm.yy" showTime hourFormat="24" placeholder="Выберите дату начала" :maxDate="formData.endDate" />
|
||||
<small v-if="errors.startDate" class="p-error">{{ errors.startDate }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6">
|
||||
<div class="field">
|
||||
<label for="endDate">Дата окончания *</label>
|
||||
<Calendar id="endDate" v-model="formData.endDate" :class="{ 'p-invalid': errors.endDate }" dateFormat="dd.mm.yy" showTime hourFormat="24" placeholder="Выберите дату окончания" :minDate="formData.startDate" />
|
||||
<small v-if="errors.endDate" class="p-error">{{ errors.endDate }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6">
|
||||
<div class="field">
|
||||
<label for="format">Формат файла *</label>
|
||||
<Dropdown id="format" v-model="formData.format" :options="formatOptions" optionLabel="label" optionValue="value" placeholder="Выберите формат" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6">
|
||||
<div class="field">
|
||||
<label for="template">Шаблон отчёта</label>
|
||||
<Dropdown id="template" v-model="formData.template" :options="templateOptions" optionLabel="label" optionValue="value" placeholder="Выберите шаблон" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="field">
|
||||
<label for="description">Описание отчёта</label>
|
||||
<Textarea id="description" v-model="formData.description" rows="3" placeholder="Введите краткое описание отчёта (необязательно)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="flex gap-2">
|
||||
<Button type="submit" label="Сгенерировать отчёт" icon="pi pi-file-pdf" :loading="loading" :disabled="loading" size="large" />
|
||||
<Button type="button" label="Очистить форму" icon="pi pi-refresh" severity="secondary" @click="resetForm" :disabled="loading" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 lg:col-4">
|
||||
<div class="card">
|
||||
<h6>Информация о генерации</h6>
|
||||
<div class="flex flex-column gap-3">
|
||||
<div class="flex align-items-center gap-2">
|
||||
<i class="pi pi-info-circle text-blue-500"></i>
|
||||
<span class="text-sm">Отчёт будет создан на основе данных за выбранный период</span>
|
||||
</div>
|
||||
<div class="flex align-items-center gap-2">
|
||||
<i class="pi pi-clock text-orange-500"></i>
|
||||
<span class="text-sm">Время генерации: 1-3 минуты</span>
|
||||
</div>
|
||||
<div class="flex align-items-center gap-2">
|
||||
<i class="pi pi-download text-green-500"></i>
|
||||
<span class="text-sm">Файл будет автоматически скачан</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<h6>Поддерживаемые форматы</h6>
|
||||
<div class="flex flex-column gap-2">
|
||||
<div class="flex align-items-center gap-2">
|
||||
<i class="pi pi-file-pdf text-red-500"></i>
|
||||
<span class="text-sm">PDF - для печати и просмотра</span>
|
||||
</div>
|
||||
<div class="flex align-items-center gap-2">
|
||||
<i class="pi pi-file-word text-blue-500"></i>
|
||||
<span class="text-sm">DOCX - для редактирования</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ReportService from '@/service/ReportService.js';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
|
||||
// Реактивные данные
|
||||
const loading = ref(false);
|
||||
|
||||
// Данные формы
|
||||
const formData = reactive({
|
||||
reportTitle: '',
|
||||
authorName: '',
|
||||
companyName: '',
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
format: 'PDF',
|
||||
template: 'standard',
|
||||
description: ''
|
||||
});
|
||||
|
||||
// Ошибки валидации
|
||||
const errors = reactive({
|
||||
reportTitle: null,
|
||||
authorName: null,
|
||||
companyName: null,
|
||||
startDate: null,
|
||||
endDate: null
|
||||
});
|
||||
|
||||
// Опции
|
||||
const formatOptions = [
|
||||
{ label: 'PDF', value: 'PDF' },
|
||||
{ label: 'DOCX', value: 'DOCX' }
|
||||
];
|
||||
|
||||
const templateOptions = [
|
||||
{ label: 'Стандартный', value: 'standard' },
|
||||
{ label: 'Краткий', value: 'brief' },
|
||||
{ label: 'Подробный', value: 'detailed' }
|
||||
];
|
||||
|
||||
// Методы
|
||||
const validateForm = () => {
|
||||
// Очистка предыдущих ошибок
|
||||
Object.keys(errors).forEach((key) => (errors[key] = null));
|
||||
|
||||
let isValid = true;
|
||||
|
||||
if (!formData.reportTitle.trim()) {
|
||||
errors.reportTitle = 'Заголовок отчёта обязателен';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (!formData.authorName.trim()) {
|
||||
errors.authorName = 'Имя автора обязательно';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (!formData.companyName.trim()) {
|
||||
errors.companyName = 'Название компании обязательно';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (!formData.startDate) {
|
||||
errors.startDate = 'Дата начала обязательна';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (!formData.endDate) {
|
||||
errors.endDate = 'Дата окончания обязательна';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (formData.startDate && formData.endDate && formData.startDate >= formData.endDate) {
|
||||
errors.endDate = 'Дата окончания должна быть позже даты начала';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
return isValid;
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!validateForm()) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Ошибка валидации',
|
||||
detail: 'Пожалуйста, исправьте ошибки в форме',
|
||||
life: 3000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
// Подготовка данных для API
|
||||
const reportData = {
|
||||
reportTitle: formData.reportTitle.trim(),
|
||||
authorName: formData.authorName.trim(),
|
||||
companyName: formData.companyName.trim(),
|
||||
startDate: formData.startDate.toISOString(),
|
||||
endDate: formData.endDate.toISOString(),
|
||||
format: formData.format
|
||||
};
|
||||
|
||||
const blob = await ReportService.generateReport(reportData);
|
||||
|
||||
// Создание имени файла
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||
const filename = `report_${timestamp}.${formData.format.toLowerCase()}`;
|
||||
|
||||
// Скачивание файла
|
||||
ReportService.downloadFile(blob, filename);
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Успешно',
|
||||
detail: 'Отчёт сгенерирован и скачан',
|
||||
life: 3000
|
||||
});
|
||||
|
||||
// Переход к истории отчётов
|
||||
setTimeout(() => {
|
||||
router.push('/reports/history');
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error('Ошибка генерации отчёта:', error);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Ошибка',
|
||||
detail: ReportService.handleError(error),
|
||||
life: 5000
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
reportTitle: '',
|
||||
authorName: '',
|
||||
companyName: '',
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
format: 'PDF',
|
||||
template: 'standard',
|
||||
description: ''
|
||||
});
|
||||
|
||||
Object.keys(errors).forEach((key) => (errors[key] = null));
|
||||
};
|
||||
|
||||
// Инициализация
|
||||
onMounted(() => {
|
||||
// Установка дат по умолчанию (последние 7 дней)
|
||||
const today = new Date();
|
||||
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
formData.endDate = today;
|
||||
formData.startDate = weekAgo;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.p-fluid .field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-column {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.gap-3 {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.align-items-center {
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,498 @@
|
||||
<template>
|
||||
<div class="grid">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5>История отчётов</h5>
|
||||
<p>Просмотр и скачивание ранее созданных отчётов</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button icon="pi pi-refresh" @click="loadReports" :loading="loading" severity="secondary" text v-tooltip.top="'Обновить список'" />
|
||||
<Button label="Создать отчёт" icon="pi pi-plus" @click="goToGenerator" severity="success" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Фильтры -->
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<h6>Фильтры</h6>
|
||||
<div class="grid">
|
||||
<div class="col-12 md:col-3">
|
||||
<div class="field">
|
||||
<label for="search">Поиск</label>
|
||||
<InputText id="search" v-model="filters.search" placeholder="Поиск по заголовку, автору или компании" @input="onSearch" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 md:col-2">
|
||||
<div class="field">
|
||||
<label for="format">Формат</label>
|
||||
<Dropdown id="format" v-model="filters.format" :options="formatOptions" optionLabel="label" optionValue="value" placeholder="Все форматы" @change="applyFilters" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 md:col-2">
|
||||
<div class="field">
|
||||
<label for="dateFrom">Дата с</label>
|
||||
<Calendar id="dateFrom" v-model="filters.dateFrom" dateFormat="dd.mm.yy" placeholder="Выберите дату" @date-select="applyFilters" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 md:col-2">
|
||||
<div class="field">
|
||||
<label for="dateTo">Дата по</label>
|
||||
<Calendar id="dateTo" v-model="filters.dateTo" dateFormat="dd.mm.yy" placeholder="Выберите дату" @date-select="applyFilters" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 md:col-3">
|
||||
<div class="field">
|
||||
<label> </label>
|
||||
<div class="flex gap-2">
|
||||
<Button label="Применить" icon="pi pi-filter" @click="applyFilters" severity="info" />
|
||||
<Button label="Сбросить" icon="pi pi-times" @click="resetFilters" severity="secondary" text />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Статистика -->
|
||||
<div class="col-12">
|
||||
<div class="grid">
|
||||
<div class="col-12 md:col-3">
|
||||
<div class="card text-center">
|
||||
<div class="text-2xl font-bold text-blue-500">{{ totalReports }}</div>
|
||||
<div class="text-sm text-600">Всего отчётов</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 md:col-3">
|
||||
<div class="card text-center">
|
||||
<div class="text-2xl font-bold text-green-500">{{ thisMonthReports }}</div>
|
||||
<div class="text-sm text-600">За этот месяц</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 md:col-3">
|
||||
<div class="card text-center">
|
||||
<div class="text-2xl font-bold text-orange-500">{{ thisWeekReports }}</div>
|
||||
<div class="text-sm text-600">За эту неделю</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 md:col-3">
|
||||
<div class="card text-center">
|
||||
<div class="text-2xl font-bold text-purple-500">{{ totalSize }}</div>
|
||||
<div class="text-sm text-600">Общий размер</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Таблица отчётов -->
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<DataTable
|
||||
:value="reports"
|
||||
:loading="loading"
|
||||
:paginator="true"
|
||||
:rows="pageSize"
|
||||
:totalRecords="totalElements"
|
||||
:lazy="true"
|
||||
@page="onPageChange"
|
||||
@sort="onSort"
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
||||
:rowsPerPageOptions="[10, 20, 50]"
|
||||
currentPageReportTemplate="Показано {first} - {last} из {totalRecords} записей"
|
||||
emptyMessage="Отчёты не найдены"
|
||||
responsiveLayout="scroll"
|
||||
:sortField="sortField"
|
||||
:sortOrder="sortOrder"
|
||||
removableSort
|
||||
>
|
||||
<Column field="reportTitle" header="Заголовок" sortable>
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-column">
|
||||
<div class="font-semibold">{{ data.reportTitle }}</div>
|
||||
<div class="text-sm text-600" v-if="data.description">{{ data.description }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="authorName" header="Автор" sortable>
|
||||
<template #body="{ data }">
|
||||
<div class="flex align-items-center gap-2">
|
||||
<i class="pi pi-user text-blue-500"></i>
|
||||
<span>{{ data.authorName }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="companyName" header="Компания" sortable>
|
||||
<template #body="{ data }">
|
||||
<div class="flex align-items-center gap-2">
|
||||
<i class="pi pi-building text-green-500"></i>
|
||||
<span>{{ data.companyName }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="startDate" header="Период" sortable>
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-column">
|
||||
<div class="text-sm">{{ formatDate(data.startDate) }}</div>
|
||||
<div class="text-sm text-600">по {{ formatDate(data.endDate) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="format" header="Формат" sortable>
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.format" :severity="data.format === 'PDF' ? 'danger' : 'info'" :icon="data.format === 'PDF' ? 'pi pi-file-pdf' : 'pi pi-file-word'" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="fileSize" header="Размер" sortable>
|
||||
<template #body="{ data }">
|
||||
<div class="flex align-items-center gap-2">
|
||||
<i class="pi pi-database text-orange-500"></i>
|
||||
<span>{{ formatFileSize(data.fileSize) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="createdAt" header="Создан" sortable>
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-column">
|
||||
<div class="text-sm">{{ formatDateTime(data.createdAt) }}</div>
|
||||
<div class="text-xs text-600">{{ getTimeAgo(data.createdAt) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Действия" :exportable="false" style="min-width: 120px">
|
||||
<template #body="{ data }">
|
||||
<div class="flex gap-1">
|
||||
<Button icon="pi pi-download" @click="handleDownload(data.id)" severity="success" text rounded :loading="downloadingReports.includes(data.id)" v-tooltip.top="'Скачать отчёт'" />
|
||||
<Button icon="pi pi-eye" @click="viewReport(data)" severity="info" text rounded v-tooltip.top="'Просмотреть детали'" />
|
||||
<Button icon="pi pi-trash" @click="confirmDelete(data)" severity="danger" text rounded v-tooltip.top="'Удалить отчёт'" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Диалог просмотра отчёта -->
|
||||
<Dialog v-model:visible="showReportDialog" :header="selectedReport?.reportTitle" :modal="true" :style="{ width: '50vw' }" :maximizable="true">
|
||||
<div v-if="selectedReport" class="flex flex-column gap-3">
|
||||
<div class="grid">
|
||||
<div class="col-6"><strong>Автор:</strong> {{ selectedReport.authorName }}</div>
|
||||
<div class="col-6"><strong>Компания:</strong> {{ selectedReport.companyName }}</div>
|
||||
<div class="col-6"><strong>Период:</strong> {{ formatDate(selectedReport.startDate) }} - {{ formatDate(selectedReport.endDate) }}</div>
|
||||
<div class="col-6">
|
||||
<strong>Формат:</strong>
|
||||
<Tag :value="selectedReport.format" :severity="selectedReport.format === 'PDF' ? 'danger' : 'info'" />
|
||||
</div>
|
||||
<div class="col-6"><strong>Размер:</strong> {{ formatFileSize(selectedReport.fileSize) }}</div>
|
||||
<div class="col-6"><strong>Создан:</strong> {{ formatDateTime(selectedReport.createdAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Скачать" icon="pi pi-download" @click="handleDownload(selectedReport?.id)" severity="success" />
|
||||
<Button label="Закрыть" icon="pi pi-times" @click="showReportDialog = false" severity="secondary" text />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Диалог подтверждения удаления -->
|
||||
<Dialog v-model:visible="showDeleteDialog" :header="'Удаление отчёта'" :modal="true" :style="{ width: '25vw' }">
|
||||
<div class="flex align-items-center gap-3">
|
||||
<i class="pi pi-exclamation-triangle text-orange-500 text-2xl"></i>
|
||||
<span>Вы уверены, что хотите удалить этот отчёт? Это действие нельзя отменить.</span>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Отмена" icon="pi pi-times" @click="showDeleteDialog = false" severity="secondary" text />
|
||||
<Button label="Удалить" icon="pi pi-trash" @click="handleDelete" severity="danger" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ReportService from '@/service/ReportService.js';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
|
||||
// Реактивные данные
|
||||
const loading = ref(false);
|
||||
const reports = ref([]);
|
||||
const totalElements = ref(0);
|
||||
const pageSize = ref(20);
|
||||
const currentPage = ref(0);
|
||||
const downloadingReports = ref([]);
|
||||
const showReportDialog = ref(false);
|
||||
const showDeleteDialog = ref(false);
|
||||
const selectedReport = ref(null);
|
||||
const reportToDelete = ref(null);
|
||||
|
||||
// Фильтры
|
||||
const filters = reactive({
|
||||
search: '',
|
||||
format: null,
|
||||
dateFrom: null,
|
||||
dateTo: null
|
||||
});
|
||||
|
||||
// Сортировка
|
||||
const sortField = ref('createdAt');
|
||||
const sortOrder = ref(-1);
|
||||
|
||||
// Опции
|
||||
const formatOptions = [
|
||||
{ label: 'Все форматы', value: null },
|
||||
{ label: 'PDF', value: 'PDF' },
|
||||
{ label: 'DOCX', value: 'DOCX' }
|
||||
];
|
||||
|
||||
// Вычисляемые свойства
|
||||
const totalReports = computed(() => totalElements.value);
|
||||
|
||||
const thisMonthReports = computed(() => {
|
||||
const now = new Date();
|
||||
const thisMonth = now.getMonth();
|
||||
const thisYear = now.getFullYear();
|
||||
|
||||
return reports.value.filter((report) => {
|
||||
const reportDate = new Date(report.createdAt);
|
||||
return reportDate.getMonth() === thisMonth && reportDate.getFullYear() === thisYear;
|
||||
}).length;
|
||||
});
|
||||
|
||||
const thisWeekReports = computed(() => {
|
||||
const now = new Date();
|
||||
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
return reports.value.filter((report) => {
|
||||
const reportDate = new Date(report.createdAt);
|
||||
return reportDate >= weekAgo;
|
||||
}).length;
|
||||
});
|
||||
|
||||
const totalSize = computed(() => {
|
||||
const totalBytes = reports.value.reduce((sum, report) => sum + (report.fileSize || 0), 0);
|
||||
return ReportService.formatFileSize(totalBytes);
|
||||
});
|
||||
|
||||
// Методы
|
||||
const loadReports = async (page = 0) => {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const sort = `${sortField.value},${sortOrder.value === 1 ? 'asc' : 'desc'}`;
|
||||
const data = await ReportService.getReportHistory(page, pageSize.value, sort);
|
||||
reports.value = data.content || [];
|
||||
totalElements.value = data.totalElements || 0;
|
||||
currentPage.value = page;
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки истории:', error);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Ошибка',
|
||||
detail: ReportService.handleError(error),
|
||||
life: 5000
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onPageChange = (event) => {
|
||||
loadReports(event.page);
|
||||
};
|
||||
|
||||
const onSort = (event) => {
|
||||
sortField.value = event.sortField;
|
||||
sortOrder.value = event.sortOrder;
|
||||
loadReports(currentPage.value);
|
||||
};
|
||||
|
||||
const onSearch = () => {
|
||||
// Debounce поиска
|
||||
clearTimeout(searchTimeout.value);
|
||||
searchTimeout.value = setTimeout(() => {
|
||||
applyFilters();
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const searchTimeout = ref(null);
|
||||
|
||||
const applyFilters = () => {
|
||||
// Здесь можно добавить логику фильтрации на клиенте
|
||||
// или передать параметры на сервер
|
||||
loadReports(0);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
Object.assign(filters, {
|
||||
search: '',
|
||||
format: null,
|
||||
dateFrom: null,
|
||||
dateTo: null
|
||||
});
|
||||
loadReports(0);
|
||||
};
|
||||
|
||||
const goToGenerator = () => {
|
||||
router.push('/reports/generator');
|
||||
};
|
||||
|
||||
const handleDownload = async (reportId) => {
|
||||
downloadingReports.value.push(reportId);
|
||||
|
||||
try {
|
||||
const blob = await ReportService.downloadReport(reportId);
|
||||
|
||||
// Получение имени файла из истории
|
||||
const report = reports.value.find((r) => r.id === reportId);
|
||||
const filename = report ? report.filename : `report_${reportId}.pdf`;
|
||||
|
||||
ReportService.downloadFile(blob, filename);
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Успешно',
|
||||
detail: 'Отчёт скачан',
|
||||
life: 3000
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Ошибка скачивания:', error);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Ошибка',
|
||||
detail: ReportService.handleError(error),
|
||||
life: 5000
|
||||
});
|
||||
} finally {
|
||||
downloadingReports.value = downloadingReports.value.filter((id) => id !== reportId);
|
||||
}
|
||||
};
|
||||
|
||||
const viewReport = (report) => {
|
||||
selectedReport.value = report;
|
||||
showReportDialog.value = true;
|
||||
};
|
||||
|
||||
const confirmDelete = (report) => {
|
||||
reportToDelete.value = report;
|
||||
showDeleteDialog.value = true;
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
// Здесь должна быть логика удаления отчёта
|
||||
// Пока что просто закрываем диалог
|
||||
showDeleteDialog.value = false;
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: 'Информация',
|
||||
detail: 'Функция удаления будет добавлена в следующих версиях',
|
||||
life: 3000
|
||||
});
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString('ru-RU');
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString) => {
|
||||
return new Date(dateString).toLocaleString('ru-RU');
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
return ReportService.formatFileSize(bytes);
|
||||
};
|
||||
|
||||
const getTimeAgo = (dateString) => {
|
||||
const now = new Date();
|
||||
const date = new Date(dateString);
|
||||
const diffInHours = Math.floor((now - date) / (1000 * 60 * 60));
|
||||
|
||||
if (diffInHours < 1) return 'только что';
|
||||
if (diffInHours < 24) return `${diffInHours} ч. назад`;
|
||||
|
||||
const diffInDays = Math.floor(diffInHours / 24);
|
||||
if (diffInDays < 7) return `${diffInDays} дн. назад`;
|
||||
|
||||
const diffInWeeks = Math.floor(diffInDays / 7);
|
||||
if (diffInWeeks < 4) return `${diffInWeeks} нед. назад`;
|
||||
|
||||
const diffInMonths = Math.floor(diffInDays / 30);
|
||||
return `${diffInMonths} мес. назад`;
|
||||
};
|
||||
|
||||
// Инициализация
|
||||
onMounted(() => {
|
||||
loadReports();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.p-fluid .field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.text-600 {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.text-xs {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-column {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.gap-1 {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.gap-3 {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.align-items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.justify-content-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,383 @@
|
||||
<template>
|
||||
<div class="grid">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<h5>Генерация отчётов</h5>
|
||||
<p>Создайте новый отчёт на основе собранных данных за выбранный период.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Форма генерации отчёта -->
|
||||
<div class="col-12 lg:col-6">
|
||||
<div class="card">
|
||||
<h5>Создать новый отчёт</h5>
|
||||
<form @submit.prevent="handleGenerate" class="p-fluid">
|
||||
<div class="field">
|
||||
<label for="reportTitle">Заголовок отчёта *</label>
|
||||
<InputText id="reportTitle" v-model="formData.reportTitle" :class="{ 'p-invalid': errors.reportTitle }" placeholder="Введите заголовок отчёта" />
|
||||
<small v-if="errors.reportTitle" class="p-error">{{ errors.reportTitle }}</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="authorName">Автор *</label>
|
||||
<InputText id="authorName" v-model="formData.authorName" :class="{ 'p-invalid': errors.authorName }" placeholder="Введите имя автора" />
|
||||
<small v-if="errors.authorName" class="p-error">{{ errors.authorName }}</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="companyName">Компания *</label>
|
||||
<InputText id="companyName" v-model="formData.companyName" :class="{ 'p-invalid': errors.companyName }" placeholder="Введите название компании" />
|
||||
<small v-if="errors.companyName" class="p-error">{{ errors.companyName }}</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="startDate">Дата начала *</label>
|
||||
<Calendar id="startDate" v-model="formData.startDate" :class="{ 'p-invalid': errors.startDate }" dateFormat="dd.mm.yy" showTime hourFormat="24" placeholder="Выберите дату начала" />
|
||||
<small v-if="errors.startDate" class="p-error">{{ errors.startDate }}</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="endDate">Дата окончания *</label>
|
||||
<Calendar id="endDate" v-model="formData.endDate" :class="{ 'p-invalid': errors.endDate }" dateFormat="dd.mm.yy" showTime hourFormat="24" placeholder="Выберите дату окончания" />
|
||||
<small v-if="errors.endDate" class="p-error">{{ errors.endDate }}</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="format">Формат файла *</label>
|
||||
<Dropdown id="format" v-model="formData.format" :options="formatOptions" optionLabel="label" optionValue="value" placeholder="Выберите формат" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<Button type="submit" label="Сгенерировать отчёт" icon="pi pi-file-pdf" :loading="loading" :disabled="loading" class="w-full" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Статистика -->
|
||||
<div class="col-12 lg:col-6">
|
||||
<div class="card">
|
||||
<h5>Статистика отчётов</h5>
|
||||
<div class="grid">
|
||||
<div class="col-6">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-blue-500">{{ totalReports }}</div>
|
||||
<div class="text-sm text-600">Всего отчётов</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-green-500">{{ thisMonthReports }}</div>
|
||||
<div class="text-sm text-600">За этот месяц</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- История отчётов -->
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="flex justify-content-between align-items-center mb-3">
|
||||
<h5>История отчётов</h5>
|
||||
<Button icon="pi pi-refresh" @click="loadReports" :loading="loading" severity="secondary" text />
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:value="reports"
|
||||
:loading="loading"
|
||||
:paginator="true"
|
||||
:rows="pageSize"
|
||||
:totalRecords="totalElements"
|
||||
:lazy="true"
|
||||
@page="onPageChange"
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
||||
:rowsPerPageOptions="[10, 20, 50]"
|
||||
currentPageReportTemplate="Показано {first} - {last} из {totalRecords} записей"
|
||||
emptyMessage="Отчёты не найдены"
|
||||
responsiveLayout="scroll"
|
||||
>
|
||||
<Column field="reportTitle" header="Заголовок" sortable>
|
||||
<template #body="{ data }">
|
||||
<div class="font-semibold">{{ data.reportTitle }}</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="authorName" header="Автор" sortable></Column>
|
||||
|
||||
<Column field="companyName" header="Компания" sortable></Column>
|
||||
|
||||
<Column field="startDate" header="Период" sortable>
|
||||
<template #body="{ data }">
|
||||
<div>{{ formatDate(data.startDate) }} - {{ formatDate(data.endDate) }}</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="format" header="Формат" sortable>
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.format" :severity="data.format === 'PDF' ? 'danger' : 'info'" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="fileSize" header="Размер" sortable>
|
||||
<template #body="{ data }">
|
||||
{{ formatFileSize(data.fileSize) }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="createdAt" header="Создан" sortable>
|
||||
<template #body="{ data }">
|
||||
{{ formatDateTime(data.createdAt) }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Действия" :exportable="false">
|
||||
<template #body="{ data }">
|
||||
<Button icon="pi pi-download" @click="handleDownload(data.id)" severity="success" text rounded :loading="downloadingReports.includes(data.id)" />
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ReportService from '@/service/ReportService.js';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
// Реактивные данные
|
||||
const loading = ref(false);
|
||||
const reports = ref([]);
|
||||
const totalElements = ref(0);
|
||||
const pageSize = ref(20);
|
||||
const currentPage = ref(0);
|
||||
const downloadingReports = ref([]);
|
||||
|
||||
// Данные формы
|
||||
const formData = reactive({
|
||||
reportTitle: '',
|
||||
authorName: '',
|
||||
companyName: '',
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
format: 'PDF'
|
||||
});
|
||||
|
||||
// Ошибки валидации
|
||||
const errors = reactive({
|
||||
reportTitle: null,
|
||||
authorName: null,
|
||||
companyName: null,
|
||||
startDate: null,
|
||||
endDate: null
|
||||
});
|
||||
|
||||
// Опции форматов
|
||||
const formatOptions = [
|
||||
{ label: 'PDF', value: 'PDF' },
|
||||
{ label: 'DOCX', value: 'DOCX' }
|
||||
];
|
||||
|
||||
// Вычисляемые свойства
|
||||
const totalReports = computed(() => totalElements.value);
|
||||
const thisMonthReports = computed(() => {
|
||||
const now = new Date();
|
||||
const thisMonth = now.getMonth();
|
||||
const thisYear = now.getFullYear();
|
||||
|
||||
return reports.value.filter((report) => {
|
||||
const reportDate = new Date(report.createdAt);
|
||||
return reportDate.getMonth() === thisMonth && reportDate.getFullYear() === thisYear;
|
||||
}).length;
|
||||
});
|
||||
|
||||
// Методы
|
||||
const validateForm = () => {
|
||||
// Очистка предыдущих ошибок
|
||||
Object.keys(errors).forEach((key) => (errors[key] = null));
|
||||
|
||||
let isValid = true;
|
||||
|
||||
if (!formData.reportTitle.trim()) {
|
||||
errors.reportTitle = 'Заголовок отчёта обязателен';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (!formData.authorName.trim()) {
|
||||
errors.authorName = 'Имя автора обязательно';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (!formData.companyName.trim()) {
|
||||
errors.companyName = 'Название компании обязательно';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (!formData.startDate) {
|
||||
errors.startDate = 'Дата начала обязательна';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (!formData.endDate) {
|
||||
errors.endDate = 'Дата окончания обязательна';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (formData.startDate && formData.endDate && formData.startDate >= formData.endDate) {
|
||||
errors.endDate = 'Дата окончания должна быть позже даты начала';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
return isValid;
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!validateForm()) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Ошибка валидации',
|
||||
detail: 'Пожалуйста, исправьте ошибки в форме',
|
||||
life: 3000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
// Подготовка данных для API
|
||||
const reportData = {
|
||||
reportTitle: formData.reportTitle.trim(),
|
||||
authorName: formData.authorName.trim(),
|
||||
companyName: formData.companyName.trim(),
|
||||
startDate: formData.startDate.toISOString(),
|
||||
endDate: formData.endDate.toISOString(),
|
||||
format: formData.format
|
||||
};
|
||||
|
||||
const blob = await ReportService.generateReport(reportData);
|
||||
|
||||
// Создание имени файла
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||
const filename = `report_${timestamp}.${formData.format.toLowerCase()}`;
|
||||
|
||||
// Скачивание файла
|
||||
ReportService.downloadFile(blob, filename);
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Успешно',
|
||||
detail: 'Отчёт сгенерирован и скачан',
|
||||
life: 3000
|
||||
});
|
||||
|
||||
// Перезагрузка списка отчётов
|
||||
await loadReports();
|
||||
} catch (error) {
|
||||
console.error('Ошибка генерации отчёта:', error);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Ошибка',
|
||||
detail: ReportService.handleError(error),
|
||||
life: 5000
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadReports = async (page = 0) => {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const data = await ReportService.getReportHistory(page, pageSize.value, 'createdAt,desc');
|
||||
reports.value = data.content || [];
|
||||
totalElements.value = data.totalElements || 0;
|
||||
currentPage.value = page;
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки истории:', error);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Ошибка',
|
||||
detail: ReportService.handleError(error),
|
||||
life: 5000
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onPageChange = (event) => {
|
||||
loadReports(event.page);
|
||||
};
|
||||
|
||||
const handleDownload = async (reportId) => {
|
||||
downloadingReports.value.push(reportId);
|
||||
|
||||
try {
|
||||
const blob = await ReportService.downloadReport(reportId);
|
||||
|
||||
// Получение имени файла из истории
|
||||
const report = reports.value.find((r) => r.id === reportId);
|
||||
const filename = report ? report.filename : `report_${reportId}.pdf`;
|
||||
|
||||
ReportService.downloadFile(blob, filename);
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Успешно',
|
||||
detail: 'Отчёт скачан',
|
||||
life: 3000
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Ошибка скачивания:', error);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Ошибка',
|
||||
detail: ReportService.handleError(error),
|
||||
life: 5000
|
||||
});
|
||||
} finally {
|
||||
downloadingReports.value = downloadingReports.value.filter((id) => id !== reportId);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString('ru-RU');
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString) => {
|
||||
return new Date(dateString).toLocaleString('ru-RU');
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
return ReportService.formatFileSize(bytes);
|
||||
};
|
||||
|
||||
// Инициализация
|
||||
onMounted(() => {
|
||||
loadReports();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.p-fluid .field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.text-600 {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user