1329 lines
59 KiB
Vue
1329 lines
59 KiB
Vue
<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>
|