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

Переписаны запросы для статистики, отчетов
Добавлена интеграция отчета сестры
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

@@ -0,0 +1,107 @@
<script setup>
import { NIcon, NText, NTag } from 'naive-ui'
import { TbLock } from 'vue-icons-plus/tb'
import { useThemeVars } from 'naive-ui'
import { computed } from 'vue'
const props = defineProps({
icon: { type: [Object, Function], default: null },
title: { type: String, required: true },
description: { type: String, default: '' },
href: { type: String, default: null },
tag: { type: [Object, Function, String], default: 'div' },
lock: { type: Boolean, default: false },
lockMessage: { type: String, default: '' },
})
const themeVars = useThemeVars()
const cardColor = computed(() => themeVars.value.cardColor)
const dividerColor = computed(() => themeVars.value.dividerColor)
const primaryColor = computed(() => themeVars.value.primaryColor)
const primaryGlow = computed(() => `color-mix(in srgb, ${themeVars.value.primaryColor} 14%, transparent)`)
const primaryShadow = computed(() => `color-mix(in srgb, ${themeVars.value.primaryColor} 30%, transparent)`)
const lockBg = computed(() => `color-mix(in srgb, ${themeVars.value.cardColor} 55%, transparent)`)
</script>
<template>
<component
:is="lock ? 'div' : tag"
:href="lock ? undefined : href"
class="action-tile"
:class="{ locked }"
style="text-decoration: none;"
>
<NIcon v-if="icon" size="26" class="tile-icon"><component :is="icon" /></NIcon>
<NText class="tile-title">{{ title }}</NText>
<NText depth="3" class="tile-desc">{{ description }}</NText>
<div v-if="lock" class="tile-lock">
<NTag :bordered="false" round size="small">
<template v-if="lockMessage" #icon>
<NIcon :size="13"><TbLock /></NIcon>
</template>
{{ lockMessage || 'Недоступно' }}
</NTag>
</div>
</component>
</template>
<style scoped>
.action-tile {
position: relative;
overflow: hidden;
border-radius: 16px;
padding: 18px 18px 16px;
cursor: pointer;
min-height: 114px;
display: flex;
flex-direction: column;
background: v-bind(cardColor);
border: 1px solid v-bind(dividerColor);
transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease;
}
.action-tile:hover:not(.locked) {
transform: scale(1.025);
border-color: v-bind(primaryColor);
box-shadow:
0 0 0 3px v-bind(primaryGlow),
0 8px 28px -8px v-bind(primaryShadow);
}
.action-tile.locked {
cursor: default;
}
.tile-icon {
color: v-bind(primaryColor);
opacity: 0.8;
}
.tile-title {
font-weight: 600;
font-size: 14px;
display: block;
margin-top: 10px;
line-height: 1.2;
}
.tile-desc {
font-size: 12px;
display: block;
margin-top: 4px;
line-height: 1.35;
}
.tile-lock {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(2.5px);
-webkit-backdrop-filter: blur(2.5px);
background: v-bind(lockBg);
}
</style>

View File

