Добавлена страница штаба
Добавлены графики Добавлены события отчетов
This commit is contained in:
866
resources/js/Pages/Statistic/Headquarters.vue
Normal file
866
resources/js/Pages/Statistic/Headquarters.vue
Normal file
@@ -0,0 +1,866 @@
|
||||
<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 } 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">Таблица</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>
|
||||
@@ -16,7 +16,7 @@ import AppLayout from "../../Layouts/AppLayout.vue";
|
||||
import {h, ref} from "vue";
|
||||
import DatePickerQuery from "../../Components/DatePickerQuery.vue";
|
||||
import {Link, router, usePage} from "@inertiajs/vue3";
|
||||
import {TbAlertCircle, TbEye} from "vue-icons-plus/tb";
|
||||
import {TbAlertCircle, TbEye, TbBuildingSkyscraper} from "vue-icons-plus/tb";
|
||||
import {PiMicrosoftExcelLogo} from "vue-icons-plus/pi";
|
||||
import ModalUnwantedEvents from "./Components/ModalUnwantedEvents.vue";
|
||||
import ModalObservablePatients from "./Components/ModalObservablePatients.vue";
|
||||
@@ -339,6 +339,14 @@ const buildReportHref = (departmentId, startAt, endAt) => {
|
||||
<NSpace align="center">
|
||||
<DatePickerQuery :is-head-or-admin="isHeadOrAdmin" :is-show-current-date-switch="true" :date="date" :is-one-day="isOneDay" />
|
||||
<NDivider vertical />
|
||||
<Link href="/statistic/headquarters">
|
||||
<NButton type="primary" secondary>
|
||||
<template #icon>
|
||||
<TbBuildingSkyscraper />
|
||||
</template>
|
||||
Штаб
|
||||
</NButton>
|
||||
</Link>
|
||||
<NButton type="info" secondary @click="downloadReport">
|
||||
<template #icon>
|
||||
<PiMicrosoftExcelLogo />
|
||||
|
||||
Reference in New Issue
Block a user