first commit

This commit is contained in:
brusnitsyn
2026-01-04 23:15:06 +09:00
commit 0ec04cfb4b
104 changed files with 19072 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
<script setup>
import {NDatePicker} from 'naive-ui'
import {storeToRefs} from "pinia";
import {useReportStore} from "../Stores/report.js";
const themeOverride = {
peers: {
Input: {
border: null,
color: null,
colorFocus: null,
borderHover: null,
borderFocus: null,
boxShadowFocus: null,
paddingMedium: ''
}
}
}
const reportStore = useReportStore()
const { timestampCurrentRange } = storeToRefs(reportStore)
</script>
<template>
<NDatePicker :theme-overrides="themeOverride"
v-model:value="timestampCurrentRange"
format="dd.MM.YYYY"
type="daterange"
@update-value="reportStore.getDataOnReportDate"
/>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,56 @@
<script setup>
import {NButton, NP, NFlex, NIcon} from "naive-ui";
import {Link} from "@inertiajs/vue3";
import {computed, h} from "vue";
const props = defineProps({
title: {
type: String,
default: ''
},
description: {
type: String,
default: ''
},
href: {
type: String,
default: '/'
},
icon: {
type: [Object, Function],
default: null
}
})
const buttonThemeOverride = {
heightLarge: '64px',
borderRadiusLarge: '8px'
}
const pThemeOverride = {
pMargin: '',
pLineHeight: '1.4'
}
const hasIcon = computed(() => props.icon !== null)
</script>
<template>
<NButton :tag="Link" :href="href" :theme-overrides="buttonThemeOverride" size="large" block class="justify-start! text-left!">
<template v-if="hasIcon" #icon>
<component :is="icon" v-if="icon" />
</template>
<NFlex vertical :size="2" :class="hasIcon ? 'ml-2' : ''">
{{ title }}
<NP :theme-overrides="pThemeOverride" depth="3">
{{ description }}
</NP>
</NFlex>
</NButton>
</template>
<style scoped>
:deep(.n-button) {
@apply justify-start!;
}
</style>

View File

@@ -0,0 +1,41 @@
import {h} from "vue";
import {NCode, NLog} from "naive-ui"
export function useNotification() {
const showNotification = (options) => {
if (window.$notification) {
return window.$notification.create(options)
}
return null
}
const success = () => {
}
const errorApi = (data, content = '', options = {}) => {
return showNotification(
{
title: 'Произошла ошибка',
description: `Код: ${data.code}\nURL: ${data.response.config.url}\nМетод: ${data.response.config.method.toUpperCase()}`,
content: () => {
return h(
NLog,
{
rows: 4,
log: data.response.config.data,
}
)
},
meta: options.meta || new Date().toLocaleDateString(),
...options,
type: 'error'
}
)
}
return {
errorApi
}
}

View File

