UI коструктора отчетов

This commit is contained in:
brusnitsyn
2026-06-22 17:02:36 +09:00
parent bdb16dac54
commit 13dfcc3e05
11 changed files with 1664 additions and 0 deletions

View File

@@ -0,0 +1,188 @@
<script setup>
import { ref, h, computed } from 'vue'
import { router, Link } from '@inertiajs/vue3'
import {
NButton, NText, NDataTable, NEmpty, NDropdown, NIcon, NScrollbar,
NTag, useDialog, useMessage,
} from 'naive-ui'
import { TbReportMedical, TbPlus, TbDotsVertical, TbLayoutGrid } from 'vue-icons-plus/tb'
import AppLayout from '../../Layouts/AppLayout.vue'
import AppContainer from '../../Components/AppContainer.vue'
import SectionCard from '../../Components/SectionCard.vue'
import PageBanner from '../../Components/PageBanner.vue'
import PresetCard from './Components/PresetCard.vue'
import TemplatePickerModal from './Components/TemplatePickerModal.vue'
const props = defineProps({
documents: { type: Array, default: () => [] },
presets: { type: Array, default: () => [] },
categories: { type: Array, default: () => [] },
canManage: { type: Boolean, default: false },
})
const dialog = useDialog()
const message = useMessage()
const showPicker = ref(false)
// «Отчёт с нуля» + первые шаблоны в ленте создания.
const blankPreset = computed(() => props.presets.find((p) => p.key === 'blank'))
const featured = computed(() => props.presets.filter((p) => p.key !== 'blank').slice(0, 5))
const openPreset = (preset) => {
router.get(preset.key === 'blank' ? '/reports/new' : `/reports/new?preset=${preset.key}`)
}
const openDocument = (id) => router.get(`/reports/${id}`)
const rowProps = (row) => ({ style: 'cursor: pointer;', onClick: () => openDocument(row.id) })
const duplicate = (row) => router.post(`/reports/${row.id}/duplicate`)
const remove = (row) => {
dialog.warning({
title: 'Удалить отчёт',
content: `Удалить «${row.name}»? Действие необратимо.`,
positiveText: 'Удалить',
negativeText: 'Отмена',
onPositiveClick: () => router.delete(`/reports/${row.id}`, {
preserveScroll: true,
onSuccess: () => message.success('Отчёт удалён'),
}),
})
}
const rowMenu = (row) => [
{ key: 'open', label: 'Открыть' },
{ key: 'duplicate', label: 'Дублировать' },
{ key: 'delete', label: 'Удалить', props: { style: 'color: var(--error-color);' } },
]
const onMenuSelect = (key, row) => {
if (key === 'open') openDocument(row.id)
if (key === 'duplicate') duplicate(row)
if (key === 'delete') remove(row)
}
const columns = computed(() => [
{
title: 'Название',
key: 'name',
render: (row) => h('div', {}, [
h('div', { style: 'font-weight: 600;' }, row.name),
row.description ? h(NText, { depth: 3, style: 'font-size: 12px;' }, () => row.description) : null,
]),
},
{ title: 'Источник', key: 'datasetLabel', width: 200, render: (row) => h(NTag, { size: 'small', bordered: false, round: true }, () => row.datasetLabel) },
{ title: 'Автор', key: 'author', width: 180, render: (row) => row.author ?? '—' },
{ title: 'Изменено', key: 'updatedAt', width: 130, render: (row) => row.updatedAt ?? '—' },
{
title: '',
key: 'actions',
width: 56,
render: (row) => props.canManage
? h(NDropdown, {
trigger: 'click',
options: rowMenu(row),
onSelect: (key) => onMenuSelect(key, row),
}, () => h(NButton, {
text: true,
onClick: (e) => e.stopPropagation(),
}, () => h(NIcon, null, () => h(TbDotsVertical)))) : null,
},
])
</script>
<template>
<AppLayout>
<AppContainer>
<PageBanner
title="Отчёты"
:icon="TbReportMedical"
:breadcrumbs="[{ label: 'Главная', href: '/', tag: Link }]"
>
<template #meta>
<NText depth="3" style="font-size: 13px;">Создавайте отчёты с нуля или по готовым шаблонам</NText>
</template>
</PageBanner>
<SectionCard v-if="canManage" title="Создание отчёта">
<template #header-extra>
<NButton text type="primary" @click="showPicker = true">
<template #icon><TbLayoutGrid /></template>
Открыть все шаблоны
</NButton>
</template>
<NScrollbar x-scrollable class="pb-3">
<div class="create-row">
<div class="create-card" @click="openPreset({ key: 'blank' })">
<div class="blank-tile"><NIcon :size="28"><TbPlus /></NIcon></div>
<NText class="blank-title">Отчёт с нуля</NText>
</div>
<div v-for="preset in featured" :key="preset.key" class="create-card" @click="openPreset(preset)">
<PresetCard :preset="preset" />
</div>
</div>
</NScrollbar>
</SectionCard>
<SectionCard title="Отчёты" no-padding>
<NEmpty v-if="!documents.length" description="Пока нет сохранённых отчётов" style="padding: 32px;" />
<NDataTable
v-else
min-height="calc(100vh - 570px)"
:columns="columns"
:data="documents"
:row-props="rowProps"
:bordered="false"
size="small"
/>
</SectionCard>
<TemplatePickerModal
v-model:show="showPicker"
:presets="presets"
:categories="categories"
@select="openPreset"
/>
</AppContainer>
</AppLayout>
</template>
<style scoped>
:deep(.n-data-table-th) { background: transparent !important; }
:deep(.n-data-table) { background: transparent; }
:deep(.n-data-table-wrapper) { border-radius: 0; }
:deep(.n-data-table-th .n-data-table-th__title) { font-size: 12px; }
:deep(.n-data-table-td) { font-size: 13px; }
.create-row {
display: flex;
gap: 12px;
padding-top: 2px;
}
.create-card {
width: 220px;
flex-shrink: 0;
}
.blank-tile {
height: 84px;
display: flex;
align-items: center;
justify-content: center;
color: var(--primary-color);
border-radius: 8px;
background: color-mix(in srgb, var(--primary-color) 8%, transparent);
margin-bottom: 10px;
}
.create-card .blank-tile { border: 1px dashed color-mix(in srgb, var(--primary-color) 40%, transparent); }
.create-card:first-child {
border: 1px solid var(--n-border-color, rgba(255,255,255,.1));
border-radius: 12px;
padding: 14px;
cursor: pointer;
transition: border-color .15s, transform .15s;
background: var(--n-card-color);
}
.create-card:first-child:hover { border-color: var(--primary-color); transform: translateY(-2px); }
.blank-title { display: block; font-weight: 600; font-size: 14px; }
</style>