Модуль отчетов
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
<script setup>
|
||||
import { NSelect, NInput, NTransfer, NDynamicInput, NFlex, NText, NEmpty, NFormItem, NForm } from 'naive-ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
section: { type: Object, required: true },
|
||||
sources: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const sourceOptions = computed(() => props.sources.map(s => ({ label: s.label, value: s.key })))
|
||||
|
||||
const selectedSource = computed(() => props.sources.find(s => s.key === props.section.source) ?? null)
|
||||
|
||||
const columnTransferOptions = computed(() => Object.entries(selectedSource.value?.columns ?? {})
|
||||
.map(([value, label]) => ({ label, value })))
|
||||
|
||||
const filterableFieldOptions = computed(() => Object.entries(selectedSource.value?.filterableFields ?? {})
|
||||
.map(([value, def]) => ({ label: def.label, value })))
|
||||
|
||||
const filterValueOptions = (fieldKey) => {
|
||||
const options = selectedSource.value?.filterableFields?.[fieldKey]?.options
|
||||
return options ? Object.entries(options).map(([value, label]) => ({ label, value })) : null
|
||||
}
|
||||
|
||||
const createFilter = () => ({
|
||||
field: filterableFieldOptions.value[0]?.value ?? null,
|
||||
value: null,
|
||||
})
|
||||
|
||||
const onSourceChange = () => {
|
||||
// Колонки и фильтры принадлежат конкретному источнику — при смене источника они теряют смысл
|
||||
props.section.columns = []
|
||||
props.section.filters = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NFlex vertical :size="12" style="width: 100%;">
|
||||
<NForm label-placement="top">
|
||||
<NFlex :size="12" :wrap="true">
|
||||
<NFormItem label="Источник данных" style="min-width: 240px; margin-bottom: 0;">
|
||||
<NSelect v-model:value="section.source" :options="sourceOptions" @update:value="onSourceChange" />
|
||||
</NFormItem>
|
||||
<NFormItem label="Заголовок секции" style="min-width: 240px; flex: 1; margin-bottom: 0;">
|
||||
<NInput v-model:value="section.title" :placeholder="selectedSource?.label" />
|
||||
</NFormItem>
|
||||
</NFlex>
|
||||
</NForm>
|
||||
|
||||
<div>
|
||||
<NText depth="3" style="font-size: 12px; display: block; margin-bottom: 6px;">Колонки</NText>
|
||||
<NTransfer
|
||||
v-model:value="section.columns"
|
||||
:options="columnTransferOptions"
|
||||
source-filterable
|
||||
source-title="Доступные колонки"
|
||||
target-title="Колонки секции (в этом порядке)"
|
||||
style="height: 280px;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="filterableFieldOptions.length">
|
||||
<NText depth="3" style="font-size: 12px; display: block; margin-bottom: 6px;">Фильтры (необязательно)</NText>
|
||||
<NDynamicInput v-model:value="section.filters" :on-create="createFilter">
|
||||
<template #default="{ value }">
|
||||
<NFlex align="center" :size="8" style="flex: 1;">
|
||||
<NSelect
|
||||
v-model:value="value.field"
|
||||
:options="filterableFieldOptions"
|
||||
style="width: 220px;"
|
||||
placeholder="Поле"
|
||||
/>
|
||||
<NSelect
|
||||
v-if="filterValueOptions(value.field)"
|
||||
v-model:value="value.value"
|
||||
:options="filterValueOptions(value.field)"
|
||||
style="width: 220px;"
|
||||
placeholder="Значение"
|
||||
/>
|
||||
<NInput
|
||||
v-else
|
||||
v-model:value="value.value"
|
||||
style="width: 220px;"
|
||||
placeholder="Значение"
|
||||
/>
|
||||
</NFlex>
|
||||
</template>
|
||||
</NDynamicInput>
|
||||
<NEmpty v-if="!section.filters.length" description="Без фильтров — попадут все записи" size="small" style="padding: 8px 0;" />
|
||||
</div>
|
||||
</NFlex>
|
||||
</template>
|
||||
135
resources/js/Pages/Admin/ReportTemplates/Form.vue
Normal file
135
resources/js/Pages/Admin/ReportTemplates/Form.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<script setup>
|
||||
import {
|
||||
NButton, NFlex, NForm, NFormItem, NInput, NSelect,
|
||||
NText, NDynamicInput, NAlert, NTag,
|
||||
} from 'naive-ui'
|
||||
import {
|
||||
TbReportAnalytics, TbLayoutDashboard,
|
||||
} 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 SectionEditor from './Components/SectionEditor.vue'
|
||||
import { useForm, Link, usePage } from '@inertiajs/vue3'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
template: { type: Object, default: null },
|
||||
sources: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const page = usePage()
|
||||
const flash = computed(() => page.props.flash ?? {})
|
||||
|
||||
const isEdit = computed(() => !!props.template)
|
||||
|
||||
const createSection = () => ({
|
||||
source: props.sources[0]?.key ?? null,
|
||||
title: '',
|
||||
columns: [],
|
||||
filters: [],
|
||||
})
|
||||
|
||||
const form = useForm({
|
||||
name: props.template?.name ?? '',
|
||||
sections: props.template?.sections?.length ? props.template.sections : [createSection()],
|
||||
required_permissions: props.template?.requiredPermissions ?? [],
|
||||
})
|
||||
|
||||
const visibilityOptions = [
|
||||
{ label: 'Дежурный врач', value: 'report.view' },
|
||||
{ label: 'Старшая медсестра', value: 'nurse.report.view' },
|
||||
]
|
||||
|
||||
const submit = () => {
|
||||
if (isEdit.value) {
|
||||
form.put(`/admin/report-templates/${props.template.id}`)
|
||||
} else {
|
||||
form.post('/admin/report-templates/new')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<AppContainer>
|
||||
|
||||
<NAlert v-if="flash.success" type="success" closable style="margin-bottom: 4px;">
|
||||
{{ flash.success }}
|
||||
</NAlert>
|
||||
|
||||
<PageBanner
|
||||
:title="isEdit ? template.name : 'Новый шаблон отчёта'"
|
||||
:icon="TbReportAnalytics"
|
||||
:color="isEdit ? null : 'default'"
|
||||
:breadcrumbs="[
|
||||
{ label: 'Администратор', href: '/admin', icon: TbLayoutDashboard, tag: Link },
|
||||
{ label: 'Шаблоны отчётов', href: '/admin/report-templates', icon: TbReportAnalytics, tag: Link },
|
||||
]"
|
||||
>
|
||||
<template #meta>
|
||||
<NText depth="3" style="font-size: 13px;">
|
||||
{{ isEdit ? 'Редактирование шаблона' : 'Название → секции с данными → кому виден' }}
|
||||
</NText>
|
||||
</template>
|
||||
<template #actions>
|
||||
<NButton :tag="Link" href="/admin/report-templates">Отмена</NButton>
|
||||
<NButton type="primary" :loading="form.processing" @click="submit">
|
||||
{{ isEdit ? 'Сохранить' : 'Создать шаблон' }}
|
||||
</NButton>
|
||||
</template>
|
||||
</PageBanner>
|
||||
|
||||
<NFlex vertical :size="16">
|
||||
|
||||
<SectionCard title="1 · Название и видимость">
|
||||
<NFlex :size="16" :wrap="true">
|
||||
<NForm label-placement="top" style="flex: 2; min-width: 280px;">
|
||||
<NFormItem label="Название отчёта" :feedback="form.errors.name" :validation-status="form.errors.name ? 'error' : undefined" style="margin-bottom: 0;">
|
||||
<NInput v-model:value="form.name" placeholder="Например: Сводка по отделению" />
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
<NForm label-placement="top" style="flex: 1; min-width: 280px;">
|
||||
<NFormItem label="Кому виден отчёт" style="margin-bottom: 0;">
|
||||
<NSelect
|
||||
v-model:value="form.required_permissions"
|
||||
:options="visibilityOptions"
|
||||
multiple
|
||||
placeholder="Все с доступом к отчётам"
|
||||
clearable
|
||||
/>
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
</NFlex>
|
||||
<NText depth="3" style="font-size: 12px; display: block; margin-top: 4px;">
|
||||
Ничего не выбрано — отчёт увидят все, у кого есть доступ к разделу «Отчёты»
|
||||
</NText>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="2 · Секции с данными">
|
||||
<NText v-if="form.errors.sections" type="error" style="display: block; margin-bottom: 8px; font-size: 12px;">
|
||||
{{ form.errors.sections }}
|
||||
</NText>
|
||||
<NDynamicInput
|
||||
v-model:value="form.sections"
|
||||
:on-create="createSection"
|
||||
item-style="margin-bottom: 16px; padding-bottom: 16px; border-bottom: 1px solid var(--n-border-color, rgba(255,255,255,.08));"
|
||||
>
|
||||
<template #default="{ index, value }">
|
||||
<NFlex vertical :size="8" style="width: 100%;">
|
||||
<NTag size="small" round :bordered="false">Секция {{ index + 1 }}</NTag>
|
||||
<SectionEditor :section="value" :sources="sources" />
|
||||
</NFlex>
|
||||
</template>
|
||||
<template #create-button-default>
|
||||
Добавить секцию
|
||||
</template>
|
||||
</NDynamicInput>
|
||||
</SectionCard>
|
||||
|
||||
</NFlex>
|
||||
|
||||
</AppContainer>
|
||||
</AppLayout>
|
||||
</template>
|
||||
135
resources/js/Pages/Admin/ReportTemplates/Index.vue
Normal file
135
resources/js/Pages/Admin/ReportTemplates/Index.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<script setup>
|
||||
import {
|
||||
NFlex, NButton, NDataTable, NTag,
|
||||
NText, NIcon, NAlert, NPopconfirm, NEmpty,
|
||||
} from 'naive-ui'
|
||||
import {
|
||||
TbReportAnalytics, TbPlus, TbPencil, TbTrash, TbLayoutDashboard,
|
||||
} 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 { Link, router, usePage } from '@inertiajs/vue3'
|
||||
import { computed, h } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
templates: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const page = usePage()
|
||||
const flash = computed(() => page.props.flash ?? {})
|
||||
|
||||
const visibilityLabels = {
|
||||
'report.view': 'Дежурный врач',
|
||||
'nurse.report.view': 'Старшая медсестра',
|
||||
}
|
||||
|
||||
const removeTemplate = (template) => {
|
||||
router.delete(`/admin/report-templates/${template.id}`, { preserveScroll: true })
|
||||
}
|
||||
|
||||
const columns = computed(() => [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Название',
|
||||
render: (row) => h(NFlex, { vertical: true, size: 2 }, () => [
|
||||
h(NText, { style: 'font-weight: 500; font-size: 13px;' }, () => row.name),
|
||||
h(NText, { depth: 3, style: 'font-size: 12px;' }, () => row.sourceLabels.join(', ')),
|
||||
])
|
||||
},
|
||||
{
|
||||
key: 'sectionsCount',
|
||||
title: 'Секций',
|
||||
width: 90,
|
||||
render: (row) => h(NTag, { size: 'small', round: true, bordered: false }, () => `${row.sectionsCount}`)
|
||||
},
|
||||
{
|
||||
key: 'requiredPermissions',
|
||||
title: 'Видимость',
|
||||
width: 240,
|
||||
render: (row) => row.requiredPermissions.length
|
||||
? h(NFlex, { size: 4 }, () => row.requiredPermissions.map((p) => h(NTag, { size: 'small', round: true, bordered: false, type: 'info', key: p }, () => visibilityLabels[p] ?? p)))
|
||||
: h(NTag, { size: 'small', round: true, bordered: false }, () => 'Все с доступом к отчётам')
|
||||
},
|
||||
{
|
||||
key: 'creator',
|
||||
title: 'Автор',
|
||||
width: 180,
|
||||
render: (row) => row.creator ?? '—'
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
title: '',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
render: (row) => h(NFlex, { justify: 'center', size: 4 }, () => [
|
||||
h(NButton, {
|
||||
text: true, size: 'small', tag: Link,
|
||||
href: `/admin/report-templates/${row.id}`,
|
||||
title: 'Редактировать',
|
||||
}, { icon: () => h(NIcon, { size: 18 }, () => h(TbPencil)) }),
|
||||
h(NPopconfirm, {
|
||||
onPositiveClick: () => removeTemplate(row),
|
||||
}, {
|
||||
trigger: () => h(NButton, {
|
||||
text: true, size: 'small', title: 'Удалить',
|
||||
}, { icon: () => h(NIcon, { size: 18 }, () => h(TbTrash)) }),
|
||||
default: () => 'Удалить шаблон?',
|
||||
}),
|
||||
])
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout>
|
||||
<AppContainer>
|
||||
|
||||
<NAlert v-if="flash.success" type="success" closable style="margin-bottom: 4px;">
|
||||
{{ flash.success }}
|
||||
</NAlert>
|
||||
|
||||
<PageBanner
|
||||
title="Шаблоны отчётов"
|
||||
:icon="TbReportAnalytics"
|
||||
:breadcrumbs="[{ label: 'Администратор', href: '/admin', icon: TbLayoutDashboard, tag: Link }]"
|
||||
>
|
||||
<template #meta>
|
||||
<NText depth="3" style="font-size: 13px;">
|
||||
{{ templates.length ? `${templates.length} ${templates.length === 1 ? 'шаблон' : 'шаблонов'}` : 'Конструктор пользовательских отчётов' }}
|
||||
</NText>
|
||||
</template>
|
||||
<template #actions>
|
||||
<NButton type="primary" :tag="Link" href="/admin/report-templates/new">
|
||||
<template #icon><NIcon><TbPlus /></NIcon></template>
|
||||
Новый шаблон
|
||||
</NButton>
|
||||
</template>
|
||||
</PageBanner>
|
||||
|
||||
<SectionCard :icon="TbReportAnalytics" title="Шаблоны" no-padding style="margin-top: 4px;">
|
||||
<NEmpty
|
||||
v-if="!templates.length"
|
||||
description="Пока нет ни одного шаблона"
|
||||
style="padding: 32px 24px;"
|
||||
>
|
||||
<template #extra>
|
||||
<NButton size="small" type="primary" :tag="Link" href="/admin/report-templates/new">
|
||||
<template #icon><NIcon><TbPlus /></NIcon></template>
|
||||
Создать первый шаблон
|
||||
</NButton>
|
||||
</template>
|
||||
</NEmpty>
|
||||
<NDataTable
|
||||
v-else
|
||||
:columns="columns"
|
||||
:data="templates"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
</AppContainer>
|
||||
</AppLayout>
|
||||
</template>
|
||||
Reference in New Issue
Block a user