Files
onboard/resources/js/Pages/Statistic/Headquarters.vue

1329 lines
59 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import {
NFlex, NText, NStatistic, NGrid, NGridItem,
NButton, NDivider, NSpace, NScrollbar, NEmpty,
NBadge, NTag, NIcon, NModal, NList, NListItem,
NThing, NNumberAnimation, NTooltip, NEl,
useThemeVars,
} from 'naive-ui'
import AppLayout from '../../Layouts/AppLayout.vue'
import SectionCard from '../../Components/SectionCard.vue'
import DatePickerQuery from '../../Components/DatePickerQuery.vue'
import {Link, usePage} from '@inertiajs/vue3'
import VueApexCharts from 'vue3-apexcharts'
import {
TbBed, TbUsers, TbScissors, TbSkull,
TbWifi, TbWifiOff, TbInfoCircle,
TbAlertTriangle, TbActivity, TbChartBar,
TbLayoutDashboard, TbTrendingUp, TbTrendingDown, TbMinus,
} from 'vue-icons-plus/tb'
import { format } from 'date-fns'
import { ru } from 'date-fns/locale'
const props = defineProps({
data: { type: Array, default: () => [] },
grandTotals: { type: Object, default: () => ({}) },
isHeadOrAdmin: { type: Boolean },
date: { type: [Number, Array] },
isOneDay: { type: Boolean },
})
// --- Состояние ---
const isConnected = ref(false)
const isRefreshing = ref(false)
const activityFeed = ref([])
const departmentModal = ref({ show: false, department: null })
// --- Данные для графиков (реактивные) ---
const chartData = ref(props.data)
const grandTotals = ref(props.grandTotals)
// Синхронизация с Inertia-навигацией (date picker меняет props, но ref их не отслеживает)
watch(() => props.data, (val) => { chartData.value = val }, { deep: false })
watch(() => props.grandTotals, (val) => { grandTotals.value = val }, { deep: false })
// Цвета берём из темы NaiveUI, а не хардкодим
const themeVars = useThemeVars()
// Только реальные отделения (не заголовки и не итоги)
const departments = computed(() =>
chartData.value.filter(r => r.isDepartment)
)
// --- KPI ---
const kpiConsist = computed(() => grandTotals.value.consist_sum ?? 0)
const kpiAdmissions = computed(() => grandTotals.value.recipients_all_sum ?? 0)
const kpiOperations = computed(() => (grandTotals.value.plan_surgical_sum ?? 0) + (grandTotals.value.emergency_surgical_sum ?? 0))
const kpiDeceased = computed(() => grandTotals.value.deceased_sum ?? 0)
const kpiOutcome = computed(() => grandTotals.value.outcome_sum ?? 0)
const kpiBeds = computed(() => grandTotals.value.beds_sum ?? 0)
const kpiLoadPercent = computed(() =>
kpiBeds.value > 0 ? Math.round((kpiConsist.value / kpiBeds.value) * 100) : 0
)
// --- Цвет загруженности (инвертировано: мало — ок/зелёный, много — плохо/красный) ---
function loadColor(pct) {
if (pct > 100) return soften(themeVars.value.errorColor)
return soften(themeVars.value.successColor)
}
const kpiLoadColorType = computed(() => kpiLoadPercent.value > 100 ? 'error' : 'success')
// --- Спарклайны KPI (посуточные ряды из бэка) ---
const sparklines = computed(() => grandTotals.value.sparklines ?? {})
// Скользящее среднее — убирает суточный «шум», оставляет тренд.
// Окно адаптивное: на коротком периоде уменьшаем, иначе среднее «съедает» всю вариацию (линия плоская).
function smooth(arr, win = 7) {
if (!Array.isArray(arr) || arr.length === 0) return []
const n = arr.length
const w = Math.max(1, Math.min(win, Math.floor(n / 3)))
const half = Math.floor(w / 2)
return arr.map((_, i) => {
const a = Math.max(0, i - half)
const b = Math.min(arr.length - 1, i + half)
let s = 0
for (let j = a; j <= b; j++) s += arr[j]
return Math.round((s / (b - a + 1)) * 10) / 10
})
}
// Ключ периода: при смене дат спарклайны пересоздаются (иначе series/options
// в vue3-apexcharts рассинхронятся и тултип покажет пустые значения)
const periodKey = computed(() => Array.isArray(props.date) ? props.date.join('-') : String(props.date))
// KPI предыдущего аналогичного периода (с бэка)
const prevKpi = computed(() => grandTotals.value.previous ?? {})
// Подпись дат предыдущего периода: столько же дат, вплотную перед текущим
// (сдвиг = длина периода + 1 день, чтобы конец был ровно за день до начала текущего)
const prevPeriodLabel = computed(() => {
if (!Array.isArray(props.date) || props.date.length < 2) return ''
const [s, e] = props.date
const shift = (e - s) + 86400000
return `${format(new Date(s - shift), 'dd.MM.yyyy')}${format(new Date(e - shift), 'dd.MM.yyyy')}`
})
// Текст тултипа бейджа тренда
function trendTip(current, previous, unit = '') {
const p = (previous === undefined || previous === null) ? '—' : `${previous}${unit}`
const range = prevPeriodLabel.value ? ` (${prevPeriodLabel.value})` : ''
return `Предыдущий период${range}: ${p} → текущий ${current}${unit}`
}
// Тренд = изменение текущего значения относительно предыдущего периода; invert — когда рост = плохо
function delta(current, previous, invert = false) {
if (previous == null || previous === 0) return null
const raw = ((current - previous) / Math.abs(previous)) * 100
const dir = current > previous ? 'up' : current < previous ? 'down' : 'flat'
const label = `${Math.abs(raw).toFixed(1)}%`
const positive = dir === 'flat' ? null : (invert ? dir === 'down' : dir === 'up')
return { label, dir, positive }
}
function trendClass(t) {
if (!t || t.positive === null) return 'is-flat'
return t.positive ? 'is-up' : 'is-down'
}
function trendIcon(t) {
return t && t.dir === 'up' ? TbTrendingUp : t && t.dir === 'down' ? TbTrendingDown : TbMinus
}
// Сглаженные ряды для отрисовки
const sparkData = computed(() => ({
consist: smooth(sparklines.value.consist),
admissions: smooth(sparklines.value.admissions),
outcome: smooth(sparklines.value.outcome),
operations: smooth(sparklines.value.operations),
deceased: smooth(sparklines.value.deceased),
load: smooth(sparklines.value.load),
}))
// Бейджи тренда: текущее значение KPI против предыдущего периода
const trends = computed(() => ({
consist: delta(kpiConsist.value, prevKpi.value.consist),
admissions: delta(kpiAdmissions.value, prevKpi.value.admissions),
outcome: delta(kpiOutcome.value, prevKpi.value.outcome),
operations: delta(kpiOperations.value, prevKpi.value.operations),
deceased: delta(kpiDeceased.value, prevKpi.value.deceased, true), // рост смертности = плохо
load: delta(kpiLoadPercent.value, prevKpi.value.load),
}))
// Подпись даты для тултипа: 'YYYY-MM-DD' → 'DD.MM'
function sparkDayLabel(d) {
if (!d) return ''
const p = String(d).split('-')
return p.length === 3 ? `${p[2]}.${p[1]}` : String(d)
}
// Опции фонового спарклайна (area без осей/сетки) + тултип с кареткой по дням
function sparkOpts(color, raw, days) {
return {
chart: {
type: 'area',
sparkline: { enabled: true },
animations: { enabled: false },
background: 'transparent',
parentHeightOffset: 0,
},
stroke: { curve: 'smooth', width: 2, lineCap: 'round' },
fill: {
type: 'gradient',
gradient: { shadeIntensity: 1, opacityFrom: 0.4, opacityTo: 0.02, stops: [0, 100] },
},
colors: [color],
markers: { size: 0, hover: { size: 4 }, colors: [color], strokeWidth: 0 },
tooltip: {
enabled: true,
theme: 'dark', // как у остальных графиков (baseTooltip)
// показываем реальное (несглаженное) значение за конкретный день
custom: ({ dataPointIndex }) => {
const v = raw && raw[dataPointIndex] != null ? raw[dataPointIndex] : ''
const d = days && days[dataPointIndex] ? days[dataPointIndex] : ''
return `<div class="spark-tip"><span>${sparkDayLabel(d)}</span><b>${v}</b></div>`
},
},
}
}
// Правая колонка ≈ 300 (поступления) + 46 (header) + 10 (gap) + 260 (операции) + 46 (header) = 662px
// Левый чарт: чтобы совпадало — chart = 662 - 46 (header левой карточки) = 616px
// Но если отделений мало/много — берём естественный размер по числу строк
const bedChartHeight = computed(() =>
Math.max(Math.min(departments.value.length * 30, 900), 616)
)
// --- Общие базовые настройки ---
// Шрифт приложения (Golos Text подключён в app.css как семейство 'v-sans', см. --font-sans)
const FONT = "'v-sans', ui-sans-serif, system-ui, sans-serif"
// Цвета осей/подписей/сетки из темы NaiveUI
const cAxis = computed(() => themeVars.value.textColor3)
const cGrid = computed(() => themeVars.value.dividerColor)
const cLabel = computed(() => themeVars.value.textColor1)
// --- Смягчение цветов (JS-аналог CSS color-mix) ---
// ApexCharts кладёт цвет в SVG-атрибут fill и не понимает color-mix(),
// поэтому подмешиваем фон карточки к цвету темы здесь.
const SOFT_AMOUNT = 0.55 // доля фона карточки в смеси: больше → приглушённее (как тон карточек)
function parseColor(c) {
if (!c) return { r: 0, g: 0, b: 0 }
c = c.trim()
if (c[0] === '#') {
let h = c.slice(1)
if (h.length === 3) h = h.split('').map(x => x + x).join('')
const n = parseInt(h, 16)
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }
}
const m = c.match(/rgba?\(([^)]+)\)/i)
if (m) {
const [r, g, b] = m[1].split(',').map(s => parseFloat(s))
return { r, g, b }
}
return { r: 0, g: 0, b: 0 }
}
function toHex({ r, g, b }) {
const h = v => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0')
return `#${h(r)}${h(g)}${h(b)}`
}
// soften(color) = color, разбавленный фоном карточки (тон сохраняется, яркость падает)
function soften(color, amount = SOFT_AMOUNT) {
const a = parseColor(color)
const b = parseColor(themeVars.value.cardColor)
return toHex({
r: a.r * (1 - amount) + b.r * amount,
g: a.g * (1 - amount) + b.g * amount,
b: a.b * (1 - amount) + b.b * amount,
})
}
const baseChart = (type) => ({
type,
background: 'transparent',
toolbar: { show: false },
fontFamily: FONT,
animations: {
enabled: true,
speed: 700,
animateGradually: { enabled: true, delay: 60 },
dynamicAnimation: { enabled: true, speed: 450 },
},
})
const baseGrid = computed(() => ({
borderColor: cGrid.value,
strokeDashArray: 4,
xaxis: { lines: { show: false } },
padding: { top: 4, right: 12, bottom: 0, left: 4 },
}))
const baseTooltip = {
theme: 'dark',
style: { fontSize: '12px', fontFamily: FONT },
}
// --- График: Загруженность коек (вертикальные столбцы, как «Норма и выбытие») ---
// Верхняя граница оси: минимум 100%, с запасом если есть перегруженные отделения
const bedMax = computed(() => {
const peak = Math.max(0, ...departments.value.map(d => d.percentLoadedBeds ?? 0))
return peak <= 100 ? 100 : Math.ceil(peak / 10) * 10
})
const bedOccupancyOptions = computed(() => ({
chart: baseChart('bar'),
theme: { mode: 'dark' },
plotOptions: {
bar: {
borderRadius: 5,
borderRadiusApplication: 'end',
distributed: true,
columnWidth: '60%',
dataLabels: { position: 'top' },
},
},
fill: { type: 'solid', opacity: 1 },
dataLabels: {
enabled: true,
formatter: v => `${v}%`,
style: { fontSize: '10px', fontFamily: FONT, fontWeight: 600, colors: [cLabel.value] },
offsetY: -6,
},
xaxis: {
categories: departments.value.map(d => d.department),
labels: { style: { colors: cAxis.value, fontSize: '10px', fontFamily: FONT }, rotate: -40, rotateAlways: true },
axisBorder: { show: false },
axisTicks: { show: false },
},
yaxis: {
min: 0,
max: bedMax.value,
tickAmount: 5,
labels: { style: { colors: cAxis.value, fontFamily: FONT }, formatter: v => `${Math.round(v)}%` },
},
annotations: {
yaxis: [{
y: 100,
borderColor: soften(themeVars.value.errorColor),
strokeDashArray: 4,
label: {
text: '100%',
position: 'right',
borderColor: soften(themeVars.value.errorColor),
style: { background: soften(themeVars.value.errorColor), color: themeVars.value.baseColor, fontSize: '10px', fontFamily: FONT },
},
}],
},
colors: departments.value.map(d => loadColor(d.percentLoadedBeds)),
tooltip: {
...baseTooltip,
y: {
formatter: (val, { dataPointIndex }) => {
const d = departments.value[dataPointIndex]
return `${val}% — ${d?.consist ?? 0} из ${d?.beds ?? 0} коек`
},
},
},
grid: baseGrid.value,
legend: { show: false },
}))
const bedOccupancySeries = computed(() => [{
name: 'Загруженность',
data: departments.value.map(d => d.percentLoadedBeds),
}])
// --- График: Поступления и выбытия ---
const admissionsOptions = computed(() => ({
chart: baseChart('bar'),
theme: { mode: 'dark' },
plotOptions: {
bar: {
borderRadius: 5,
borderRadiusApplication: 'end',
columnWidth: '60%',
dataLabels: { position: 'top' },
},
},
fill: { type: 'solid', opacity: 1 },
dataLabels: {
enabled: true,
style: { fontSize: '10px', fontFamily: FONT, fontWeight: 600, colors: [cLabel.value] },
offsetY: -6,
},
xaxis: {
categories: departments.value.map(d => d.department),
labels: { style: { colors: cAxis.value, fontSize: '10px', fontFamily: FONT }, rotate: -40, rotateAlways: true },
axisBorder: { show: false },
axisTicks: { show: false },
},
yaxis: { labels: { style: { colors: cAxis.value, fontFamily: FONT } }, tickAmount: 4 },
colors: [soften(themeVars.value.textColor3), soften(themeVars.value.successColor)],
legend: {
position: 'top',
horizontalAlign: 'right',
labels: { colors: cAxis.value },
fontSize: '12px',
fontFamily: FONT,
markers: { size: 6, shape: 'circle' },
},
tooltip: {
...baseTooltip,
shared: true,
intersect: false,
},
grid: baseGrid.value,
}))
const admissionsSeries = computed(() => [
// Норма за выбранный период: план_месяца × число месяцев в диапазоне (PlanCalculator.period_plan)
{ name: 'Норма', data: departments.value.map(d => d.plan?.period_plan ?? 0) },
{ name: 'Выбыло', data: departments.value.map(d => d.outcome ?? 0) },
])
// --- График: Операции (горизонтальный бар — Экстренные/Плановые) ---
const totalEmergencySurgical = computed(() => grandTotals.value.emergency_surgical_sum ?? 0)
const totalPlanSurgical = computed(() => grandTotals.value.plan_surgical_sum ?? 0)
const totalSurgical = computed(() => totalEmergencySurgical.value + totalPlanSurgical.value)
const operationsOptions = computed(() => ({
chart: baseChart('bar'),
theme: { mode: 'dark' },
plotOptions: {
bar: {
horizontal: true,
distributed: true,
borderRadius: 5,
borderRadiusApplication: 'end',
barHeight: '70%',
dataLabels: { position: 'center' },
},
},
fill: { type: 'solid', opacity: 1 },
colors: [soften(themeVars.value.errorColor), soften(themeVars.value.textColor3)],
dataLabels: {
enabled: true,
formatter: v => v,
style: { fontSize: '13px', fontFamily: FONT, fontWeight: 700, colors: [themeVars.value.baseColor] },
},
xaxis: {
categories: ['Экстренные', 'Плановые'],
labels: { style: { colors: cAxis.value, fontFamily: FONT } },
axisBorder: { show: false },
axisTicks: { show: false },
},
yaxis: { labels: { style: { colors: cAxis.value, fontSize: '13px', fontFamily: FONT } } },
legend: { show: false },
tooltip: { ...baseTooltip, y: { formatter: v => `${v} опер.` } },
grid: { ...baseGrid.value, yaxis: { lines: { show: false } } },
}))
const operationsSeries = computed(() => [{
name: 'Операции',
data: [totalEmergencySurgical.value, totalPlanSurgical.value],
}])
// --- График: Нежелательные события ---
const unwantedDepts = computed(() => departments.value.filter(d => (d.countUnwanted ?? 0) > 0))
const unwantedOptions = computed(() => ({
chart: baseChart('bar'),
theme: { mode: 'dark' },
plotOptions: {
bar: {
borderRadius: 5,
borderRadiusApplication: 'end',
columnWidth: '55%',
dataLabels: { position: 'top' },
},
},
fill: { type: 'solid', opacity: 1 },
dataLabels: {
enabled: true,
style: { fontSize: '11px', fontFamily: FONT, fontWeight: 600, colors: [cLabel.value] },
offsetY: -6,
},
xaxis: {
categories: unwantedDepts.value.map(d => d.department),
labels: { style: { colors: cAxis.value, fontSize: '10px', fontFamily: FONT }, rotate: -40, rotateAlways: true },
axisBorder: { show: false },
axisTicks: { show: false },
},
yaxis: { labels: { style: { colors: cAxis.value, fontFamily: FONT } }, tickAmount: 4 },
colors: [soften(themeVars.value.errorColor)],
tooltip: { ...baseTooltip, y: { formatter: v => `${v} событий` } },
grid: baseGrid.value,
}))
const unwantedSeries = computed(() => [{
name: 'НС',
data: unwantedDepts.value.map(d => d.countUnwanted),
}])
// --- График: Хирургическая активность ---
// Формула: (кол-во операций / кол-во выбывших) * 100
const surgicalActivity = (d) => {
const ops = (d.surgical?.plan ?? 0) + (d.surgical?.emergency ?? 0)
const outcome = d.outcome ?? 0
return outcome > 0 ? Math.round((ops / outcome) * 100) : 0
}
const surgicalActivityOptions = computed(() => ({
chart: baseChart('bar'),
theme: { mode: 'dark' },
plotOptions: {
bar: {
borderRadius: 5,
borderRadiusApplication: 'end',
columnWidth: '55%',
dataLabels: { position: 'top' },
},
},
fill: { type: 'solid', opacity: 1 },
dataLabels: {
enabled: true,
formatter: v => `${v}%`,
style: { fontSize: '10px', fontFamily: FONT, fontWeight: 600, colors: [cLabel.value] },
offsetY: -6,
},
xaxis: {
categories: departments.value.map(d => d.department),
labels: { style: { colors: cAxis.value, fontSize: '10px', fontFamily: FONT }, rotate: -40, rotateAlways: true },
axisBorder: { show: false },
axisTicks: { show: false },
},
yaxis: { labels: { style: { colors: cAxis.value, fontFamily: FONT }, formatter: v => `${Math.round(v)}%` }, tickAmount: 4 },
colors: [soften(themeVars.value.textColor3)],
tooltip: {
...baseTooltip,
y: {
formatter: (val, { dataPointIndex }) => {
const d = departments.value[dataPointIndex]
const ops = (d?.surgical?.plan ?? 0) + (d?.surgical?.emergency ?? 0)
return `${val}% — ${ops} опер. / ${d?.outcome ?? 0} выб.`
},
},
},
grid: baseGrid.value,
}))
const surgicalActivitySeries = computed(() => [{
name: 'Хир. активность',
data: departments.value.map(d => surgicalActivity(d)),
}])
// --- График: Структура поступлений (stacked: плановые/экстренные) ---
const admissionStructureOptions = computed(() => ({
chart: { ...baseChart('bar'), stacked: true },
theme: { mode: 'dark' },
plotOptions: { bar: { borderRadius: 4, borderRadiusApplication: 'end', columnWidth: '55%' } },
fill: { type: 'solid', opacity: 1 },
colors: [soften(themeVars.value.textColor3), soften(themeVars.value.errorColor)],
dataLabels: { enabled: false },
xaxis: {
categories: departments.value.map(d => d.department),
labels: { style: { colors: cAxis.value, fontSize: '10px', fontFamily: FONT }, rotate: -40, rotateAlways: true },
axisBorder: { show: false },
axisTicks: { show: false },
},
yaxis: { labels: { style: { colors: cAxis.value, fontFamily: FONT } }, tickAmount: 4 },
legend: { position: 'top', horizontalAlign: 'right', labels: { colors: cAxis.value }, fontSize: '12px', fontFamily: FONT, markers: { size: 6, shape: 'circle' } },
tooltip: { ...baseTooltip, shared: true, intersect: false },
grid: baseGrid.value,
}))
const admissionStructureSeries = computed(() => [
{ name: 'Плановые', data: departments.value.map(d => d.recipients?.plan ?? 0) },
{ name: 'Экстренные', data: departments.value.map(d => d.recipients?.emergency ?? 0) },
])
// --- График: Предоперационные к/д (только отделения с операциями) ---
const preopDepts = computed(() => departments.value.filter(d => (d.preoperativeDays ?? 0) > 0))
const preopOptions = computed(() => ({
chart: baseChart('bar'),
theme: { mode: 'dark' },
plotOptions: { bar: { borderRadius: 5, borderRadiusApplication: 'end', columnWidth: '55%', dataLabels: { position: 'top' } } },
fill: { type: 'solid', opacity: 1 },
dataLabels: {
enabled: true,
style: { fontSize: '10px', fontFamily: FONT, fontWeight: 600, colors: [cLabel.value] },
offsetY: -6,
},
xaxis: {
categories: preopDepts.value.map(d => d.department),
labels: { style: { colors: cAxis.value, fontSize: '10px', fontFamily: FONT }, rotate: -40, rotateAlways: true },
axisBorder: { show: false },
axisTicks: { show: false },
},
yaxis: { labels: { style: { colors: cAxis.value, fontFamily: FONT } }, tickAmount: 4 },
colors: [soften(themeVars.value.textColor3)],
tooltip: { ...baseTooltip, y: { formatter: v => `${v} к/д` } },
grid: baseGrid.value,
}))
const preopSeries = computed(() => [{
name: 'Пред. опер. к/д',
data: preopDepts.value.map(d => d.preoperativeDays ?? 0),
}])
// --- График: Структура исходов (stacked: выписано/переведено/умерло) ---
const outcomeStructureOptions = computed(() => ({
chart: { ...baseChart('bar'), stacked: true },
theme: { mode: 'dark' },
plotOptions: { bar: { borderRadius: 4, borderRadiusApplication: 'end', columnWidth: '55%' } },
fill: { type: 'solid', opacity: 1 },
colors: [soften(themeVars.value.successColor), soften(themeVars.value.textColor3), soften(themeVars.value.errorColor)],
dataLabels: { enabled: false },
xaxis: {
categories: departments.value.map(d => d.department),
labels: { style: { colors: cAxis.value, fontSize: '10px', fontFamily: FONT }, rotate: -40, rotateAlways: true },
axisBorder: { show: false },
axisTicks: { show: false },
},
yaxis: { labels: { style: { colors: cAxis.value, fontFamily: FONT } }, tickAmount: 4 },
legend: { position: 'top', horizontalAlign: 'right', labels: { colors: cAxis.value }, fontSize: '12px', fontFamily: FONT, markers: { size: 6, shape: 'circle' } },
tooltip: { ...baseTooltip, shared: true, intersect: false },
grid: baseGrid.value,
}))
const outcomeStructureSeries = computed(() => [
{ name: 'Выписано', data: departments.value.map(d => d.outcome ?? 0) },
{ name: 'Переведено', data: departments.value.map(d => d.recipients?.transferred ?? 0) },
{ name: 'Умерло', data: departments.value.map(d => d.deceased ?? 0) },
])
// --- Обновление данных через API ---
async function refreshStats() {
if (isRefreshing.value) return
isRefreshing.value = true
try {
const params = new URLSearchParams()
if (Array.isArray(props.date)) {
params.set('startAt', props.date[0])
params.set('endAt', props.date[1])
}
const res = await window.axios.get(`/api/statistics/headquarters?${params}`)
chartData.value = res.data.data
grandTotals.value = res.data.grandTotals
} catch {
// silent
} finally {
isRefreshing.value = false
}
}
// --- WebSocket подписка ---
let echoChannel = null
function addToFeed(type, payload) {
const labels = {
'report-duty.updated': { text: `Отчёт дежурного`, color: 'info' },
'report-nurse.updated': { text: `Отчёт сестры`, color: 'success' },
'patient.changed': { text: `Пациент`, color: 'warning' },
}
const meta = labels[type] ?? { text: type, color: 'default' }
const actionLabel = payload.action === 'created' ? 'подан' : 'обновлён'
activityFeed.value.unshift({
id: Date.now(),
type,
color: meta.color,
title: meta.text,
body: payload.department ?? payload.full_name ?? '',
action: actionLabel,
time: format(new Date(), 'HH:mm:ss', { locale: ru }),
})
if (activityFeed.value.length > 50) {
activityFeed.value.pop()
}
}
function subscribeToChannel() {
if (!window.Echo) return
echoChannel = window.Echo.private('headquarters')
.listen('.report-duty.updated', (payload) => {
addToFeed('report-duty.updated', payload)
window.$notification?.create({
title: `Отчёт дежурного — ${payload.department ?? ''}`,
content: payload.action === 'created' ? 'Новый отчёт подан' : 'Отчёт обновлён',
type: 'info',
duration: 5000,
})
refreshStats()
})
.listen('.report-nurse.updated', (payload) => {
addToFeed('report-nurse.updated', payload)
window.$notification?.create({
title: `Отчёт сестры — ${payload.department ?? ''}`,
content: payload.action === 'created' ? 'Новый отчёт подан' : 'Отчёт обновлён',
type: 'success',
duration: 5000,
})
refreshStats()
})
.listen('.patient.changed', (payload) => {
addToFeed('patient.changed', payload)
window.$message?.info(`Данные пациента обновлены: ${payload.full_name ?? ''}`)
refreshStats()
})
.subscribed(() => { isConnected.value = true })
.error(() => { isConnected.value = false })
}
// --- Клик по графику → модалка с деталями ---
function onBedChartClick(event, chartContext, config) {
const idx = config.dataPointIndex
if (idx < 0) return
const dept = departments.value[idx]
if (!dept) return
departmentModal.value = { show: true, department: dept }
}
function onAdmissionsChartClick(event, chartContext, config) {
const idx = config.dataPointIndex
if (idx < 0) return
const dept = departments.value[idx]
if (!dept) return
departmentModal.value = { show: true, department: dept }
}
onMounted(() => {
subscribeToChannel()
})
onUnmounted(() => {
echoChannel?.stopListening('.report-duty.updated')
echoChannel?.stopListening('.report-nurse.updated')
echoChannel?.stopListening('.patient.changed')
window.Echo?.leave('headquarters')
})
</script>
<template>
<AppLayout>
<template #headerExtra>
<NSpace align="center">
<DatePickerQuery
:is-head-or-admin="isHeadOrAdmin"
:is-show-current-date-switch="true"
:date="date"
:is-one-day="isOneDay"
/>
<NDivider vertical />
<NTooltip>
<template #trigger>
<NBadge :type="isConnected ? 'success' : 'error'" dot :offset="[-2, 2]">
<NButton secondary circle size="small" :loading="isRefreshing" @click="refreshStats">
<template #icon>
<NIcon :component="isConnected ? TbWifi : TbWifiOff" />
</template>
</NButton>
</NBadge>
</template>
{{ isConnected ? 'WebSocket подключён' : 'WebSocket отключён' }}
</NTooltip>
<NDivider vertical />
<NButton secondary :tag="Link" :href="`/statistic?startAt=${usePage().props.date[0]}&endAt=${usePage().props.date[1]}`">Таблица</NButton>
</NSpace>
</template>
<NScrollbar style="height: calc(100vh - 48px)">
<div class="hq-grid">
<!-- KPI -->
<NGrid responsive="screen" cols="2 s:3 m:6" :x-gap="10" :y-gap="10">
<NGridItem>
<SectionCard title="Состоит" :icon="TbBed" no-padding>
<div class="kpi-cell">
<div class="kpi-head">
<div class="kpi-value"><NNumberAnimation :from="0" :to="kpiConsist" /></div>
<NTooltip v-if="trends.consist">
<template #trigger>
<span class="kpi-trend" :class="trendClass(trends.consist)">
<NIcon :component="trendIcon(trends.consist)" />{{ trends.consist.label }}
</span>
</template>
{{ trendTip(kpiConsist, prevKpi.consist) }}
</NTooltip>
</div>
<VueApexCharts v-if="sparkData.consist.length > 1" class="kpi-spark" :key="periodKey" type="area" height="48"
:options="sparkOpts(soften(themeVars.textColor3), sparklines.consist, sparklines.days)" :series="[{ data: sparkData.consist }]" />
</div>
</SectionCard>
</NGridItem>
<NGridItem>
<SectionCard title="Поступило" :icon="TbUsers" no-padding>
<div class="kpi-cell">
<div class="kpi-head">
<div class="kpi-value"><NNumberAnimation :from="0" :to="kpiAdmissions" /></div>
<NTooltip v-if="trends.admissions">
<template #trigger>
<span class="kpi-trend" :class="trendClass(trends.admissions)">
<NIcon :component="trendIcon(trends.admissions)" />{{ trends.admissions.label }}
</span>
</template>
{{ trendTip(kpiAdmissions, prevKpi.admissions) }}
</NTooltip>
</div>
<VueApexCharts v-if="sparkData.admissions.length > 1" class="kpi-spark" :key="periodKey" type="area" height="48"
:options="sparkOpts(soften(themeVars.textColor3), sparklines.admissions, sparklines.days)" :series="[{ data: sparkData.admissions }]" />
</div>
</SectionCard>
</NGridItem>
<NGridItem>
<SectionCard title="Выбыло" :icon="TbLayoutDashboard" no-padding>
<div class="kpi-cell">
<div class="kpi-head">
<div class="kpi-value"><NNumberAnimation :from="0" :to="kpiOutcome" /></div>
<NTooltip v-if="trends.outcome">
<template #trigger>
<span class="kpi-trend" :class="trendClass(trends.outcome)">
<NIcon :component="trendIcon(trends.outcome)" />{{ trends.outcome.label }}
</span>
</template>
{{ trendTip(kpiOutcome, prevKpi.outcome) }}
</NTooltip>
</div>
<VueApexCharts v-if="sparkData.outcome.length > 1" class="kpi-spark" :key="periodKey" type="area" height="48"
:options="sparkOpts(themeVars.successColor, sparklines.outcome, sparklines.days)" :series="[{ data: sparkData.outcome }]" />
</div>
</SectionCard>
</NGridItem>
<NGridItem>
<SectionCard title="Операции" :icon="TbScissors" no-padding>
<div class="kpi-cell">
<div class="kpi-head">
<div class="kpi-value"><NNumberAnimation :from="0" :to="kpiOperations" /></div>
<NTooltip v-if="trends.operations">
<template #trigger>
<span class="kpi-trend" :class="trendClass(trends.operations)">
<NIcon :component="trendIcon(trends.operations)" />{{ trends.operations.label }}
</span>
</template>
{{ trendTip(kpiOperations, prevKpi.operations) }}
</NTooltip>
</div>
<VueApexCharts v-if="sparkData.operations.length > 1" class="kpi-spark" :key="periodKey" type="area" height="48"
:options="sparkOpts(soften(themeVars.textColor3), sparklines.operations, sparklines.days)" :series="[{ data: sparkData.operations }]" />
</div>
</SectionCard>
</NGridItem>
<NGridItem>
<SectionCard title="Умерло" :icon="TbSkull" :color="kpiDeceased > 0 ? 'error' : null" no-padding>
<div class="kpi-cell">
<div class="kpi-head">
<div class="kpi-value" :style="kpiDeceased > 0 ? 'color:var(--error-color)' : ''">
<NNumberAnimation :from="0" :to="kpiDeceased" />
</div>
<NTooltip v-if="trends.deceased">
<template #trigger>
<span class="kpi-trend" :class="trendClass(trends.deceased)">
<NIcon :component="trendIcon(trends.deceased)" />{{ trends.deceased.label }}
</span>
</template>
{{ trendTip(kpiDeceased, prevKpi.deceased) }}
</NTooltip>
</div>
<VueApexCharts v-if="sparkData.deceased.length > 1" class="kpi-spark" :key="periodKey" type="area" height="48"
:options="sparkOpts(themeVars.errorColor, sparklines.deceased, sparklines.days)" :series="[{ data: sparkData.deceased }]" />
</div>
</SectionCard>
</NGridItem>
<NGridItem>
<SectionCard title="Загруженность" :icon="TbChartBar" :color="kpiLoadColorType" no-padding>
<div class="kpi-cell">
<div class="kpi-head">
<div class="kpi-value" :style="`color:${kpiLoadPercent > 100 ? 'var(--error-color)' : 'var(--success-color)'}`">
<NNumberAnimation :from="0" :to="kpiLoadPercent" /><span class="kpi-unit">%</span>
</div>
<NTooltip v-if="trends.load">
<template #trigger>
<span class="kpi-trend" :class="trendClass(trends.load)">
<NIcon :component="trendIcon(trends.load)" />{{ trends.load.label }}
</span>
</template>
{{ trendTip(kpiLoadPercent, prevKpi.load, '%') }}
</NTooltip>
</div>
<VueApexCharts v-if="sparkData.load.length > 1" class="kpi-spark" :key="periodKey" type="area" height="48"
:options="sparkOpts(kpiLoadPercent > 100 ? themeVars.errorColor : themeVars.successColor, sparklines.load, sparklines.days)" :series="[{ data: sparkData.load }]" />
</div>
</SectionCard>
</NGridItem>
</NGrid>
<!-- Основная сетка: загруженность слева (2 строки), поступления + операции справа -->
<div class="hq-main-grid">
<!-- Загруженность коек левая колонка, обе строки -->
<div class="hq-bed-cell">
<SectionCard title="Загруженность коек" :icon="TbBed" no-padding>
<template #header-extra>
<NTooltip>
<template #trigger>
<NIcon :component="TbInfoCircle" class="chart-hint-icon" />
</template>
Кликните на столбец для деталей отделения
</NTooltip>
</template>
<VueApexCharts
v-if="departments.length"
type="bar"
:height="bedChartHeight"
:options="{ ...bedOccupancyOptions, chart: { ...bedOccupancyOptions.chart, events: { dataPointSelection: onBedChartClick } } }"
:series="bedOccupancySeries"
/>
<NEl v-else class="chart-empty"><NEmpty description="Нет данных" size="small" /></NEl>
</SectionCard>
</div>
<!-- Норма и выбытие правая колонка, строка 1 -->
<div class="hq-admissions-cell">
<SectionCard class="hq-fill" title="Норма и выбытие" :icon="TbUsers" no-padding>
<template #header-extra>
<NTooltip>
<template #trigger>
<NIcon :component="TbInfoCircle" class="chart-hint-icon" />
</template>
Кликните на столбец для деталей отделения
</NTooltip>
</template>
<div class="hq-chart-fill">
<VueApexCharts
v-if="departments.length"
type="bar"
height="100%"
:options="{ ...admissionsOptions, chart: { ...admissionsOptions.chart, events: { dataPointSelection: onAdmissionsChartClick } } }"
:series="admissionsSeries"
/>
<NEl v-else class="chart-empty"><NEmpty description="Нет данных" size="small" /></NEl>
</div>
</SectionCard>
</div>
<!-- Операции правая колонка, строка 2 -->
<div class="hq-operations-cell">
<SectionCard class="hq-fill" title="Операции" :icon="TbScissors" no-padding>
<template #header-extra>
<NText depth="3" style="font-size: 12px;">Всего: <NText :depth="1" strong>{{ totalSurgical }}</NText></NText>
</template>
<div class="hq-chart-fill">
<VueApexCharts
v-if="kpiOperations > 0"
type="bar"
height="100%"
:options="operationsOptions"
:series="operationsSeries"
/>
<NEl v-else class="chart-empty"><NEmpty description="Операций нет" size="small" /></NEl>
</div>
</SectionCard>
</div>
</div>
<!-- Нижняя сетка: bento (тайлы разного размера) -->
<div class="hq-bottom-grid">
<!-- Хирургическая активность (широкий тайл) -->
<SectionCard class="b-wide" title="Хирургическая активность" :icon="TbScissors" no-padding>
<VueApexCharts
v-if="departments.length"
type="bar"
height="260"
:options="surgicalActivityOptions"
:series="surgicalActivitySeries"
/>
<NEl v-else class="chart-empty"><NEmpty description="Нет данных" size="small" /></NEl>
</SectionCard>
<!-- Лента активности (узкий тайл рядом, на всю высоту графика) -->
<SectionCard class="b-narrow" title="Лента активности" :icon="TbActivity" no-padding>
<template #header-extra>
<NBadge v-if="activityFeed.length" :value="activityFeed.length" :max="99" type="info" />
</template>
<div class="hq-feed-body">
<NScrollbar v-if="activityFeed.length">
<NList hoverable :show-divider="false" style="padding: 8px 12px;">
<NListItem
v-for="item in activityFeed" :key="item.id"
style="padding: 6px 2px;"
>
<NThing>
<template #header>
<NSpace align="center" size="small">
<NTag :type="item.color" size="small" round :bordered="false">{{ item.title }}</NTag>
<NText depth="2" style="font-size: 12px;">{{ item.body }}</NText>
</NSpace>
</template>
<template #header-extra>
<NText depth="3" style="font-size: 11px;">{{ item.time }}</NText>
</template>
<NText depth="3" style="font-size: 12px;">{{ item.action }}</NText>
</NThing>
</NListItem>
</NList>
</NScrollbar>
<div v-else class="hq-feed-empty">
<NEmpty description="Событий пока нет" size="small" />
</div>
</div>
</SectionCard>
<!-- Структура поступлений (план/экстренные) -->
<SectionCard class="b-half" title="Структура поступлений" :icon="TbUsers" no-padding>
<VueApexCharts
v-if="departments.length"
type="bar"
height="260"
:options="admissionStructureOptions"
:series="admissionStructureSeries"
/>
<NEl v-else class="chart-empty"><NEmpty description="Нет данных" size="small" /></NEl>
</SectionCard>
<!-- Структура исходов (выписано/переведено/умерло) -->
<SectionCard class="b-half" title="Структура исходов" :icon="TbLayoutDashboard" no-padding>
<VueApexCharts
v-if="departments.length"
type="bar"
height="260"
:options="outcomeStructureOptions"
:series="outcomeStructureSeries"
/>
<NEl v-else class="chart-empty"><NEmpty description="Нет данных" size="small" /></NEl>
</SectionCard>
<!-- Предоперационные к/д (отделения с операциями) -->
<SectionCard v-if="preopDepts.length" class="b-half" title="Предоперационные к/д" :icon="TbScissors" no-padding>
<VueApexCharts
type="bar"
height="260"
:options="preopOptions"
:series="preopSeries"
/>
</SectionCard>
<!-- НС (только если есть) -->
<SectionCard
v-if="unwantedDepts.length"
class="b-half"
title="Нежелательные события"
:icon="TbAlertTriangle"
color="error"
no-padding
>
<VueApexCharts
type="bar"
height="260"
:options="unwantedOptions"
:series="unwantedSeries"
/>
</SectionCard>
</div>
</div>
</NScrollbar>
<!-- Модалка: детали отделения -->
<NModal
v-model:show="departmentModal.show"
preset="card"
:title="departmentModal.department?.department ?? 'Отделение'"
style="max-width: 540px;"
:segmented="{ content: true }"
>
<template v-if="departmentModal.department">
<NGrid :cols="3" :x-gap="8" :y-gap="8">
<NGridItem>
<SectionCard title="Коек">
<div class="modal-stat">{{ departmentModal.department.beds }}</div>
</SectionCard>
</NGridItem>
<NGridItem>
<SectionCard title="Состоит">
<div class="modal-stat">{{ departmentModal.department.consist }}</div>
</SectionCard>
</NGridItem>
<NGridItem>
<SectionCard
title="Загруженность"
:color="departmentModal.department.percentLoadedBeds >= 80 ? 'success'
: departmentModal.department.percentLoadedBeds >= 50 ? 'warning' : 'error'"
>
<div class="modal-stat" :style="`color:${loadColor(departmentModal.department.percentLoadedBeds)}`">
{{ departmentModal.department.percentLoadedBeds }}%
</div>
</SectionCard>
</NGridItem>
<NGridItem>
<SectionCard title="Поступило">
<div class="modal-stat">{{ departmentModal.department.recipients?.all ?? 0 }}</div>
</SectionCard>
</NGridItem>
<NGridItem>
<SectionCard title="Выбыло">
<div class="modal-stat">{{ departmentModal.department.outcome }}</div>
</SectionCard>
</NGridItem>
<NGridItem>
<SectionCard title="Умерло" :color="departmentModal.department.deceased > 0 ? 'error' : null">
<div class="modal-stat" :style="departmentModal.department.deceased > 0 ? 'color:var(--error-color)' : ''">
{{ departmentModal.department.deceased }}
</div>
</SectionCard>
</NGridItem>
<NGridItem>
<SectionCard title="Операции Э / П">
<div class="modal-stat" style="font-size:16px;">
{{ departmentModal.department.surgical?.emergency ?? 0 }}
<NText depth="3" style="font-size:13px;">/</NText>
{{ departmentModal.department.surgical?.plan ?? 0 }}
</div>
</SectionCard>
</NGridItem>
<NGridItem>
<SectionCard title="Ср. к/д">
<div class="modal-stat">{{ departmentModal.department.averageBedDays }}</div>
</SectionCard>
</NGridItem>
<NGridItem>
<SectionCard title="Летальность">
<div class="modal-stat" style="font-size:16px;">{{ departmentModal.department.lethality }}%</div>
</SectionCard>
</NGridItem>
<NGridItem>
<SectionCard title="НС" :color="departmentModal.department.countUnwanted > 0 ? 'warning' : null">
<div class="modal-stat">{{ departmentModal.department.countUnwanted }}</div>
</SectionCard>
</NGridItem>
<NGridItem>
<SectionCard title="На контроле">
<div class="modal-stat">{{ departmentModal.department.countObservable }}</div>
</SectionCard>
</NGridItem>
<NGridItem>
<SectionCard title="Пред. опер. к/д">
<div class="modal-stat" style="font-size:16px;">{{ departmentModal.department.preoperativeDays }}</div>
</SectionCard>
</NGridItem>
</NGrid>
<NFlex justify="end" style="margin-top: 12px;">
<NButton
type="primary" secondary size="small" :tag="Link"
:href="`/report?departmentId=${departmentModal.department.department_id}&startAt=${Array.isArray(date) ? date[0] : date}&endAt=${Array.isArray(date) ? date[1] : date}`"
>
Открыть отчёт
</NButton>
</NFlex>
</template>
</NModal>
</AppLayout>
</template>
<style scoped>
/* ── Контейнер страницы ── */
.hq-grid {
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
/* не растягиваем на весь сверхширокий монитор — ограничиваем и центрируем */
width: 100%;
max-width: 1600px;
margin: 0 auto;
}
/* ── KPI карточки со спарклайном ── */
.kpi-cell {
position: relative;
overflow: hidden;
padding: 12px 16px 14px;
min-height: 86px;
}
.kpi-head {
position: relative;
z-index: 1;
display: flex;
align-items: baseline;
gap: 8px;
}
.kpi-value {
font-size: 30px;
font-weight: 700;
line-height: 1;
letter-spacing: -0.5px;
}
.kpi-unit {
font-size: 18px;
font-weight: 600;
opacity: 0.7;
margin-left: 2px;
}
.kpi-trend {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 12px;
font-weight: 600;
line-height: 1;
padding: 2px 6px;
border-radius: 6px;
}
.kpi-trend .n-icon {
font-size: 13px;
}
.kpi-trend.is-up {
color: var(--success-color);
background: color-mix(in srgb, var(--success-color) 14%, transparent);
}
.kpi-trend.is-down {
color: var(--error-color);
background: color-mix(in srgb, var(--error-color) 14%, transparent);
}
.kpi-trend.is-flat {
color: var(--text-color-3, rgba(255, 255, 255, 0.52));
background: rgba(255, 255, 255, 0.06);
}
/* спарклайн — фоном снизу карточки */
.kpi-spark {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 0;
opacity: 0.6;
pointer-events: auto; /* чтобы ловить наведение и показывать значение за день */
}
/* подпись в тултипе спарклайна */
:deep(.spark-tip) {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
font-size: 12px;
font-family: var(--font-sans);
}
:deep(.spark-tip span) { opacity: 0.7; }
:deep(.spark-tip b) { font-weight: 700; }
/* ── Основная сетка: левый бар + 2 правых ── */
.hq-main-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-areas:
"bed admissions"
"bed operations";
gap: 10px;
}
/* Левая ячейка растягивается на 2 строки */
.hq-bed-cell {
grid-area: bed;
}
.hq-admissions-cell { grid-area: admissions; }
.hq-operations-cell { grid-area: operations; }
/* Карточка, заполняющая всю высоту своей ячейки (график тянется под неё) */
.hq-fill {
height: 100%;
display: flex;
flex-direction: column;
}
.hq-fill > :deep(div:last-child) {
flex: 1;
min-height: 0;
}
.hq-chart-fill {
height: 100%;
min-height: 200px; /* запас, когда в ряду нет высокого соседа */
}
/* ── Нижняя сетка: bento на 12 колонок, тайлы разного размера ── */
.hq-bottom-grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 10px;
align-items: start;
}
.hq-bottom-grid .b-wide { grid-column: span 8; }
.hq-bottom-grid .b-narrow { grid-column: span 4; }
.hq-bottom-grid .b-half { grid-column: span 6; }
/* Лента активности тянется на высоту соседнего блока в ряду (через align-self: stretch) */
.hq-bottom-grid .b-narrow {
align-self: stretch;
display: flex;
flex-direction: column;
}
/* контентная область карточки (последний div корня) заполняет остаток высоты */
.hq-bottom-grid .b-narrow > :deep(div:last-child) {
flex: 1;
min-height: 0;
}
.hq-feed-body {
height: 100%;
min-height: 200px; /* запас на случай, когда в ряду нет высокого соседа (моб./узкий экран) */
display: flex;
flex-direction: column;
}
.hq-feed-body :deep(.n-scrollbar) {
flex: 1;
min-height: 0;
}
.hq-feed-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
/* Планшет/узкий десктоп: 2 равные колонки */
@media (max-width: 1280px) {
.hq-bottom-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.hq-bottom-grid .b-wide,
.hq-bottom-grid .b-narrow,
.hq-bottom-grid .b-half { grid-column: auto; }
}
/* Мобильный: всё в одну колонку */
@media (max-width: 760px) {
.hq-main-grid {
grid-template-columns: 1fr;
grid-template-areas:
"admissions"
"operations"
"bed";
}
.hq-bottom-grid { grid-template-columns: 1fr; }
}
/* ── Вспомогалки ── */
.chart-hint-icon {
cursor: pointer;
opacity: 0.4;
transition: opacity 0.15s;
}
.chart-hint-icon:hover { opacity: 0.85; }
.chart-empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 120px;
}
.modal-stat {
font-size: 20px;
font-weight: 700;
padding: 6px 2px 4px;
line-height: 1;
}
</style>