diff --git a/resources/js/Pages/Statistic/Headquarters.vue b/resources/js/Pages/Statistic/Headquarters.vue
index 589cc2d..50f2f23 100644
--- a/resources/js/Pages/Statistic/Headquarters.vue
+++ b/resources/js/Pages/Statistic/Headquarters.vue
@@ -5,6 +5,7 @@ import {
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'
@@ -15,7 +16,7 @@ import {
TbBed, TbUsers, TbScissors, TbSkull,
TbWifi, TbWifiOff, TbInfoCircle,
TbAlertTriangle, TbActivity, TbChartBar,
- TbLayoutDashboard,
+ TbLayoutDashboard, TbTrendingUp, TbTrendingDown, TbMinus,
} from 'vue-icons-plus/tb'
import { format } from 'date-fns'
import { ru } from 'date-fns/locale'
@@ -42,6 +43,9 @@ const grandTotals = ref(props.grandTotals)
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)
@@ -58,19 +62,131 @@ 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'
+ if (pct > 100) return soften(themeVars.value.errorColor)
+ return soften(themeVars.value.successColor)
}
-const kpiLoadColorType = computed(() => {
- if (kpiLoadPercent.value >= 80) return 'success'
- if (kpiLoadPercent.value >= 50) return 'warning'
- return 'error'
+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 `
${sparkDayLabel(d)}${v}
`
+ },
+ },
+ }
+}
+
// Правая колонка ≈ 300 (поступления) + 46 (header) + 10 (gap) + 260 (операции) + 46 (header) = 662px
// Левый чарт: чтобы совпадало — chart = 662 - 46 (header левой карточки) = 616px
// Но если отделений мало/много — берём естественный размер по числу строк
@@ -79,10 +195,51 @@ const bedChartHeight = computed(() =>
)
// --- Общие базовые настройки ---
-const FONT = "'Golos Text', system-ui, sans-serif"
-const AXIS_COLOR = '#71717a'
-const GRID_COLOR = '#3f3f46'
-const LABEL_COLOR = '#e4e4e7'
+// Шрифт приложения (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,
@@ -97,12 +254,12 @@ const baseChart = (type) => ({
},
})
-const baseGrid = {
- borderColor: GRID_COLOR,
+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',
@@ -128,25 +285,16 @@ const bedOccupancyOptions = computed(() => ({
dataLabels: { position: 'top' },
},
},
- fill: {
- type: 'gradient',
- gradient: {
- shade: 'dark',
- type: 'vertical',
- opacityFrom: 1,
- opacityTo: 0.55,
- stops: [0, 100],
- },
- },
+ fill: { type: 'solid', opacity: 1 },
dataLabels: {
enabled: true,
formatter: v => `${v}%`,
- style: { fontSize: '10px', fontFamily: FONT, fontWeight: 600, colors: [LABEL_COLOR] },
+ style: { fontSize: '10px', fontFamily: FONT, fontWeight: 600, colors: [cLabel.value] },
offsetY: -6,
},
xaxis: {
categories: departments.value.map(d => d.department),
- labels: { style: { colors: AXIS_COLOR, fontSize: '10px', fontFamily: FONT }, rotate: -40, rotateAlways: true },
+ labels: { style: { colors: cAxis.value, fontSize: '10px', fontFamily: FONT }, rotate: -40, rotateAlways: true },
axisBorder: { show: false },
axisTicks: { show: false },
},
@@ -154,18 +302,18 @@ const bedOccupancyOptions = computed(() => ({
min: 0,
max: bedMax.value,
tickAmount: 5,
- labels: { style: { colors: AXIS_COLOR, fontFamily: FONT }, formatter: v => `${Math.round(v)}%` },
+ labels: { style: { colors: cAxis.value, fontFamily: FONT }, formatter: v => `${Math.round(v)}%` },
},
annotations: {
yaxis: [{
y: 100,
- borderColor: '#d03050',
+ borderColor: soften(themeVars.value.errorColor),
strokeDashArray: 4,
label: {
text: '100%',
position: 'right',
- borderColor: '#d03050',
- style: { background: '#d03050', color: '#fff', fontSize: '10px', fontFamily: FONT },
+ borderColor: soften(themeVars.value.errorColor),
+ style: { background: soften(themeVars.value.errorColor), color: themeVars.value.baseColor, fontSize: '10px', fontFamily: FONT },
},
}],
},
@@ -179,7 +327,7 @@ const bedOccupancyOptions = computed(() => ({
},
},
},
- grid: baseGrid,
+ grid: baseGrid.value,
legend: { show: false },
}))
@@ -200,33 +348,24 @@ const admissionsOptions = computed(() => ({
dataLabels: { position: 'top' },
},
},
- fill: {
- type: 'gradient',
- gradient: {
- shade: 'dark',
- type: 'vertical',
- opacityFrom: 1,
- opacityTo: 0.55,
- stops: [0, 100],
- },
- },
+ fill: { type: 'solid', opacity: 1 },
dataLabels: {
enabled: true,
- style: { fontSize: '10px', fontFamily: FONT, fontWeight: 600, colors: [LABEL_COLOR] },
+ style: { fontSize: '10px', fontFamily: FONT, fontWeight: 600, colors: [cLabel.value] },
offsetY: -6,
},
xaxis: {
categories: departments.value.map(d => d.department),
- labels: { style: { colors: AXIS_COLOR, fontSize: '10px', fontFamily: FONT }, rotate: -40, rotateAlways: true },
+ labels: { style: { colors: cAxis.value, 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'],
+ 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: AXIS_COLOR },
+ labels: { colors: cAxis.value },
fontSize: '12px',
fontFamily: FONT,
markers: { size: 6, shape: 'circle' },
@@ -236,91 +375,56 @@ const admissionsOptions = computed(() => ({
shared: true,
intersect: false,
},
- grid: baseGrid,
+ grid: baseGrid.value,
}))
const admissionsSeries = computed(() => [
- // Норма нарастающим итогом по периоду — то же поле, что в правом виджете статистики
- // (recipientPlanOfYear.plan = сумма cumulative_plan, см. StatisticsService).
- { name: 'Норма', data: departments.value.map(d => d.plan?.cumulative_plan ?? 0) },
+ // Норма за выбранный период: план_месяца × число месяцев в диапазоне (PlanCalculator.period_plan)
+ { name: 'Норма', data: departments.value.map(d => d.plan?.period_plan ?? 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 totalSurgical = computed(() => totalEmergencySurgical.value + totalPlanSurgical.value)
const operationsOptions = computed(() => ({
- chart: {
- ...baseChart('donut'),
- animations: { speed: 700, dynamicAnimation: { enabled: true, speed: 450 } },
+ chart: baseChart('bar'),
+ theme: { mode: 'dark' },
+ plotOptions: {
+ bar: {
+ horizontal: true,
+ distributed: true,
+ borderRadius: 5,
+ borderRadiusApplication: 'end',
+ barHeight: '70%',
+ dataLabels: { position: 'center' },
+ },
},
- colors: ['#f43f5e', '#6366f1'],
- labels: ['Экстренные', 'Плановые'],
- stroke: { width: 2, colors: ['#18181b'] },
+ fill: { type: 'solid', opacity: 1 },
+ colors: [soften(themeVars.value.errorColor), soften(themeVars.value.textColor3)],
dataLabels: {
enabled: true,
- style: { fontSize: '13px', fontFamily: FONT, fontWeight: 700 },
- dropShadow: { enabled: false },
+ formatter: v => v,
+ style: { fontSize: '13px', fontFamily: FONT, fontWeight: 700, colors: [themeVars.value.baseColor] },
},
- fill: {
- type: 'gradient',
- gradient: {
- shade: 'dark',
- type: 'diagonal1',
- opacityFrom: 1,
- opacityTo: 0.75,
- },
+ xaxis: {
+ categories: ['Экстренные', 'Плановые'],
+ labels: { style: { colors: cAxis.value, fontFamily: FONT } },
+ axisBorder: { show: false },
+ axisTicks: { show: false },
},
- 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,
+ 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(() => [
- totalEmergencySurgical.value,
- totalPlanSurgical.value,
-])
+const operationsSeries = computed(() => [{
+ name: 'Операции',
+ data: [totalEmergencySurgical.value, totalPlanSurgical.value],
+}])
// --- График: Нежелательные события ---
const unwantedDepts = computed(() => departments.value.filter(d => (d.countUnwanted ?? 0) > 0))
@@ -336,31 +440,22 @@ const unwantedOptions = computed(() => ({
dataLabels: { position: 'top' },
},
},
- fill: {
- type: 'gradient',
- gradient: {
- shade: 'dark',
- type: 'vertical',
- opacityFrom: 1,
- opacityTo: 0.55,
- stops: [0, 100],
- },
- },
+ fill: { type: 'solid', opacity: 1 },
dataLabels: {
enabled: true,
- style: { fontSize: '11px', fontFamily: FONT, fontWeight: 600, colors: [LABEL_COLOR] },
+ style: { fontSize: '11px', fontFamily: FONT, fontWeight: 600, colors: [cLabel.value] },
offsetY: -6,
},
xaxis: {
categories: unwantedDepts.value.map(d => d.department),
- labels: { style: { colors: AXIS_COLOR, fontSize: '10px', fontFamily: FONT }, rotate: -40, rotateAlways: true },
+ labels: { style: { colors: cAxis.value, 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'],
+ yaxis: { labels: { style: { colors: cAxis.value, fontFamily: FONT } }, tickAmount: 4 },
+ colors: [soften(themeVars.value.errorColor)],
tooltip: { ...baseTooltip, y: { formatter: v => `${v} событий` } },
- grid: baseGrid,
+ grid: baseGrid.value,
}))
const unwantedSeries = computed(() => [{
@@ -387,30 +482,21 @@ const surgicalActivityOptions = computed(() => ({
dataLabels: { position: 'top' },
},
},
- fill: {
- type: 'gradient',
- gradient: {
- shade: 'dark',
- type: 'vertical',
- opacityFrom: 1,
- opacityTo: 0.5,
- stops: [0, 100],
- },
- },
+ fill: { type: 'solid', opacity: 1 },
dataLabels: {
enabled: true,
formatter: v => `${v}%`,
- style: { fontSize: '10px', fontFamily: FONT, fontWeight: 600, colors: [LABEL_COLOR] },
+ style: { fontSize: '10px', fontFamily: FONT, fontWeight: 600, colors: [cLabel.value] },
offsetY: -6,
},
xaxis: {
categories: departments.value.map(d => d.department),
- labels: { style: { colors: AXIS_COLOR, fontSize: '10px', fontFamily: FONT }, rotate: -40, rotateAlways: true },
+ labels: { style: { colors: cAxis.value, fontSize: '10px', fontFamily: FONT }, rotate: -40, rotateAlways: true },
axisBorder: { show: false },
axisTicks: { show: false },
},
- yaxis: { labels: { style: { colors: AXIS_COLOR, fontFamily: FONT }, formatter: v => `${Math.round(v)}%` }, tickAmount: 4 },
- colors: ['#14b8a6'],
+ yaxis: { labels: { style: { colors: cAxis.value, fontFamily: FONT }, formatter: v => `${Math.round(v)}%` }, tickAmount: 4 },
+ colors: [soften(themeVars.value.textColor3)],
tooltip: {
...baseTooltip,
y: {
@@ -421,7 +507,7 @@ const surgicalActivityOptions = computed(() => ({
},
},
},
- grid: baseGrid,
+ grid: baseGrid.value,
}))
const surgicalActivitySeries = computed(() => [{
@@ -429,6 +515,87 @@ const surgicalActivitySeries = computed(() => [{
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
@@ -570,38 +737,122 @@ onUnmounted(() => {
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+ {{ trends.consist.label }}
+
+
+ {{ trendTip(kpiConsist, prevKpi.consist) }}
+
+
+
-
-
-
%
+
+
+
+
+
+
+
+ {{ trends.admissions.label }}
+
+
+ {{ trendTip(kpiAdmissions, prevKpi.admissions) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ trends.outcome.label }}
+
+
+ {{ trendTip(kpiOutcome, prevKpi.outcome) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ trends.operations.label }}
+
+
+ {{ trendTip(kpiOperations, prevKpi.operations) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ trends.deceased.label }}
+
+
+ {{ trendTip(kpiDeceased, prevKpi.deceased) }}
+
+
+
+
+
+
+
+
+
+
+
+ %
+
+
+
+
+ {{ trends.load.label }}
+
+
+ {{ trendTip(kpiLoadPercent, prevKpi.load, '%') }}
+
+
+
@@ -634,7 +885,7 @@ onUnmounted(() => {
-
+
@@ -643,37 +894,44 @@ onUnmounted(() => {
Кликните на столбец для деталей отделения
-
-
+
+
+
+
-
-
-
+
+
+ Всего: {{ totalSurgical }}
+
+
+
+
+
-
+
-
-
+
+
{
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.title }}
+ {{ item.body }}
+
+
+
+ {{ item.time }}
+
+ {{ item.action }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
:series="unwantedSeries"
/>
-
-
-
-
-
-
-
-
-
-
-
-
- {{ item.title }}
- {{ item.body }}
-
-
-
- {{ item.time }}
-
- {{ item.action }}
-
-
-
-
-
-
@@ -836,14 +1133,30 @@ onUnmounted(() => {
display: flex;
flex-direction: column;
gap: 10px;
+ /* не растягиваем на весь сверхширокий монитор — ограничиваем и центрируем */
+ width: 100%;
+ max-width: 1600px;
+ margin: 0 auto;
}
-/* ── KPI числа ── */
+/* ── 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;
- padding: 10px 2px 8px;
letter-spacing: -0.5px;
}
.kpi-unit {
@@ -852,6 +1165,52 @@ onUnmounted(() => {
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 {
@@ -871,13 +1230,79 @@ onUnmounted(() => {
.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(auto-fit, minmax(280px, 1fr));
+ 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 {