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

867 lines
34 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,
} 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,
} 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 })
// Только реальные отделения (не заголовки и не итоги)
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 >= 80) return '#18a058'
if (pct >= 50) return '#f0a020'
return '#d03050'
}
const kpiLoadColorType = computed(() => {
if (kpiLoadPercent.value >= 80) return 'success'
if (kpiLoadPercent.value >= 50) return 'warning'
return 'error'
})
// Правая колонка ≈ 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)
)
// --- Общие базовые настройки ---
const FONT = "'Golos Text', system-ui, sans-serif"
const AXIS_COLOR = '#71717a'
const GRID_COLOR = '#3f3f46'
const LABEL_COLOR = '#e4e4e7'
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 = {
borderColor: GRID_COLOR,
strokeDashArray: 4,
xaxis: { lines: { show: false } },
padding: { top: 4, right: 12, bottom: 0, left: 4 },
}
const baseTooltip = {
theme: 'dark',
style: { fontSize: '12px', fontFamily: FONT },
}
// --- График: Загруженность коек (горизонтальный бар) ---
const bedOccupancyOptions = computed(() => ({
chart: baseChart('bar'),
theme: { mode: 'dark' },
plotOptions: {
bar: {
horizontal: true,
borderRadius: 5,
borderRadiusApplication: 'end',
distributed: true,
barHeight: '58%',
dataLabels: { position: 'top' },
},
},
fill: {
type: 'gradient',
gradient: {
type: 'horizontal',
opacityFrom: 0.75,
opacityTo: 1,
stops: [0, 100],
},
},
dataLabels: {
enabled: true,
formatter: v => `${v}%`,
style: { fontSize: '11px', fontFamily: FONT, fontWeight: 600, colors: [LABEL_COLOR] },
offsetX: 28,
},
xaxis: {
categories: departments.value.map(d => d.department),
max: 100,
labels: { style: { colors: AXIS_COLOR, fontSize: '11px', fontFamily: FONT }, formatter: v => `${v}%` },
axisBorder: { show: false },
axisTicks: { show: false },
},
yaxis: {
labels: {
style: { colors: AXIS_COLOR, fontSize: '11px', fontFamily: FONT },
maxWidth: 170,
},
},
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, yaxis: { lines: { show: false } } },
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: 'gradient',
gradient: {
shade: 'dark',
type: 'vertical',
opacityFrom: 1,
opacityTo: 0.55,
stops: [0, 100],
},
},
dataLabels: {
enabled: true,
style: { fontSize: '10px', fontFamily: FONT, fontWeight: 600, colors: [LABEL_COLOR] },
offsetY: -6,
},
xaxis: {
categories: departments.value.map(d => d.department),
labels: { style: { colors: AXIS_COLOR, fontSize: '10px', fontFamily: FONT }, rotate: -40, rotateAlways: true },
axisBorder: { show: false },
axisTicks: { show: false },
},
yaxis: { labels: { style: { colors: AXIS_COLOR, fontFamily: FONT } }, tickAmount: 4 },
colors: ['#6366f1', '#10b981'],
legend: {
position: 'top',
horizontalAlign: 'right',
labels: { colors: AXIS_COLOR },
fontSize: '12px',
fontFamily: FONT,
markers: { size: 6, shape: 'circle' },
},
tooltip: {
...baseTooltip,
shared: true,
intersect: false,
},
grid: baseGrid,
}))
const admissionsSeries = computed(() => [
{ name: 'Поступило', data: departments.value.map(d => d.recipients?.all ?? 0) },
{ name: 'Выбыло', data: departments.value.map(d => d.outcome ?? 0) },
])
// --- График: Операции (donut) ---
const totalEmergencySurgical = computed(() => grandTotals.value.emergency_surgical_sum ?? 0)
const totalPlanSurgical = computed(() => grandTotals.value.plan_surgical_sum ?? 0)
const operationsOptions = computed(() => ({
chart: {
...baseChart('donut'),
animations: { speed: 700, dynamicAnimation: { enabled: true, speed: 450 } },
},
colors: ['#f43f5e', '#6366f1'],
labels: ['Экстренные', 'Плановые'],
stroke: { width: 2, colors: ['#18181b'] },
dataLabels: {
enabled: true,
style: { fontSize: '13px', fontFamily: FONT, fontWeight: 700 },
dropShadow: { enabled: false },
},
fill: {
type: 'gradient',
gradient: {
shade: 'dark',
type: 'diagonal1',
opacityFrom: 1,
opacityTo: 0.75,
},
},
legend: {
position: 'bottom',
labels: { colors: AXIS_COLOR },
fontSize: '13px',
fontFamily: FONT,
markers: { size: 8, shape: 'circle' },
itemMargin: { horizontal: 12 },
},
plotOptions: {
pie: {
donut: {
size: '68%',
labels: {
show: true,
name: {
show: true,
fontSize: '13px',
fontFamily: FONT,
color: AXIS_COLOR,
offsetY: -6,
},
value: {
show: true,
fontSize: '32px',
fontFamily: FONT,
fontWeight: 700,
color: LABEL_COLOR,
offsetY: 8,
formatter: val => Math.round(val),
},
total: {
show: true,
label: 'Всего',
fontSize: '12px',
fontFamily: FONT,
color: AXIS_COLOR,
formatter: w => w.globals.seriesTotals.reduce((a, b) => a + b, 0),
},
},
},
},
},
tooltip: baseTooltip,
}))
const operationsSeries = computed(() => [
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: 'gradient',
gradient: {
shade: 'dark',
type: 'vertical',
opacityFrom: 1,
opacityTo: 0.55,
stops: [0, 100],
},
},
dataLabels: {
enabled: true,
style: { fontSize: '11px', fontFamily: FONT, fontWeight: 600, colors: [LABEL_COLOR] },
offsetY: -6,
},
xaxis: {
categories: unwantedDepts.value.map(d => d.department),
labels: { style: { colors: AXIS_COLOR, fontSize: '10px', fontFamily: FONT }, rotate: -40, rotateAlways: true },
axisBorder: { show: false },
axisTicks: { show: false },
},
yaxis: { labels: { style: { colors: AXIS_COLOR, fontFamily: FONT } }, tickAmount: 4 },
colors: ['#f59e0b'],
tooltip: { ...baseTooltip, y: { formatter: v => `${v} событий` } },
grid: baseGrid,
}))
const unwantedSeries = computed(() => [{
name: 'НС',
data: unwantedDepts.value.map(d => d.countUnwanted),
}])
// --- График: Состав по отделениям ---
const consistOptions = computed(() => ({
chart: baseChart('bar'),
theme: { mode: 'dark' },
plotOptions: {
bar: {
borderRadius: 5,
borderRadiusApplication: 'end',
columnWidth: '55%',
dataLabels: { position: 'top' },
},
},
fill: {
type: 'gradient',
gradient: {
shade: 'dark',
type: 'vertical',
opacityFrom: 1,
opacityTo: 0.5,
stops: [0, 100],
},
},
dataLabels: {
enabled: true,
style: { fontSize: '10px', fontFamily: FONT, fontWeight: 600, colors: [LABEL_COLOR] },
offsetY: -6,
},
xaxis: {
categories: departments.value.map(d => d.department),
labels: { style: { colors: AXIS_COLOR, fontSize: '10px', fontFamily: FONT }, rotate: -40, rotateAlways: true },
axisBorder: { show: false },
axisTicks: { show: false },
},
yaxis: { labels: { style: { colors: AXIS_COLOR, fontFamily: FONT } }, tickAmount: 4 },
colors: ['#818cf8'],
tooltip: { ...baseTooltip, y: { formatter: v => `${v} пациентов` } },
grid: baseGrid,
}))
const consistSeries = computed(() => [{
name: 'Пациентов',
data: departments.value.map(d => d.consist ?? 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 :cols="6" :x-gap="10">
<NGridItem>
<SectionCard title="Состоит" :icon="TbBed">
<div class="kpi-value"><NNumberAnimation :from="0" :to="kpiConsist" /></div>
</SectionCard>
</NGridItem>
<NGridItem>
<SectionCard title="Поступило" :icon="TbUsers">
<div class="kpi-value"><NNumberAnimation :from="0" :to="kpiAdmissions" /></div>
</SectionCard>
</NGridItem>
<NGridItem>
<SectionCard title="Выбыло" :icon="TbLayoutDashboard">
<div class="kpi-value"><NNumberAnimation :from="0" :to="kpiOutcome" /></div>
</SectionCard>
</NGridItem>
<NGridItem>
<SectionCard title="Операции" :icon="TbScissors">
<div class="kpi-value"><NNumberAnimation :from="0" :to="kpiOperations" /></div>
</SectionCard>
</NGridItem>
<NGridItem>
<SectionCard title="Умерло" :icon="TbSkull" :color="kpiDeceased > 0 ? 'error' : null">
<div class="kpi-value" :style="kpiDeceased > 0 ? 'color:var(--error-color)' : ''">
<NNumberAnimation :from="0" :to="kpiDeceased" />
</div>
</SectionCard>
</NGridItem>
<NGridItem>
<SectionCard title="Загруженность" :icon="TbChartBar" :color="kpiLoadColorType">
<div class="kpi-value" :style="`color:${loadColor(kpiLoadPercent)}`">
<NNumberAnimation :from="0" :to="kpiLoadPercent" /><span class="kpi-unit">%</span>
</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 title="Поступления и выбытия" :icon="TbUsers" 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="300"
:options="{ ...admissionsOptions, chart: { ...admissionsOptions.chart, events: { dataPointSelection: onAdmissionsChartClick } } }"
:series="admissionsSeries"
/>
<NEl v-else class="chart-empty"><NEmpty description="Нет данных" size="small" /></NEl>
</SectionCard>
</div>
<!-- Операции правая колонка, строка 2 -->
<div class="hq-operations-cell">
<SectionCard title="Операции" :icon="TbScissors" no-padding>
<VueApexCharts
v-if="kpiOperations > 0"
type="donut"
height="260"
:options="operationsOptions"
:series="operationsSeries"
/>
<NEl v-else class="chart-empty"><NEmpty description="Операций нет" size="small" /></NEl>
</SectionCard>
</div>
</div>
<!-- Нижняя сетка: состав + НС + лента -->
<div class="hq-bottom-grid">
<!-- Состав по отделениям -->
<SectionCard title="Состав по отделениям" :icon="TbBed" no-padding>
<VueApexCharts
v-if="departments.length"
type="bar"
height="260"
:options="consistOptions"
:series="consistSeries"
/>
<NEl v-else class="chart-empty"><NEmpty description="Нет данных" size="small" /></NEl>
</SectionCard>
<!-- НС (только если есть) -->
<SectionCard
v-if="unwantedDepts.length"
title="Нежелательные события"
:icon="TbAlertTriangle"
color="warning"
no-padding
>
<VueApexCharts
type="bar"
height="260"
:options="unwantedOptions"
:series="unwantedSeries"
/>
</SectionCard>
<!-- Лента активности -->
<SectionCard title="Лента активности" :icon="TbActivity">
<template #header-extra>
<NBadge v-if="activityFeed.length" :value="activityFeed.length" :max="99" type="info" />
</template>
<NScrollbar style="max-height: 220px;">
<NList v-if="activityFeed.length" hoverable :show-divider="false">
<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>
<NEmpty v-else description="Событий пока нет" size="small" />
</NScrollbar>
</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;
}
/* ── KPI числа ── */
.kpi-value {
font-size: 30px;
font-weight: 700;
line-height: 1;
padding: 10px 2px 8px;
letter-spacing: -0.5px;
}
.kpi-unit {
font-size: 18px;
font-weight: 600;
opacity: 0.7;
margin-left: 2px;
}
/* ── Основная сетка: левый бар + 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-bottom-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 10px;
align-items: start;
}
/* ── Вспомогалки ── */
.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>