283 lines
15 KiB
Vue
283 lines
15 KiB
Vue
<script setup>
|
||
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: () => ({}) },
|
||
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>
|
||
<AppContainer>
|
||
|
||
<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-tabs-pane-wrapper) { padding-top: 0; }
|
||
:deep(.n-transfer) { border: none !important; border-radius: 0 !important; }
|
||
</style>
|