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(() => {
- + - -
-
-
- - -
-
-
- - -
-
-
- - -
-
-
- - -
- + +
+
+
+ + + {{ trendTip(kpiConsist, prevKpi.consist) }} + +
+
- -
- % + +
+
+
+ + + {{ trendTip(kpiAdmissions, prevKpi.admissions) }} + +
+ +
+
+ + + +
+
+
+ + + {{ trendTip(kpiOutcome, prevKpi.outcome) }} + +
+ +
+
+
+ + +
+
+
+ + + {{ trendTip(kpiOperations, prevKpi.operations) }} + +
+ +
+
+
+ + +
+
+
+ +
+ + + {{ trendTip(kpiDeceased, prevKpi.deceased) }} + +
+ +
+
+
+ + +
+
+
+ % +
+ + + {{ trendTip(kpiLoadPercent, prevKpi.load, '%') }} + +
+
@@ -634,7 +885,7 @@ onUnmounted(() => {
- +