@@ -1,11 +1,11 @@
<script setup>
import {NDatePicker, NIcon, NFlex, NButton, NSkeleton} from 'naive-ui'
import {computed, onMounted, ref, watch} from "vue";
import {computed, ref} from "vue";
import {router} from "@inertiajs/vue3";
import {TbCalendar} from 'vue-icons-plus/tb'
import {formatRussianDate, formatRussianDateRange} from "../Utils/dateFormatter.js";
import {useReportStore} from "../Stores/report.js";
import {formatDistanceStrict, getTime} from "date-fns";
import {formatDistanceStrict} from "date-fns";
const props = defineProps({
// Пользователь с ролью админ или зав
@@ -16,9 +16,6 @@ const props = defineProps({
date: {
type: [Number, Array]
},
isOneDay: {
type: Boolean
},
isShowCurrentDateSwitch: {
type: Boolean,
default: false
@@ -27,113 +24,203 @@ const props = defineProps({
const reportStore = useReportStore()
const isLoading = ref(false)
const isUseDateRange = computed(() => props.isHeadOrAdmin)
const datePicker = ref()
const showCalendar = ref(false)
const calendarType = ref(props.isHeadOrAdmin ? 'daterange' : 'date')
const dateType = computed(() => {
if (isUseDateRange.value) return 'daterange'
return 'date'
})
const isDateRange = computed(() => calendarType.value === 'daterange')
const isDateIsOneDay = computed(() => {
return formatDistanceStrict(modelValue.value[0], modelValue.value[1]) === '0 seconds' || formatDistanceStrict(modelValue.value[0], modelValue.value[1]) === '1 day'
})
const queryDate = ref([null, null])
const modelValue = defineModel('date')
const onChangeDate = () => {
if (isDateIsOneDay.value) {
const startDate = Date.parse(new Date(new Date().getFullYear(), 0, 1).toDateString())
const date = Date.parse(new Date().toDateString())
const arrayDate = [startDate, date]
handleDateUpdate(arrayDate)
} else {
const date = Date.parse(new Date().toDateString())
const arrayDate = [date, date]
handleDateUpdate(arrayDate)
}
showCalendar.value = false
}
const setQueryDate = () => {
reportStore.openedCollapsible = []
router.reload({
data: {
startAt: queryDate.value[0],
endAt: queryDate.value[1]
},
onFinish: () => {
isLoading.value = false
}
})
}
// Всегда используем диапазон, но для врача показываем как один день
const calendarType = 'daterange'
// Форматированное значение для отображения
const formattedValue = computed(() => {
const value = modelValue.value
if (props.isHeadOrAdmin) {
if (props.isOneDay) {
const dateToFormat = Array.isArray(value) ? value[1] : value
return formatRussianDate(dateToFormat)
} else if (Array.isArray(value) && value.length >= 2 && value[0] && value[1]) { // Для админа - диапазон дат
return formatRussianDateRange(value)
}
// Если что-то пошло не так, форматируем как одиночную дату
if (value) {
const dateToFormat = Array.isArray(value) ? value[0] : value
return formatRussianDate(dateToFormat)
}
return ''
} else {
// Для врача - одиночная дата
let dateToFormat
if (Array.isArray(value)) {
dateToFormat = value[1] || value[0]
} else {
dateToFormat = value
}
// Если выбрана сегодняшняя дата - показываем текущее время
if (dateToFormat) {
return formatRussianDate(dateToFormat)
}
if (!value || !Array.isArray(value) || !value[0] || !value[1]) {
return ''
}
// Для админа/зав. показываем диапазон
if (props.isHeadOrAdmin) {
return formatRussianDateRange(value)
}
// Для врача показываем одну дату (конец смены)
return formatRussianDate(value[1])
})
const handleDateUpdate = (value) => {
isLoading.value = true
// Устанавливаем новое значение
modelValue.value = value
// Проверяем, выбран ли один день
const isOneDaySelected = computed(() => {
if (!modelValue.value || !Array.isArray(modelValue.value)) return false
const [start, end] = modelValue.value
if (!start || !end) return false
// Для диапазона: отправляем только если обе даты заполнены
if (isDateRange.value) {
if (Array.isArray(value) && value[0] && value[1]) {
// Дебаунс для предотвращения двойной отправки
setTimeout(() => {
// Проверяем что значение не изменилось за время таймаута
if (JSON.stringify(modelValue.value) === JSON.stringify(value)) {
queryDate.value = value
setQueryDate()
}
}, 100)
}
const diff = formatDistanceStrict(start, end)
return diff === '0 seconds' || diff === '1 day'
})
// Блокировка будущих дат с учетом смен 09:00-09:00
const isDateDisabled = (ts) => {
const date = new Date(ts)
const now = new Date()
// Получаем сегодняшнюю дату без времени
const today = new Date()
today.setHours(0, 0, 0, 0)
// Если выбранная дата меньше сегодня - разрешаем (прошлые даты)
if (date < today) {
return false
}
// Получаем текущий час
const currentHour = now.getHours()
// Если сейчас меньше 09:00, то максимальная дата - сегодня
// Если сейчас 09:00 или больше, то максимальная дата - завтра
const maxDaysOffset = currentHour < 9 ? 0 : 1
const maxDate = new Date()
maxDate.setDate(maxDate.getDate() + maxDaysOffset)
maxDate.setHours(23, 59, 59, 999)
// Блокируем только даты строго больше максимальной
return date > maxDate
}
// Получить начало смены для даты
const getShiftStart = (date) => {
const shiftStart = new Date(date)
shiftStart.setHours(9, 0, 0, 0)
return shiftStart
}
// Получить конец смены для даты
const getShiftEnd = (date) => {
const shiftEnd = new Date(date)
shiftEnd.setHours(9, 0, 0, 0)
shiftEnd.setDate(shiftEnd.getDate() + 1)
return shiftEnd
}
// Получить текущую смену
const getCurrentShift = () => {
const now = new Date()
const today9am = new Date()
today9am.setHours(9, 0, 0, 0)
if (now < today9am) {
const start = new Date(today9am)
start.setDate(start.getDate() - 1)
return [start, today9am]
} else {
// Для одиночной даты отправляем сразу
if (value) {
queryDate.value = [value, value]
setQueryDate()
}
const end = new Date(today9am)
end.setDate(end.getDate() + 1)
return [today9am, end]
}
}
// Получить предыдущую смену
const getPreviousShift = () => {
const now = new Date()
const today9am = new Date()
today9am.setHours(9, 0, 0, 0)
if (now < today9am) {
const start = new Date(today9am)
start.setDate(start.getDate() - 2)
const end = new Date(today9am)
end.setDate(end.getDate() - 1)
return [start, end]
} else {
const start = new Date(today9am)
start.setDate(start.getDate() - 1)
return [start, today9am]
}
}
// Шорткаты
const shortcuts = {
'Сегодня': () => getCurrentShift(),
'Вчера': () => getPreviousShift(),
'За 7 дней': () => {
const currentShift = getCurrentShift()
const end = currentShift[1]
const start = new Date(end)
start.setDate(start.getDate() - 7)
start.setHours(9, 0, 0, 0)
return [start, end]
},
'За 30 дней': () => {
const currentShift = getCurrentShift()
const end = currentShift[1]
const start = new Date(end)
start.setDate(start.getDate() - 30)
start.setHours(9, 0, 0, 0)
return [start, end]
},
'Текущий год': () => {
const currentShift = getCurrentShift()
const end = currentShift[1]
const start = new Date(end.getFullYear(), 0, 1)
start.setHours(9, 0, 0, 0)
return [start, end]
}
}
// Обработка изменения даты
const handleDateUpdate = (value) => {
if (!value || !Array.isArray(value) || !value[0] || !value[1]) return
isLoading.value = true
let finalValue = value
// Для врача: если выбран диапазон, преобразуем в один день (берем конец смены)
if (!props.isHeadOrAdmin) {
// Если выбрано несколько дней, берем последний день
finalValue = [value[1], value[1]]
}
modelValue.value = finalValue
// Дебаунс для предотвращения двойной отправки
setTimeout(() => {
if (JSON.stringify(modelValue.value) === JSON.stringify(finalValue)) {
reportStore.openedCollapsible = []
router.reload({
data: {
startAt: finalValue[0],
endAt: finalValue[1]
},
onFinish: () => {
isLoading.value = false
}
})
}
}, 100)
}
// Кнопка "Сегодня/Текущий год"
const onQuickSelect = () => {
const currentShift = getCurrentShift()
if (isOneDaySelected.value) {
// Если выбран один день, переключаем на текущий год
const end = currentShift[1]
const start = new Date(end.getFullYear(), 0, 1)
start.setHours(9, 0, 0, 0)
handleDateUpdate([start, end])
} else {
// Если выбран диапазон, переключаем на сегодня
handleDateUpdate(currentShift)
}
showCalendar.value = false
}
// Текст для кнопки
const quickButtonText = computed(() => {
return isOneDaySelected.value ? 'Текущий год' : 'Сегодня'
})
</script>
<template>
@@ -142,30 +229,28 @@ const handleDateUpdate = (value) => {
<NSkeleton width="240" height="20" round />
</template>
<template v-else>
<div v-if="formattedValue" class="font-medium leading-3 cursor-pointer" @click="showCalendar = true">
<div
v-if="formattedValue"
class="font-medium leading-3 cursor-pointer"
@click="showCalendar = true"
>
{{ formattedValue }}
</div>
<NDatePicker v-model:value="modelValue"
v-model:show="showCalendar"
ref="datePicker"
class="opacity-0 absolute! inset-x-0 bottom-full -translate-y-1/2"
placement="top-start"
input-readonly
bind-calendar-months
@update:value="value => handleDateUpdate(value)"
:type="calendarType">
<template #confirm="{onConfirm, disabled, text}">
<NFlex justify="end" align="center" :size="0">
<NButton v-if="isShowCurrentDateSwitch" secondary type="primary" size="tiny" @click="onChangeDate()">
{{ isDateIsOneDay ? 'Текущий год' : 'Сегодня' }}
</NButton>
<NButton type="success" size="tiny" @click="onConfirm" :disabled="disabled">
{{ text }}
</NButton>
</NFlex>
</template>
<NDatePicker
v-model:value="modelValue"
v-model:show="showCalendar"
class="opacity-0 absolute! inset-x-0 bottom-full -translate-y-1/2"
placement="top-start"
:shortcuts="shortcuts"
:is-date-disabled="isDateDisabled"
input-readonly
bind-calendar-months
type="daterange"
@update:value="handleDateUpdate"
>
</NDatePicker>
<div class="cursor-pointer p-2 flex items-center justify-center" @click="showCalendar = true">
<NIcon size="20">
<TbCalendar />

View File

@@ -0,0 +1,112 @@
<script setup>
import { NEl, NFlex, NIcon, NText } from 'naive-ui'
import { TbChevronRight } from 'vue-icons-plus/tb'
import { useThemeVars } from 'naive-ui'
import { computed } from 'vue'
const props = defineProps({
title: { type: String, default: '' },
// null / имя цвета темы (primary|success|error|warning|info) / 'default' = нейтральный тёмный
color: { type: String, default: null },
// [{ label, href? }]
breadcrumbs: { type: Array, default: () => [] },
// компонент-иконка (если не нужен кастомный слот #icon)
icon: { type: [Object, Function], default: null },
})
const themeVars = useThemeVars()
const isNeutral = computed(() => props.color === 'default')
const cssColor = computed(() => isNeutral.value ? null : `var(--${props.color ?? 'primary'}-color)`)
const bannerBg = computed(() => isNeutral.value
? themeVars.value.cardColor
: `color-mix(in srgb, ${cssColor.value} 8%, ${themeVars.value.cardColor})`
)
const bannerBorder = computed(() => isNeutral.value
? themeVars.value.dividerColor
: `color-mix(in srgb, ${cssColor.value} 20%, transparent)`
)
const iconBg = computed(() => isNeutral.value
? `color-mix(in srgb, ${themeVars.value.textColor1} 10%, transparent)`
: `color-mix(in srgb, ${cssColor.value} 16%, transparent)`
)
const iconColor = computed(() => isNeutral.value
? themeVars.value.textColor2
: cssColor.value
)
const crumbColor = computed(() => isNeutral.value
? themeVars.value.primaryColor
: cssColor.value
)
</script>
<template>
<NEl
class="rounded-2xl px-6 py-5"
:style="`background: ${bannerBg}; border: 1px solid ${bannerBorder};`"
>
<!-- Хлебные крошки -->
<NFlex v-if="breadcrumbs.length" align="center" :size="6" style="margin-bottom: 12px;">
<template v-for="(crumb, i) in breadcrumbs" :key="i">
<NEl
v-if="crumb.href"
:tag="crumb.tag ?? 'a'"
:href="crumb.href"
:style="`font-size: 12px; color: ${crumbColor}; text-decoration: none; opacity: .75; transition: opacity .15s;`"
@mouseenter="e => e.currentTarget.style.opacity = 1"
@mouseleave="e => e.currentTarget.style.opacity = .75"
>
<NFlex align="center" :size="4">
<NIcon v-if="crumb.icon" size="13"><component :is="crumb.icon" /></NIcon>
{{ crumb.label }}
</NFlex>
</NEl>
<NText v-else depth="3" style="font-size: 12px;">{{ crumb.label }}</NText>
<NIcon v-if="i < breadcrumbs.length - 1" size="12" depth="3"><TbChevronRight /></NIcon>
</template>
</NFlex>
<!-- Основная строка -->
<NFlex align="center" justify="space-between" :wrap="false">
<NFlex align="center" :size="14" :wrap="false">
<!-- Иконка: кастомный слот или prop -->
<NEl
v-if="$slots.icon || icon"
class="rounded-xl"
:style="`
width: 48px; height: 48px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
background: ${iconBg};
`"
>
<slot name="icon">
<NIcon :size="24" :style="`color: ${iconColor};`">
<component :is="icon" />
</NIcon>
</slot>
</NEl>
<div class="min-w-0">
<!-- Заголовок: кастомный слот или prop -->
<slot name="title">
<span style="font-size: 20px; font-weight: 700; line-height: 1.2;">
{{ title }}
</span>
</slot>
<!-- Мета-строка -->
<div v-if="$slots.meta" style="margin-top: 5px;">
<slot name="meta" />
</div>
</div>
</NFlex>
<!-- Действия (кнопки) -->
<NFlex v-if="$slots.actions" :size="8" style="flex-shrink: 0;">
<slot name="actions" />
</NFlex>
</NFlex>
</NEl>
</template>

View File

@@ -0,0 +1,85 @@
<script setup>
import { NEl, NFlex, NIcon, NText } from 'naive-ui'
import { useThemeVars } from 'naive-ui'
import { computed } from 'vue'
const props = defineProps({
title: {
type: String,
default: ''
},
// null = нейтральный (фон из темы), либо: primary | success | warning | error | info
color: {
type: String,
default: null
},
icon: {
type: [Object, Function],
default: null
},
noPadding: {
type: Boolean,
default: false
}
})
const themeVars = useThemeVars()
const isNeutral = computed(() => !props.color)
const cssColor = computed(() => `var(--${props.color}-color)`)
const cardStyle = computed(() => isNeutral.value
? `background: ${themeVars.value.cardColor}; border: 1px solid ${themeVars.value.dividerColor};`
: `--sc-color: ${cssColor.value};
background: color-mix(in srgb, var(--sc-color) 5%, ${themeVars.value.cardColor});
border: 1px solid color-mix(in srgb, var(--sc-color) 24%, transparent);`
)
const headerStyle = computed(() => isNeutral.value
? `background: ${themeVars.value.tableHeaderColor}; border-bottom: 1px solid ${themeVars.value.dividerColor};`
: `background: color-mix(in srgb, var(--sc-color) 10%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--sc-color) 18%, transparent);`
)
const titleColor = computed(() => isNeutral.value
? themeVars.value.textColor2
: `color-mix(in srgb, var(--sc-color) 85%, ${themeVars.value.textColor1})`
)
const iconColor = computed(() => isNeutral.value
? themeVars.value.primaryColor
: `var(--sc-color)`
)
</script>
<template>
<NEl class="rounded-xl overflow-hidden" :style="cardStyle">
<!-- Заголовок -->
<NFlex
align="center"
justify="space-between"
:wrap="false"
style="padding: 12px 16px;"
:style="headerStyle"
>
<NFlex align="center" :size="8" :wrap="false">
<NIcon v-if="icon" size="15" :style="`color: ${iconColor}; flex-shrink: 0;`">
<component :is="icon" />
</NIcon>
<NText
style="font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .6px;"
:style="`color: ${titleColor};`"
>
{{ title }}
</NText>
</NFlex>
<slot name="header-extra" />
</NFlex>
<!-- Контент -->
<div :style="noPadding ? '' : 'padding: 16px;'">
<slot />
</div>
</NEl>
</template>

View File

@@ -0,0 +1,94 @@
<script setup>
import { NDatePicker, NIcon } from 'naive-ui'
import { computed, ref } from 'vue'
import { router } from '@inertiajs/vue3'
import { TbCalendar } from 'vue-icons-plus/tb'
import { formatRussianDateRange } from '../Utils/dateFormatter.js'
const props = defineProps({
date: {
type: Array,
default: null
}
})
const showCalendar = ref(false)
const isLoading = ref(false)
const currentShiftDate = computed(() => {
if (!props.date?.[0]) return null
const d = new Date(props.date[0])
d.setHours(0, 0, 0, 0)
return d
})
const formattedShift = computed(() => {
if (!props.date?.[0] || !props.date?.[1]) return ''
return formatRussianDateRange(props.date)
})
const pickerValue = computed(() => currentShiftDate.value?.getTime() ?? null)
const isDateDisabled = (ts) => {
const now = new Date()
const today9am = new Date()
today9am.setHours(9, 0, 0, 0)
const maxShiftStart = now < today9am
? new Date(today9am.setDate(today9am.getDate() - 1))
: today9am
maxShiftStart.setHours(0, 0, 0, 0)
return new Date(ts) > maxShiftStart
}
const navigateToShift = (dateMs) => {
if (!dateMs) return
const start = new Date(dateMs)
start.setHours(9, 0, 0, 0)
const end = new Date(start)
end.setDate(end.getDate() + 1)
isLoading.value = true
router.reload({
data: { startAt: start.getTime(), endAt: end.getTime() },
onFinish: () => { isLoading.value = false }
})
}
const onPickDate = (value) => {
showCalendar.value = false
navigateToShift(value)
}
</script>
<template>
<div class="relative inline-flex items-center">
<div
class="font-medium leading-3 cursor-pointer select-none"
@click="showCalendar = true"
>
{{ formattedShift }}
</div>
<NDatePicker
:value="pickerValue"
v-model:show="showCalendar"
class="opacity-0 absolute! inset-x-0 bottom-full -translate-y-1/2"
placement="top-start"
:is-date-disabled="isDateDisabled"
input-readonly
type="date"
@update:value="onPickDate"
/>
<div class="cursor-pointer p-2 flex items-center justify-center" @click="showCalendar = true">
<NIcon size="20"><TbCalendar /></NIcon>
</div>
</div>
</template>
<style scoped>
:deep(.n-input) {
position: absolute;
pointer-events: none;
}
</style>

View File

@@ -3,6 +3,7 @@ import {NButton, NP, NFlex, NTag, NIcon} from "naive-ui";
import {TbLock} from "vue-icons-plus/tb"
import {Link} from "@inertiajs/vue3";
import {computed, h} from "vue";
import { useThemeVars } from "naive-ui";
const props = defineProps({
title: {
@@ -49,6 +50,7 @@ const pThemeOverride = {
pLineHeight: '1.4'
}
const themeVars = useThemeVars()
const hasIcon = computed(() => props.icon !== null)
const isLink = computed(() => props.tag === 'link')
@@ -66,7 +68,7 @@ const lockTag = computed(() => {
</script>
<template>
<div class="relative overflow-hidden w-full rounded-md" style="background-color: rgb(18, 18, 22);">
<div class="relative overflow-hidden w-full rounded-md" :style="`background: ${themeVars.cardColor};`">
<NButton :tag="lockTag"
:href="href"
:theme-overrides="buttonThemeOverride"