Обновлен стартовый экран
Переписаны запросы для статистики, отчетов Добавлена интеграция отчета сестры
This commit is contained in:
107
resources/js/Components/ActionTile.vue
Normal file
107
resources/js/Components/ActionTile.vue
Normal 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>
|
||||
@@ -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 />
|
||||
|
||||
112
resources/js/Components/PageBanner.vue
Normal file
112
resources/js/Components/PageBanner.vue
Normal 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>
|
||||
85
resources/js/Components/SectionCard.vue
Normal file
85
resources/js/Components/SectionCard.vue
Normal 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>
|
||||
94
resources/js/Components/ShiftPickerQuery.vue
Normal file
94
resources/js/Components/ShiftPickerQuery.vue
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user