Обновлен стартовый экран

Переписаны запросы для статистики, отчетов
Добавлена интеграция отчета сестры
This commit is contained in:
brusnitsyn
2026-05-28 22:10:00 +09:00
parent 90e0d04dfd
commit 739168d427
96 changed files with 6663 additions and 1465 deletions

View File

@@ -1,120 +1,282 @@
<script setup>
import {NButton, NDataTable, NScrollbar, NList, NListItem, NGrid, NGi, NFlex, NH2, NSpace} from "naive-ui";
import AppLayout from "../../../Layouts/AppLayout.vue";
import AppPanel from "../../../Components/AppPanel.vue";
import AppContainer from "../../../Components/AppContainer.vue";
import {
NButton, NFlex, NForm, NFormItem, NInput, NTransfer,
NSwitch, NSelect, NAlert, NTabs, NTabPane,
NAvatar, NTag, NText, NEl, NGrid, NGi, NIcon,
} from 'naive-ui'
import {
TbUser, TbAt, TbBuildingHospital, TbShieldLock,
TbLock, TbAlertTriangle, TbCheck, TbUserShield,
TbLayoutDashboard, TbUsers,
} 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 { useForm, Link, usePage } from '@inertiajs/vue3'
import { computed } from 'vue'
const props = defineProps({
userData: {
type: Object,
default: {}
},
roles: {
type: Array,
default: []
},
departments: {
type: Array,
default: []
}
userData: { type: Object, default: () => ({}) },
userRoleIds: { type: Array, default: () => [] },
userDepartmentIds: { type: Array, default: () => [] },
allRoles: { type: Array, default: () => [] },
allDepartments: { type: Array, default: () => [] },
})
const page = usePage()
const flash = computed(() => page.props.flash ?? {})
const departmentOptions = computed(() => props.allDepartments.map(d => ({
label: d.name_full,
value: d.department_id,
})))
const roleOptions = computed(() => props.allRoles.map(r => ({
label: r.name,
value: r.role_id,
})))
const initials = computed(() => {
const parts = (props.userData.name ?? '').trim().split(/\s+/)
return parts.slice(0, 2).map(p => p[0]?.toUpperCase() ?? '').join('')
})
const form = useForm({
name: props.userData.name,
login: props.userData.login,
is_active: props.userData.is_active,
department_id: props.userData.department_id,
departments: [...props.userDepartmentIds],
roles: [...props.userRoleIds],
})
const save = () => form.put(`/admin/users/${props.userData.id}`)
const passwordForm = useForm({
password: '',
password_confirmation: '',
})
const resetPassword = () => {
passwordForm.put(`/admin/users/${props.userData.id}/password`, {
onSuccess: () => passwordForm.reset(),
})
}
const toggleBlock = () => {
form.is_active = !form.is_active
save()
}
</script>
<template>
<AppLayout>
<template #header>
<NFlex align="center" justify="space-between" class="max-w-6xl mx-auto mt-6 mb-4 w-full">
<NH2>
{{ userData.name }}
</NH2>
<NSpace>
<NButton type="primary">
Создать учетную запись
</NButton>
</NSpace>
</NFlex>
</template>
<AppContainer>
<NGrid cols="2" x-gap="12" y-gap="12">
<NGi>
<AppPanel header="Информация о пользователе" min-h="148px" max-h="148px">
<NSpace vertical>
<NFlex align="center" justify="space-between">
<div>
Имя
</div>
<div>
{{ userData.name }}
</div>
</NFlex>
<NFlex align="center" justify="space-between">
<div>
Логин
</div>
<div>
{{ userData.login }}
</div>
</NFlex>
<NFlex align="center" justify="space-between">
<div>
Активен
</div>
<div>
{{ userData.is_active ? 'Да' : 'Нет' }}
</div>
</NFlex>
<NFlex align="center" justify="space-between">
<div>
Дата создания
</div>
<div>
{{ userData.created_at }}
</div>
</NFlex>
<NFlex align="center" justify="space-between">
<div>
Дата обновления
</div>
<div>
{{ userData.updated_at }}
</div>
</NFlex>
</NSpace>
</AppPanel>
</NGi>
<NGi>
</NGi>
<NGi>
<AppPanel :header="`Доступные отделения (${departments.length})`" min-h="148px" max-h="148px">
<NList>
<NListItem v-for="department in departments">
{{ department.name_full }}
</NListItem>
</NList>
</AppPanel>
</NGi>
<NGi>
<AppPanel :header="`Доступные роли (${roles.length})`" min-h="148px" max-h="148px">
<NList>
<NListItem v-for="role in roles">
{{ role.name }}
</NListItem>
</NList>
</AppPanel>
</NGi>
</NGrid>
<NAlert v-if="flash.success" type="success" closable style="margin-bottom: 4px;">
{{ flash.success }}
</NAlert>
<PageBanner
:breadcrumbs="[
{ label: 'Администратор', href: '/admin', icon: TbLayoutDashboard, tag: Link },
{ label: 'Учётные записи', href: '/admin/users', icon: TbUsers, tag: Link },
]"
>
<template #icon>
<NAvatar round :size="40"
style="background: color-mix(in srgb, var(--primary-color) 22%, transparent); color: var(--primary-color); font-size: 15px; font-weight: 600;"
>{{ initials }}</NAvatar>
</template>
<template #title>
<NFlex align="center" :size="8" :wrap="false">
<span style="font-size: 20px; font-weight: 700; line-height: 1.2;" class="truncate">
{{ userData.name }}
</span>
<NTag :type="userData.is_active ? 'success' : 'error'" size="small" round :bordered="false">
{{ userData.is_active ? 'Активен' : 'Заблокирован' }}
</NTag>
</NFlex>
</template>
<template #meta>
<NFlex :size="12">
<NText depth="3" style="font-size: 13px;">@{{ userData.login }}</NText>
<NText depth="3" style="font-size: 13px;">Создан: {{ userData.created_at }}</NText>
<NText depth="3" style="font-size: 13px;">Изменён: {{ userData.updated_at }}</NText>
</NFlex>
</template>
<template #actions>
<NButton :tag="Link" href="/admin/users">Назад</NButton>
<NButton type="primary" :loading="form.processing" @click="save">Сохранить</NButton>
</template>
</PageBanner>
<!-- Табы -->
<NTabs type="line" animated>
<!-- Основное -->
<NTabPane name="general" tab="Основное">
<NGrid :cols="2" :x-gap="16" :y-gap="16" style="margin-top: 4px;">
<NGi>
<SectionCard title="Личные данные" :icon="TbUser">
<NForm label-placement="top">
<NFormItem label="Имя" :feedback="form.errors.name" :validation-status="form.errors.name ? 'error' : undefined">
<NInput v-model:value="form.name">
<template #prefix><NIcon depth="3"><TbUser /></NIcon></template>
</NInput>
</NFormItem>
<NFormItem label="Логин" :feedback="form.errors.login" :validation-status="form.errors.login ? 'error' : undefined">
<NInput v-model:value="form.login">
<template #prefix><NIcon depth="3"><TbAt /></NIcon></template>
</NInput>
</NFormItem>
<NFormItem label="Основное отделение" :feedback="form.errors.department_id" :validation-status="form.errors.department_id ? 'error' : undefined" style="margin-bottom: 0;">
<NSelect v-model:value="form.department_id" :options="departmentOptions" filterable />
</NFormItem>
</NForm>
</SectionCard>
</NGi>
<NGi>
<NFlex vertical :size="12">
<SectionCard
title="Статус аккаунта"
:icon="TbShieldLock"
:color="form.is_active ? 'success' : 'error'"
>
<NFlex justify="space-between" align="center">
<div>
<NText style="font-size: 14px; font-weight: 500; display: block;">
{{ form.is_active ? 'Пользователь активен' : 'Пользователь заблокирован' }}
</NText>
<NText depth="3" style="font-size: 12px; margin-top: 2px; display: block;">
{{ form.is_active ? 'Может входить в систему' : 'Вход в систему запрещён' }}
</NText>
</div>
<NSwitch v-model:value="form.is_active" />
</NFlex>
</SectionCard>
<SectionCard title="Роли" :icon="TbUserShield">
<NFlex :size="6" wrap>
<NTag
v-for="roleId in form.roles" :key="roleId"
type="info" :bordered="false" round size="small"
>
{{ allRoles.find(r => r.role_id === roleId)?.name ?? roleId }}
</NTag>
<NText v-if="form.roles.length === 0" depth="3" style="font-size: 13px;">
Роли не назначены
</NText>
</NFlex>
<NText depth="3" style="font-size: 11px; margin-top: 8px; display: block;">
Изменить роли можно во вкладке «Доступ»
</NText>
</SectionCard>
</NFlex>
</NGi>
</NGrid>
</NTabPane>
<!-- Безопасность -->
<NTabPane name="security" tab="Безопасность">
<NGrid :cols="2" :x-gap="16" :y-gap="16" style="margin-top: 4px;">
<NGi>
<SectionCard title="Смена пароля" :icon="TbLock">
<NText depth="3" style="font-size: 13px; display: block; margin-bottom: 14px; line-height: 1.5;">
После смены пароля все активные сессии пользователя будут завершены.
</NText>
<NForm label-placement="top">
<NFormItem label="Новый пароль" :feedback="passwordForm.errors.password" :validation-status="passwordForm.errors.password ? 'error' : undefined">
<NInput v-model:value="passwordForm.password" type="password" show-password-toggle placeholder="Минимум 6 символов" />
</NFormItem>
<NFormItem label="Подтверждение" :feedback="passwordForm.errors.password_confirmation" :validation-status="passwordForm.errors.password_confirmation ? 'error' : undefined">
<NInput v-model:value="passwordForm.password_confirmation" type="password" show-password-toggle placeholder="Повторите пароль" />
</NFormItem>
<NFormItem :show-label="false" :show-feedback="false" style="margin-bottom: 0;">
<NButton
type="primary"
:loading="passwordForm.processing"
:disabled="!passwordForm.password || !passwordForm.password_confirmation"
@click="resetPassword"
>
<template #icon><NIcon><TbCheck /></NIcon></template>
Изменить пароль
</NButton>
</NFormItem>
</NForm>
</SectionCard>
</NGi>
<NGi>
<SectionCard title="Опасная зона" :icon="TbAlertTriangle" color="error">
<NFlex justify="space-between" align="center">
<div>
<NText style="font-size: 14px; font-weight: 500; display: block;">
{{ form.is_active ? 'Заблокировать пользователя' : 'Разблокировать пользователя' }}
</NText>
<NText depth="3" style="font-size: 12px; margin-top: 2px; display: block; line-height: 1.4; max-width: 220px;">
{{ form.is_active
? 'Пользователь потеряет доступ ко всем разделам системы'
: 'Пользователь снова сможет входить в систему' }}
</NText>
</div>
<NButton
:type="form.is_active ? 'error' : 'default'"
size="small"
:loading="form.processing"
@click="toggleBlock"
>
{{ form.is_active ? 'Заблокировать' : 'Разблокировать' }}
</NButton>
</NFlex>
</SectionCard>
</NGi>
</NGrid>
</NTabPane>
<!-- Доступ -->
<NTabPane name="access" tab="Доступ">
<NFlex vertical :size="16" style="margin-top: 4px;">
<SectionCard title="Роли" :icon="TbUserShield" no-padding>
<NTransfer
v-model:value="form.roles"
:options="roleOptions"
source-filterable
source-title="Доступные роли"
target-title="Назначенные роли"
/>
</SectionCard>
<SectionCard title="Отделения" :icon="TbBuildingHospital" no-padding>
<NTransfer
v-model:value="form.departments"
virtual-scroll
:options="departmentOptions"
source-filterable
target-filterable
source-title="Все отделения"
target-title="Доступные отделения"
/>
</SectionCard>
</NFlex>
</NTabPane>
</NTabs>
</AppContainer>
</AppLayout>
</template>
<style scoped>
:deep(.n-h) {
margin: 0;
}
:deep(.n-data-table-th),
:deep(.n-data-table-td) {
font-size: var(--n-font-size);
}
:deep(.n-tabs-pane-wrapper) { padding-top: 0; }
:deep(.n-transfer) { border: none !important; border-radius: 0 !important; }
</style>