@@ -0,0 +1,51 @@
<script setup>
import {NLayout, NLayoutSider, NConfigProvider, NLayoutHeader, ruRU, dateRuRU, darkTheme} from "naive-ui";
import SideMenu from "./Components/SideMenu.vue";
import AppHeader from "./Components/AppHeader.vue";
const themeOverrides = {
Modal: {
peers: {
Dialog: {
borderRadius: '8px'
},
Card: {
borderRadius: '8px'
},
}
}
}
</script>
<template>
<NConfigProvider :theme="darkTheme" :theme-overrides="themeOverrides" :locale="ruRU" :date-locale="dateRuRU">
<NLayout position="absolute">
<NLayoutHeader style="height: 48px;" bordered>
<AppHeader />
</NLayoutHeader>
<NLayout has-sider position="absolute" class="top-12!" content-class="relative" :native-scrollbar="false">
<!-- <NLayoutSider-->
<!-- :native-scrollbar="false"-->
<!-- width="290"-->
<!-- class="h-[100vh-48px]!"-->
<!-- >-->
<!-- <SideMenu />-->
<!-- </NLayoutSider>-->
<NLayout content-class="pl-4">
<div>
<slot name="header" />
</div>
<slot />
</NLayout>
</NLayout>
</NLayout>
</NConfigProvider>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,29 @@
<script setup>
import { NFlex, NSpace, NDivider, NButton } from 'naive-ui'
import ReportSelectDate from "../../Components/ReportSelectDate.vue";
import AppUserButton from "./AppUserButton.vue";
import {Link} from "@inertiajs/vue3";
</script>
<template>
<NFlex justify="space-between" align="center" class="px-4 w-full h-full">
<NSpace align="center">
<NButton :tag="Link" text href="/">
Метрика
</NButton>
<NDivider vertical />
<ReportSelectDate />
</NSpace>
<NSpace align="center">
<NButton :tag="Link" text href="/">
Мои отчеты??
</NButton>
<NDivider vertical />
<AppUserButton />
</NSpace>
</NFlex>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,32 @@
<script setup>
import {useAuthStore} from "../../Stores/auth.js";
import {NDropdown, NButton} from 'naive-ui'
const authStore = useAuthStore()
const userOptions = [
{
label: 'Выход',
key: 'exit',
},
]
const themeOverride = {
border: null,
borderHover: null,
borderPressed: null,
borderFocus: null,
paddingMedium: null,
rippleColor: null
}
</script>
<template>
<NDropdown :options="userOptions" placement="bottom-end">
<NButton :theme-overrides="themeOverride">
{{ authStore.user?.name }}
</NButton>
</NDropdown>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,17 @@
<script setup>
import {NDatePicker} from 'naive-ui'
const onUpdateDateQuery = (value) => {
axios.get(`/api/metric-forms/1/report-by-date?sent_at=${value}`)
.then(res => {
console.log(res)
})
}
</script>
<template>
<NDatePicker panel type="date" @update-value="onUpdateDateQuery" />
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,218 @@
<script setup>
import { ref, reactive } from 'vue'
import {Head, router, useForm} from '@inertiajs/vue3'
import { useAuthStore } from '../../Stores/auth.js'
import {
NForm, NFormItem, NInput, NButton, NCheckbox,
NSpace, NCard, NIcon, NAlert, NModal, darkTheme,
NConfigProvider, NLayout, NP
} from 'naive-ui'
const authStore = useAuthStore()
// Состояние формы
const formRef = ref(null)
const form = useForm({
login: '',
password: '',
remember: false,
})
// Состояние UI
const loading = ref(false)
const forgotLoading = ref(false)
const error = ref('')
const showForgotPassword = ref(false)
// Правила валидации
const rules = {
login: [
{ required: true, message: 'Введите логин', trigger: 'blur' },
],
password: [
{ required: true, message: 'Введите пароль', trigger: 'blur' },
{ min: 3, message: 'Пароль должен содержать минимум 3 символа', trigger: 'blur' }
]
}
// Обработка входа
const handleLogin = async () => {
error.value = ''
try {
await formRef.value?.validate()
loading.value = true
form.post(
'/auth/login',
{
onSuccess: () => {},
onError: (err) => {
error.value = err
}
}
)
} catch (validationError) {
console.log('Ошибка валидации:', validationError)
} finally {
loading.value = false
}
}
// Восстановление пароля
const handleForgotPassword = async () => {
forgotLoading.value = true
// Здесь будет запрос на восстановление пароля
await new Promise(resolve => setTimeout(resolve, 1000))
forgotLoading.value = false
showForgotPassword.value = false
}
</script>
<template>
<NConfigProvider :theme="darkTheme">
<NLayout embedded>
<div class="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<Head title="Вход в систему" />
<div class="max-w-md w-full space-y-8">
<!-- Логотип и заголовок -->
<div class="text-center">
<div class="flex justify-center">
<div class="w-16 h-16 bg-blue-600 rounded-full flex items-center justify-center">
<n-icon size="32" color="white">
<Hospital />
</n-icon>
</div>
</div>
<h2 class="mt-6 text-3xl font-bold">
Метрика
</h2>
<NP class="mt-2! text-sm" depth="3">
Введите данные для входа в систему
</NP>
</div>
<!-- Форма входа -->
<n-card>
<n-form
ref="formRef"
:model="form"
:rules="rules"
@submit.prevent="handleLogin"
>
<n-space vertical size="large">
<!-- Email -->
<n-form-item label="Логин" path="login">
<n-input
v-model:value="form.login"
placeholder="Ваш логин"
size="large"
@keydown.enter="handleLogin"
>
<template #prefix>
<n-icon><Mail /></n-icon>
</template>
</n-input>
</n-form-item>
<!-- Пароль -->
<n-form-item label="Пароль" path="password">
<n-input
v-model:value="form.password"
type="password"
placeholder="Ваш пароль"
size="large"
show-password-on="click"
@keydown.enter="handleLogin"
>
<template #prefix>
<n-icon><LockClosed /></n-icon>
</template>
</n-input>
</n-form-item>
<!-- Запомнить меня -->
<n-form-item>
<n-checkbox v-model:checked="form.remember">
Запомнить меня
</n-checkbox>
</n-form-item>
<!-- Ошибки -->
<n-alert v-if="error" title="Ошибка входа" type="error">
{{ error }}
</n-alert>
<!-- Кнопка входа -->
<n-button
type="primary"
size="large"
:loading="loading"
@click="handleLogin"
block
>
Войти в систему
</n-button>
<!-- Дополнительные ссылки -->
<div class="text-center space-y-3">
<div class="text-sm">
<n-button text type="primary" @click="showForgotPassword = true">
Забыли пароль?
</n-button>
</div>
<!-- <div class="text-sm text-gray-600">-->
<!-- Нет аккаунта?-->
<!-- <n-button text type="primary" @click="$inertia.visit(route('register'))">-->
<!-- Зарегистрироваться-->
<!-- </n-button>-->
<!-- </div>-->
</div>
</n-space>
</n-form>
</n-card>
<!-- Информация о системе -->
<NP class="mt-2! text-xs! text-center" depth="3">
<p>Метрика v1.0</p>
<p>Только для авторизованного персонала</p>
</NP>
</div>
<!-- Модальное окно восстановления пароля -->
<n-modal v-model:show="showForgotPassword">
<n-card
style="width: 400px"
title="Восстановление пароля"
:bordered="false"
size="small"
>
<n-form :model="forgotForm" @submit.prevent="handleForgotPassword">
<n-space vertical>
<n-form-item label="Email" required>
<n-input
v-model:value="forgotForm.email"
placeholder="Введите ваш email"
/>
</n-form-item>
<n-button type="primary" block :loading="forgotLoading">
Отправить инструкции
</n-button>
</n-space>
</n-form>
</n-card>
</n-modal>
</div>
</NLayout>
</NConfigProvider>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,59 @@
<script setup>
import AppLayout from "../Layouts/AppLayout.vue";
import {useAuthStore} from "../Stores/auth.js";
import {NH1, NSpace, NP, NFlex} from 'naive-ui'
import StartButton from "../Components/StartButton.vue";
import {computed} from "vue";
import {format} from "date-fns";
import {ru} from "date-fns/locale";
import {useNow} from "@vueuse/core";
import {TbArticle, TbChartTreemap} from "vue-icons-plus/tb";
import {useReportStore} from "../Stores/report.js";
const authStore = useAuthStore()
const reportStore = useReportStore()
const currentDate = computed(() => {
const formatted = format(useNow().value, 'PPPPpp', {
locale: ru
})
return formatted.charAt(0).toUpperCase() + formatted.slice(1)
})
</script>
<template>
<AppLayout>
<div class="flex flex-col justify-start items-center mt-12">
<NFlex vertical align="center" justify="center" class="max-w-xl w-full">
<NSpace vertical align="center">
<NH1 class="mb-0!">
Привет {{authStore.user.name}}!
</NH1>
<NP class="mb-4!">
{{ currentDate }}
</NP>
</NSpace>
<StartButton title="Заполнить сводную"
description="Заполняется регулярно"
href="/dashboard"
:icon="TbArticle"
/>
<StartButton title="Статистика моего отделения"
:description="`Ваше отделение в системе: ${authStore.user.current_department.departmentname}`"
:href="`/statistic?sent_at=${reportStore.timestampCurrentRange}&groupId=1`"
:icon="TbChartTreemap"
/>
<StartButton title="Заполнить сводную"
description="Заполняется регулярно"
href="/dashboard"
/>
</NFlex>
</div>
</AppLayout>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,602 @@
<script setup>
import { ref, computed, reactive, onMounted, watch } from 'vue'
import { Head, router, useForm } from '@inertiajs/vue3'
import {
NForm, NFormItem, NInput, NInputNumber, NSelect,
NSwitch, NButton, NSpace, NCard, NH1, NH3,
NText, NTag, NProgress, NDivider, NGrid, NGi,
NAlert, NStatistic, NIcon, NTooltip, NRadioGroup,
NRadio, NCheckbox, NDatePicker, NTimePicker
} from 'naive-ui'
import {useNotification} from "../../../Composables/useNotification.js";
const props = defineProps({
groupId: {
type: [String, Number],
required: true
},
initialData: {
type: Object,
default: () => ({})
}
})
const { errorApi } = useNotification()
// Состояние формы
const formRef = ref(null)
const form = ref({
group_id: props.groupId,
comment: '',
// Динамические поля будут добавлены позже
})
const formProcessing = ref(false)
const formSections = ref([])
const formRules = ref({})
const formTitle = ref('')
const formDescription = ref('')
const isSubmitting = ref(false)
const isSavingDraft = ref(false)
const lastSavedTime = ref(null)
// Используем useForm из Inertia
// const { data: form, defaults, post, processing, errors: formErrors, reset } = useForm()
// Загрузка формы
onMounted(async () => {
await loadFormData()
await loadExistingData()
})
// Загрузка структуры формы
const loadFormData = async () => {
try {
const response = await axios.get(`/api/metric-forms/${props.groupId}`)
const formData = response.data
console.log(formData)
formTitle.value = formData.group.name
formDescription.value = formData.group.description
formSections.value = formData.form.sections
// Инициализируем значения формы
const initialValues = { group_id: props.groupId, comment: '' }
for (let fieldName in formData.form.default) {
initialValues[fieldName] = formData.form.default[fieldName]
}
form.value = {
...form.value,
...initialValues
}
// Настраиваем правила валидации
formRules.value = formData.form.validation
} catch (error) {
console.error('Ошибка загрузки формы:', error)
// errorApi()
}
}
// Загрузка существующих данных
const loadExistingData = async () => {
try {
const response = await axios.get(`/api/metric-forms/${props.groupId}/existing`)
if (response.data.existing_data) {
const existing = response.data.existing_data
// Заполняем форму существующими данными
Object.keys(existing.data).forEach(key => {
form[key] = existing.data[key]
})
form.comment = existing.comment || ''
lastSavedTime.value = new Date(existing.submitted_at).toLocaleTimeString('ru-RU')
}
} catch (error) {
console.error('Ошибка загрузки существующих данных:', error)
}
}
// Вычисляемые свойства
const currentDate = computed(() => {
return new Date().toLocaleDateString('ru-RU', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})
})
const totalFieldsCount = computed(() => {
return formSections.value.reduce((total, section) => total + section.fields.length, 0)
})
const filledFieldsCount = computed(() => {
return Object.keys(form.value).filter(key =>
key.startsWith('metrika_item_') &&
form.value[key] !== null &&
form.value[key] !== '' &&
form.value[key] !== undefined
).length
})
const progressPercentage = computed(() => {
if (totalFieldsCount.value === 0) return 0
return Math.round((filledFieldsCount.value / totalFieldsCount.value) * 100)
})
const requiredFieldsCount = computed(() => {
return formSections.value.reduce((total, section) => {
return total + section.fields.filter(f => f.is_required).length
}, 0)
})
const filledRequiredFieldsCount = computed(() => {
let count = 0
formSections.value.forEach(section => {
section.fields.forEach(field => {
if (field.is_required) {
const fieldName = `metrika_item_${field.metrika_item_id}`
if (form.value[fieldName] !== null && form.value[fieldName] !== '' && form.value[fieldName] !== undefined) {
count++
}
}
})
})
return count
})
const requiredFieldsStatus = computed(() => {
const filled = filledRequiredFieldsCount.value
const total = requiredFieldsCount.value
if (filled === 0) return { type: 'error', text: '0%' }
if (filled === total) return { type: 'success', text: '100%' }
const percent = Math.round((filled / total) * 100)
return {
type: percent > 50 ? 'warning' : 'error',
text: `${percent}%`
}
})
const formStatus = computed(() => {
if (filledFieldsCount.value === 0) return 'empty'
if (filledRequiredFieldsCount.value < requiredFieldsCount.value) return 'incomplete'
// if (Object.keys(formErrors).length > 0) return 'invalid'
return 'ready'
})
const formStatusText = computed(() => {
const texts = {
'empty': 'Не заполнено',
'incomplete': 'Не все обязательные поля',
'invalid': 'Есть ошибки',
'ready': 'Готово к отправке'
}
return texts[formStatus.value] || 'Неизвестно'
})
const formStatusType = computed(() => {
const types = {
'empty': 'default',
'incomplete': 'warning',
'invalid': 'error',
'ready': 'success'
}
return types[formStatus.value] || 'default'
})
const canSubmit = computed(() => {
return formStatus.value === 'ready' && !formProcessing.value
})
const hasData = computed(() => {
return filledFieldsCount.value > 0
})
const showStatistics = computed(() => {
return totalFieldsCount.value > 0
})
// Методы
const getFieldComponent = (dataType) => {
const components = {
'integer': NInputNumber,
'float': NInputNumber,
'string': NInput,
'text': NInput,
'textarea': NInput,
'boolean': NSwitch,
'select': NSelect,
'radio': NRadioGroup,
'checkbox': NCheckbox,
'date': NDatePicker,
'time': NTimePicker
}
return components[dataType] || NInput
}
const getFieldOptions = (field) => {
if (field.options && Array.isArray(field.options)) {
return field.options.map(opt => ({
label: opt.label || opt,
value: opt.value || opt
}))
}
return []
}
const getFieldName = (fieldKey) => {
const itemId = fieldKey.replace('metrika_item_', '')
for (const section of formSections.value) {
for (const field of section.fields) {
if (field.metrika_item_id.toString() === itemId) {
return field.metrika_item_name
}
}
}
return fieldKey
}
const getStatusTagType = (status) => {
const types = {
'empty': 'default',
'incomplete': 'warning',
'invalid': 'error',
'ready': 'success'
}
return types[status] || 'default'
}
const submitForm = async () => {
if (!formRef.value) return
// Валидация формы
await formRef.value.validate()
// if (Object.keys(formErrors).length > 0) {
// // notification.warning({
// // title: 'Ошибки в форме',
// // content: 'Исправьте ошибки перед отправкой',
// // duration: 4000
// // })
// return
// }
isSubmitting.value = true
try {
// const response = await post(route('metric-forms.save', { group: props.groupId }), {
// onSuccess: () => {
// notification.success({
// title: 'Успешно!',
// content: 'Отчет сохранен',
// duration: 5000
// })
// lastSavedTime.value = new Date().toLocaleTimeString('ru-RU')
// },
// onError: (errors) => {
// notification.error({
// title: 'Ошибка сохранения',
// content: 'Проверьте правильность заполнения данных',
// duration: 5000
// })
// }
// })
const response = await axios.post(`/api/metric-forms/${props.groupId}/save`, formRef.value)
.then(res => {
// notification.success({
// title: 'Успешно!',
// content: 'Отчет сохранен',
// duration: 5000
// })
router.visit('/')
lastSavedTime.value = new Date().toLocaleTimeString('ru-RU')
})
.catch(e => {
// notification.error({
// title: 'Ошибка сохранения',
// content: 'Проверьте правильность заполнения данных',
// duration: 5000
// })
})
} catch (error) {
console.error('Ошибка сохранения:', error)
// notification.error({
// title: 'Ошибка',
// content: error.message || 'Произошла ошибка при сохранении',
// duration: 5000
// })
} finally {
isSubmitting.value = false
}
}
const saveDraft = async () => {
isSavingDraft.value = true
try {
// Сохраняем в localStorage
const draftData = {
form: form.value,
timestamp: new Date().toISOString(),
groupId: props.groupId
}
localStorage.setItem(`metric_form_draft_${props.groupId}`, JSON.stringify(draftData))
lastSavedTime.value = new Date().toLocaleTimeString('ru-RU')
// message.success('Черновик сохранен')
} catch (error) {
console.error('Ошибка сохранения черновика:', error)
// message.error('Не удалось сохранить черновик')
} finally {
isSavingDraft.value = false
}
}
const resetForm = () => {
if (confirm('Вы уверены, что хотите сбросить форму? Все несохраненные данные будут потеряны.')) {
reset()
loadFormData()
// message.info('Форма сброшена')
}
}
const exportFormData = () => {
const exportData = {
group: formTitle.value,
date: currentDate.value,
data: {},
comment: form.comment
}
// Собираем данные полей
formSections.value.forEach(section => {
section.fields.forEach(field => {
const fieldName = `metrika_item_${field.metrika_item_id}`
exportData.data[field.metrika_item_name] = form.value[fieldName]
})
})
// Создаем и скачиваем файл
const dataStr = JSON.stringify(exportData, null, 2)
const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr)
const link = document.createElement('a')
link.setAttribute('href', dataUri)
link.setAttribute('download', `metric_report_${formTitle.value}_${new Date().toISOString().split('T')[0]}.json`)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
</script>
<template>
<div>
<Head :title="`Ввод данных: ${formTitle}`" />
<!-- Заголовок формы -->
<div class="max-w-6xl mx-auto mt-4 mb-4">
<div class="flex items-center justify-between">
<div>
<n-h1 class="!mb-2">{{ formTitle }}</n-h1>
<n-text depth="3" v-if="formDescription">
{{ formDescription }}
</n-text>
<div class="flex items-center gap-2 mt-2">
<n-tag :type="getStatusTagType(formStatus)" size="small" round>
{{ formStatusText }}
</n-tag>
<n-text depth="3" class="text-sm">
{{ currentDate }}
</n-text>
</div>
</div>
<!-- <n-space>-->
<!-- <n-button @click="$inertia.visit(route('dashboard'))" secondary>-->
<!-- Назад-->
<!-- </n-button>-->
<!-- <n-button @click="exportFormData" :disabled="!hasData">-->
<!-- Экспорт-->
<!-- </n-button>-->
<!-- </n-space>-->
</div>
</div>
<!-- Прогресс заполнения -->
<div class="max-w-6xl mx-auto mb-2">
<n-card size="small">
<n-space justify="space-between" align="center">
<n-text>Прогресс заполнения</n-text>
<n-text strong>{{ progressPercentage }}%</n-text>
</n-space>
<n-progress
:percentage="progressPercentage"
:height="6"
:border-radius="0"
:show-indicator="false"
class="mt-2"
/>
</n-card>
</div>
<!-- Основная форма -->
<div class="max-w-6xl mx-auto">
<n-card>
<n-form
ref="formRef"
:model="form"
:rules="formRules"
@submit.prevent="submitForm"
>
<!-- Секции формы -->
<div v-for="(section, sectionIndex) in formSections" :key="sectionIndex">
<n-divider v-if="sectionIndex > 0" />
<div class="mb-6">
<n-h3>{{ section.name }}</n-h3>
<n-text depth="3" class="text-sm">
Заполните данные по секции
</n-text>
</div>
<n-grid :cols="section.fields.length > 5 ? 2 : 1" :x-gap="24" :y-gap="16">
<n-gi
v-for="field in section.fields"
:key="field.metrika_item_id"
:span="field.span || 1"
>
<n-form-item
:label="field.metrika_item_name"
:path="`metrika_item_${field.metrika_item_id}`"
:required="field.is_required"
>
<!-- <template #label>-->
<!-- <div class="flex items-center gap-2">-->
<!-- <span>{{ field.metrika_item_name }}</span>-->
<!-- <n-tooltip v-if="field.metrika_item_description" trigger="hover">-->
<!-- <template #trigger>-->
<!-- <n-icon size="14" class="cursor-help">-->
<!-- <InfoCircle />-->
<!-- </n-icon>-->
<!-- </template>-->
<!-- {{ field.metrika_item_description }}-->
<!-- </n-tooltip>-->
<!-- </div>-->
<!-- </template>-->
<!-- Динамические поля ввода -->
<component
:is="getFieldComponent(field.metrika_item_data_type)"
v-model:value="form[`metrika_item_${field.metrika_item_id}`]"
:placeholder="field.placeholder || `Введите ${field.metrika_item_name.toLowerCase()}`"
:options="getFieldOptions(field)"
:min="field.validation_rules?.min"
:max="field.validation_rules?.max"
:step="field.metrika_item_data_type === 'float' ? 0.1 : 1"
:rows="3"
clearable
class="w-full"
/>
<!-- <template #feedback>-->
<!-- <div v-if="formErrors[`metric_${field.metrika_item_id}`]" class="text-red-500 text-xs mt-1">-->
<!-- {{ formErrors[`metric_${field.metrika_item_id}`][0] }}-->
<!-- </div>-->
<!-- </template>-->
</n-form-item>
</n-gi>
</n-grid>
</div>
<!-- Комментарий -->
<n-form-item label="Комментарий" path="comment">
<n-input
v-model:value="form.comment"
type="textarea"
placeholder="Дополнительные заметки и комментарии..."
:autosize="{ minRows: 3, maxRows: 6 }"
/>
</n-form-item>
<!-- Валидационные ошибки -->
<!-- <n-alert-->
<!-- v-if="Object.keys(formErrors).length > 0"-->
<!-- title="Ошибки заполнения"-->
<!-- type="error"-->
<!-- class="mb-4"-->
<!-- >-->
<!-- <ul class="list-disc pl-5">-->
<!-- <li v-for="(errors, field) in formErrors" :key="field">-->
<!-- {{ getFieldName(field) }}: {{ errors[0] }}-->
<!-- </li>-->
<!-- </ul>-->
<!-- </n-alert>-->
<!-- Статистика -->
<!-- <n-card v-if="showStatistics" title="Статистика" size="small" class="mb-4">-->
<!-- <n-grid :cols="4" responsive="screen">-->
<!-- <n-gi>-->
<!-- <n-statistic label="Заполнено полей">-->
<!-- {{ filledFieldsCount }} / {{ totalFieldsCount }}-->
<!-- </n-statistic>-->
<!-- </n-gi>-->
<!-- <n-gi>-->
<!-- <n-statistic label="Обязательные поля">-->
<!-- <n-tag :type="requiredFieldsStatus.type" size="small">-->
<!-- {{ requiredFieldsStatus.text }}-->
<!-- </n-tag>-->
<!-- </n-statistic>-->
<!-- </n-gi>-->
<!-- <n-gi>-->
<!-- <n-statistic label="Последнее сохранение">-->
<!-- {{ lastSavedTime || 'Не сохранено' }}-->
<!-- </n-statistic>-->
<!-- </n-gi>-->
<!-- <n-gi>-->
<!-- <n-statistic label="Статус">-->
<!-- <n-tag :type="formStatusType" size="small">-->
<!-- {{ formStatusText }}-->
<!-- </n-tag>-->
<!-- </n-statistic>-->
<!-- </n-gi>-->
<!-- </n-grid>-->
<!-- </n-card>-->
<!-- Кнопки действий -->
<n-space justify="end">
<!-- <n-button @click="resetForm" :disabled="isSubmitting">-->
<!-- Сбросить-->
<!-- </n-button>-->
<!-- <n-button-->
<!-- @click="saveDraft"-->
<!-- :loading="isSavingDraft"-->
<!-- secondary-->
<!-- >-->
<!-- Сохранить черновик-->
<!-- </n-button>-->
<n-button
type="primary"
@click="submitForm"
:loading="isSubmitting"
:disabled="!canSubmit"
>
Сохранить отчет
</n-button>
</n-space>
</n-form>
</n-card>
</div>
</div>
</template>
<style scoped>
.n-form-item :deep(.n-form-item-label__text) {
display: flex;
align-items: center;
gap: 4px;
}
.n-divider {
margin: 32px 0;
}
.n-statistic :deep(.n-statistic-label) {
font-size: 0.875rem;
color: #6b7280;
}
.n-statistic :deep(.n-statistic-value) {
font-size: 1.125rem;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,19 @@
<script setup>
import AppLayout from "../../Layouts/AppLayout.vue";
import {NList, NListItem, NFlex, NInput, NButton} from 'naive-ui'
import {useForm} from "@inertiajs/vue3";
import {computed} from "vue";
import MetrikaForm from "../Metriks/Components/MetrikaForm.vue";
const props = defineProps({
metriks: {
type: Array
}
})
</script>
<template>
<AppLayout>
<MetrikaForm :group-id="1" />
</AppLayout>
</template>

View File

@@ -0,0 +1,58 @@
<script setup>
import AppLayout from "../../Layouts/AppLayout.vue";
import {NList, NListItem, NFlex, NText, NH1, NTag} from 'naive-ui'
const props = defineProps({
is_view_only: {
type: Boolean,
default: false
},
period: {
type: Object,
default: {}
},
group: {
type: Object,
default: {}
},
metrics: {
type: Object,
default: {}
},
form: {
type: Object,
default: {}
},
})
</script>
<template>
<AppLayout>
<div class="max-w-6xl mx-auto mt-4 mb-4">
<div class="flex items-center justify-between">
<div>
<n-h1 class="!mb-2">{{ group.name }}</n-h1>
<n-text depth="3" v-if="group.description">
{{ group.description }}
</n-text>
</div>
</div>
<NList>
<NListItem v-for="metric in metrics.values">
<NFlex justify="space-between" align="center" class="w-full px-4">
<div>
{{metric.item_name}}
</div>
<div>
{{metric.sum}}
</div>
</NFlex>
</NListItem>
</NList>
</div>
</AppLayout>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,27 @@
import {createDiscreteApi, darkTheme, lightTheme} from "naive-ui";
export function setupNaiveDiscreteApi(app) {
const {
message,
notification,
dialog,
loadingBar
} = createDiscreteApi(
['message', 'dialog', 'notification', 'loadingBar'],
{
configProviderProps: {
theme: window.matchMedia('(prefers-color-scheme: dark)').matches ? darkTheme : lightTheme
}
}
)
window.$notification = notification
window.$message = message
window.$dialog = dialog
window.$loadingBar = loadingBar
app.config.globalProperties.$notification = notification
app.config.globalProperties.$message = message
app.config.globalProperties.$dialog = dialog
app.config.globalProperties.$loadingBar = loadingBar
}

View File

@@ -0,0 +1,74 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import axios from 'axios'
import {usePage} from "@inertiajs/vue3";
export const useAuthStore = defineStore('authStore', () => {
const user = usePage().props.user
const token = user.token
const permissions = user.permissions
const availableDepartments = ref([])
// Инициализация axios с токеном
if (token.value) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token.value}`
}
// Вычисляемые свойства
const isAuthenticated = computed(() => !!user.value && !!token.value)
const isAdmin = computed(() => user.role === 'admin')
const isDoctor = computed(() => user.role === 'doctor')
const isNurse = computed(() => user.role === 'nurse')
const isHeadOfDepartment = computed(() => user.role === 'head_of_department')
const isStatistician = computed(() => user.role === 'statistician')
const userDepartment = computed(() => user.department || '')
const clearAuthData = () => {
user.value = null
token.value = null
permissions.value = {}
availableDepartments.value = []
localStorage.removeItem('token')
localStorage.removeItem('user')
delete axios.defaults.headers.common['Authorization']
}
const logout = async () => {
try {
await axios.post('/api/auth/logout')
} catch (error) {
console.error('Ошибка при выходе:', error)
} finally {
clearAuthData()
}
}
// Проверка прав
const hasPermission = (permission) => {
return permissions.value[permission] === true
}
const canAccessDepartment = (department) => {
if (isAdmin.value || isHeadOfDepartment.value) return true
return availableDepartments.value.includes(department)
}
return {
user,
token,
permissions,
availableDepartments,
isAuthenticated,
isAdmin,
isDoctor,
isNurse,
isHeadOfDepartment,
isStatistician,
userDepartment,
clearAuthData,
hasPermission,
canAccessDepartment
}
})

View File

@@ -0,0 +1,44 @@
import {defineStore} from "pinia";
import {useTimestamp} from "@vueuse/core";
import {computed, ref} from "vue";
export const useReportStore = defineStore('reportStore', () => {
const timestampNow = useTimestamp()
const _timestampCurrent = ref(null)
const timestampCurrent = computed({
get: () => {
if (_timestampCurrent.value === null)
return timestampNow.value
return _timestampCurrent.value
},
set: (value) => {
_timestampCurrent.value = value
}
})
const timestampCurrentRange = ref([timestampNow.value, timestampNow.value])
const dataOnReport = ref(null)
const getDataOnReportDate = async () => {
await axios.get(`/api/metric-forms/1/report-by-date?sent_at=${timestampCurrentRange.value}`)
.then(res => {
dataOnReport.value = res.data
})
.catch(err => {
// Отчета на выбранную дату не найдено
if (err.code === 404) {}
})
}
return {
timestampNow,
timestampCurrent,
timestampCurrentRange,
dataOnReport,
getDataOnReportDate
}
})

30
resources/js/app.js Normal file
View File

@@ -0,0 +1,30 @@
import './bootstrap';
import '../css/app.css';
import { createApp, h } from 'vue'
import { createInertiaApp } from '@inertiajs/vue3'
import {createPinia} from "pinia";
import {setupNaiveDiscreteApi} from "./Plugins/NaiveUI.js";
createInertiaApp({
id: 'onboard',
resolve: name => {
const pages = import.meta.glob('./Pages/**/*.vue', {eager: true})
return pages[`./Pages/${name}.vue`]
},
setup({el, App, props, plugin}) {
const vueApp = createApp({
render: () => h(App, props)
})
const pinia = createPinia()
vueApp.use(plugin)
vueApp.use(pinia)
setupNaiveDiscreteApi(vueApp)
vueApp.mount(el)
},
}).then(r => {
console.log('Инициализация прошла успешно')
})

140
resources/js/bootstrap.js vendored Normal file
View File

@@ -0,0 +1,140 @@
import axios from 'axios';
import {useAuthStore} from "./Stores/auth.js";
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
window.axios.defaults.withCredentials = true
// Добавляем токен авторизации к запросам
window.axios.interceptors.request.use(
(config) => {
const authStore = useAuthStore()
// Если токен есть, добавляем его в заголовки
if (authStore.token) {
config.headers.Authorization = `Bearer ${authStore.token}`
}
// Для форм добавляем заголовок Content-Type
if (config.data instanceof FormData) {
config.headers['Content-Type'] = 'multipart/form-data'
} else {
config.headers['Content-Type'] = 'application/json'
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// Интерцептор ответов для обработки ошибок авторизации
window.axios.interceptors.response.use(
(response) => response,
(error) => {
const authStore = useAuthStore()
console.log('Ошибка в API', error.response)
// Если ошибка 401 (Unauthorized)
if (error.response?.status === 401) {
// Если пользователь был авторизован, выполняем выход
if (authStore.isAuthenticated) {
authStore.logout()
}
}
// Если ошибка 403 (Forbidden)
if (error.response?.status === 403) {
console.error('Доступ запрещен:', error.response?.data?.message)
}
// Если ошибка 422 (Validation Error)
if (error.response?.status === 422) {
console.log('Ошибки валидации:', error.response?.data?.errors)
}
return Promise.reject(error)
}
)
// Вспомогательные методы для работы с API
const api = {
// GET запрос
get: async (url, config = {}) => {
try {
const response = await axios.get(url, config)
return response.data
} catch (error) {
throw error.response?.data || error
}
},
// POST запрос
post: async (url, data = {}, config = {}) => {
try {
const response = await axios.post(url, data, config)
return response.data
} catch (error) {
throw error.response?.data || error
}
},
// PUT запрос
put: async (url, data = {}, config = {}) => {
try {
const response = await axios.put(url, data, config)
return response.data
} catch (error) {
throw error.response?.data || error
}
},
// PATCH запрос
patch: async (url, data = {}, config = {}) => {
try {
const response = await axios.patch(url, data, config)
return response.data
} catch (error) {
throw error.response?.data || error
}
},
// DELETE запрос
delete: async (url, config = {}) => {
try {
const response = await axios.delete(url, config)
return response.data
} catch (error) {
throw error.response?.data || error
}
},
// Загрузка файлов
upload: async (url, formData, config = {}) => {
try {
const response = await axios.post(url, formData, {
...config,
headers: {
'Content-Type': 'multipart/form-data'
}
})
return response.data
} catch (error) {
throw error.response?.data || error
}
},
// Проверка токена
checkToken: async () => {
try {
const response = await axios.get('/auth/check-token')
return response.data.valid
} catch {
return false
}
}
}
export default api