Обновлен стартовый экран

Переписаны запросы для статистики, отчетов
Добавлена интеграция отчета сестры
This commit is contained in:
brusnitsyn
2026-05-28 22:10:00 +09:00
parent 90e0d04dfd
commit 739168d427
96 changed files with 6663 additions and 1465 deletions

View File

@@ -40,7 +40,7 @@ const actionGroups = computed(() => [
key: 'actions',
items: [
{
label: 'Добавить на контроль',
label: 'Поставить на контроль',
key: 'add-observable',
icon: TbEyePlus,
if: props.isAddObservable
@@ -50,12 +50,7 @@ const actionGroups = computed(() => [
key: 'add-observable-comment',
icon: TbMessage,
if: props.isAddObservableComment
}
]
},
{
key: 'danger',
items: [
},
{
label: 'Снять с контроля',
key: 'remove-observable',
@@ -88,9 +83,9 @@ const onSelectOption = (key) => {
// props.onAddObservable?.(props.row)
emits('addObservable', props.row)
},
'add-comment': () => {
'add-observable-comment': () => {
// props.onAddObservableComment?.(props.row)
emits('onAddObservableComment', props.row)
emits('addObservableComment', props.row)
},
'remove-observable': () => {
// props.onRemoveObservable?.(props.row)

View File

@@ -2,7 +2,8 @@
import { NTooltip } from 'naive-ui'
import {computed} from "vue";
const props = defineProps({
operations: Array
operations: Array,
patient: Object
})
const emits = defineEmits(['click'])
@@ -13,7 +14,7 @@ const firstOperation = computed(() => props.operations[0])
<template>
<NTooltip v-if="operations.length" :arrow="false">
<template #trigger>
<div class="absolute inset-0 p-2 pt-2.5 cursor-pointer" @click="emits('click', operations)">
<div class="absolute inset-0 p-2 pt-2.5 cursor-pointer" @click="emits('click', {patient, operations})">
{{ firstOperation?.code_service }}
</div>
</template>

View File

@@ -0,0 +1,287 @@
<script setup>
import PatientTypeSection from "./PatientSection.vue";
import PatientDataTable from "./PatientDataTable.vue";
import {NButton, NFlex, NInput, NTabPane, NTabs, NSpace, NDivider} from "naive-ui";
import PatientTypeSectionItem from "./PatientSectionItem.vue";
import {computed, inject, ref} from "vue";
import {usePatientColumns} from "../../../Composables/usePatientColumns.js";
import OperationInfoModal from "./OperationInfoModal.vue";
import ObservableModal from "./Modals/ObservableModal.vue";
import {useAppDialog} from "../../../Composables/useAppDialog.js";
import {TbSearch} from "vue-icons-plus/tb";
import {router, usePage} from "@inertiajs/vue3";
const props = defineProps({
patients: {
type: Object,
default: () => ({data: []})
},
nursePatients: {
type: Object,
default: () => ({data: []})
}
})
const emits = defineEmits([
'createObservable',
'removeObservable',
])
const operationsInModal = ref(null)
const showOperationsModal = ref(false)
const activePatient = ref(null)
const showObservableModal = ref(false)
const searchArg = ref(null)
const searching = ref(false)
const { reportForm, updateReportForm } = inject('reportForm')
const page = usePage()
const canEditObservable = computed(() => {
const perms = page.props.user?.permissions ?? []
return perms.includes('report.create') || perms.includes('report.edit') || perms.includes('report.edit.past')
})
// Нормализация даты до минут для сравнения (убирает расхождения в секундах)
const normDate = (d) => {
if (!d) return null
try { return new Date(d).toISOString().slice(0, 16) } catch { return null }
}
const nurseByOriginalId = computed(() => {
const map = new Map()
for (const p of props.nursePatients?.data ?? []) {
if (p.original_id != null) map.set(p.original_id, p)
}
return map
})
const FIELD_LABELS = {
recipient_date: 'дата госпитализации',
extract_date: 'дата выписки',
death_date: 'дата смерти',
ingoing_date: 'дата поступления в отделение',
out_date: 'дата выбытия из отделения',
department: 'перевод в другое отделение',
}
const getNurseChanges = (misPat) => {
// Текущий период: MedicalHistory использует 'id', прошлый: ReportDutyPatient — 'original_id'
const patientKey = misPat.original_id ?? misPat.id
const nurseP = nurseByOriginalId.value.get(patientKey)
if (!nurseP) return []
const changes = []
if (normDate(nurseP.recipient_date) !== normDate(misPat.recipient_date))
changes.push('recipient_date')
if (normDate(nurseP.extract_date) !== normDate(misPat.extract_date))
changes.push('extract_date')
if (normDate(nurseP.death_date) !== normDate(misPat.death_date))
changes.push('death_date')
// Текущий период грузит latestMigration (объект), прошлый — migrations (массив)
const nurseMig = nurseP.migrations?.[0]
const misMig = misPat.migrations?.[0] ?? misPat.latest_migration
if (nurseMig && misMig) {
if (normDate(nurseMig.ingoing_date) !== normDate(misMig.ingoing_date))
changes.push('ingoing_date')
if (normDate(nurseMig.out_date) !== normDate(misMig.out_date))
changes.push('out_date')
if (nurseMig.department_id !== misMig.department_id)
changes.push('department')
}
return changes
}
const withNurse = (p) => {
const nurse_changes = getNurseChanges(p)
if (!nurse_changes.length) return p
return {
...p,
nurse_changes,
nurse_changes_labels: nurse_changes.map(k => FIELD_LABELS[k] ?? k),
}
}
const patientsByGroup = computed(() => {
const groups = {
urgent: [],
planned: [],
deceased: [],
in_department: [],
recipient: [],
discharged: [],
transferred: [],
reanimations: [],
observables: []
}
const patients = props.patients?.data ?? []
for (const raw of patients) {
const p = withNurse(raw)
const flags = p.period_flags ?? {}
const isCurrentAtEnd = flags.current_at_end ?? ['in_department', 'recipient'].includes(p.patient_status)
// Группировка по срочности за период: пациент должен состоять на конец периода.
if (isCurrentAtEnd && (flags.urgent ?? p.patient_urgency === 'urgent')) groups.urgent.push(p)
else if (isCurrentAtEnd && (flags.planned ?? p.patient_urgency === 'planned')) groups.planned.push(p)
// Группировка по реанимации
if (p.in_reanimation === true) groups.reanimations.push(p)
// Группировка по наблюдению
if (p.in_observable === true) groups.observables.push(p)
// Событийная группировка за период. Один пациент может быть в нескольких группах.
if (flags.recipient ?? p.patient_status === 'recipient') groups.recipient.push(p)
if (flags.current_at_end ?? p.patient_status === 'in_department') groups.in_department.push(p)
if (flags.discharged ?? p.patient_status === 'discharged') groups.discharged.push(p)
if (flags.deceased ?? p.patient_status === 'deceased') groups.deceased.push(p)
if (flags.transferred ?? p.patient_status === 'transferred') groups.transferred.push(p)
}
return groups
})
const {
planOrEmergencyColumns, observableColumns, reanimationColumns, dischargedColumns,
deceasedColumns, transferredColumns,
} = usePatientColumns({
onAddObservable: (row) => {
activePatient.value = row
showObservableModal.value = true
},
onShowOperationModal: ({patient, operations}) => {
activePatient.value = patient
operationsInModal.value = operations
showOperationsModal.value = true
},
onAddObservableComment: (row) => {
activePatient.value = row
showObservableModal.value = true
},
onRemoveObservable: async (row) => {
await useAppDialog({
title: 'Снятие с контроля',
content: 'Это действие необратимо. Продолжить?',
onConfirm: async () => {
const queryString = window.location.search;
const params = new URLSearchParams(queryString);
const url = new URL(`${window.location.origin}/api/duty/observable/close`)
url.searchParams.append('startAt', params.get('startAt'))
url.searchParams.append('endAt', params.get('endAt'))
try {
await axios.post(url.toString(), { ...row })
} catch (e) {
if (e.response?.status !== 404) throw e
// 404 — запись не найдена, удаляем локально
}
emits('removeObservable', row)
}
})
}
}, { canEditObservable })
const findPatient = () => {
if (searching.value) return
// Просто перезагружаем текущую страницу с новым search параметром
router.reload({
data: {
search: searchArg.value || null, // Отправляем как POST или GET параметр
},
preserveState: true,
preserveScroll: true,
only: ['patients', 'search'], // Обновляем только нужные пропсы
onStart: () => {
searching.value = true
},
onFinish: () => {
searching.value = false
}
})
}
const resetSearch = () => {
searchArg.value = null
router.reload({
data: { search: null }, // Отправляем null
preserveState: true,
preserveScroll: true,
only: ['patients', 'search', 'meta'],
onStart: () => {
searching.value = true
},
onFinish: () => {
searching.value = false
}
});
}
const onObservablePatient = (model) => {
const observableKey = 'observables'
const observablesInReport = reportForm.value[observableKey]
const preparedModel = {
...model,
in_observable: true
}
const form = { [observableKey]: [...observablesInReport, preparedModel] }
updateReportForm(form)
emits('createObservable', preparedModel)
}
</script>
<template>
<NSpace vertical :size="16">
<NFlex :wrap="false">
<NInput v-model:value="searchArg"
placeholder="Поиск пациента по ФИО"
@keydown.enter="findPatient"
clearable
@clear="resetSearch"
/>
<NButton secondary @click="findPatient">
<template #icon>
<TbSearch />
</template>
Найти
</NButton>
</NFlex>
<PatientTypeSection>
<PatientTypeSectionItem label="Планово" :counter="patientsByGroup.planned.length">
<PatientDataTable :data="patientsByGroup.planned" :columns="planOrEmergencyColumns" :loading="searching" />
</PatientTypeSectionItem>
<PatientTypeSectionItem label="Экстренно" :counter="patientsByGroup.urgent.length">
<PatientDataTable :data="patientsByGroup.urgent" :columns="planOrEmergencyColumns" :loading="searching" />
</PatientTypeSectionItem>
<PatientTypeSectionItem label="Находятся на контроле" :counter="patientsByGroup.observables.length">
<PatientDataTable :data="patientsByGroup.observables" :columns="observableColumns" :loading="searching" />
</PatientTypeSectionItem>
<PatientTypeSectionItem label="Находятся в реанимации" :counter="patientsByGroup.reanimations.length">
<PatientDataTable :data="patientsByGroup.reanimations" :columns="reanimationColumns" :loading="searching" />
</PatientTypeSectionItem>
<PatientTypeSectionItem label="Выбывшие" :counter="patientsByGroup.discharged.length + patientsByGroup.deceased.length">
<NTabs type="segment" animated>
<NTabPane name="1" :tab="`Выписанные (${patientsByGroup.discharged.length})`">
<PatientDataTable :data="patientsByGroup.discharged" :columns="dischargedColumns" :loading="searching" />
</NTabPane>
<NTabPane name="2" :tab="`Умершие (${patientsByGroup.deceased.length})`">
<PatientDataTable :data="patientsByGroup.deceased" :columns="deceasedColumns" :loading="searching" />
</NTabPane>
<NTabPane name="3" :tab="`Переведенные (${patientsByGroup.transferred.length})`">
<PatientDataTable :data="patientsByGroup.transferred" :columns="transferredColumns" :loading="searching" />
</NTabPane>
</NTabs>
</PatientTypeSectionItem>
</PatientTypeSection>
</NSpace>
<OperationInfoModal :patient="activePatient" :operations="operationsInModal" v-model:show="showOperationsModal" />
<ObservableModal :patient="activePatient" v-model:show="showObservableModal" @on-submit="(model) => onObservablePatient(model)" />
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,59 @@
<script setup>
import AppPanel from "../../../Components/AppPanel.vue";
import {NNumberAnimation, NStatistic} from "naive-ui";
const props = defineProps({
label: String,
counter: {
type: [String, Number],
default: null
},
isDoubleCounter: {
type: Boolean,
default: false
},
counterSuffix: {
type: [String, Number],
default: null
},
percent: {
type: Boolean,
default: false
},
counterClass: {
type: String
},
counterSuffixClass: {
type: String
}
})
</script>
<template>
<AppPanel no-padding>
<div class="flex flex-col items-center justify-center text-center py-2">
<NStatistic :label="label">
<template v-if="isDoubleCounter">
<span :class="counterClass">
<NNumberAnimation :from="0" :to="counter" />
<span v-if="percent" style="color: var(--n-close-icon-color)">%</span>
</span>
<span style="color: var(--n-close-icon-color)"> / </span>
<span :class="counterSuffixClass">
<NNumberAnimation :from="0" :to="counterSuffix" />
<span v-if="percent" style="color: var(--n-close-icon-color)">%</span>
</span>
</template>
<span v-else :class="counterClass">
<NNumberAnimation :from="0" :to="counter" />
<span v-if="percent" style="color: var(--n-close-icon-color)">%</span>
</span>
</NStatistic>
</div>
</AppPanel>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,98 @@
<script setup>
import {NButton, NForm, NFormItem, NInput, NModal, NSpace, NText, NSpin} from "naive-ui";
import {computed, ref, useTemplateRef, watch} from "vue";
const show = defineModel('show')
const props = defineProps({
patient: Object
})
const emits = defineEmits([
'onSubmit'
])
const formRef = useTemplateRef('form')
const loading = ref(true)
const model = ref({
observable_reason: null
})
const rules = {
observable_reason: {
required: true,
}
}
const resetForm = () => {
model.value = {
observable_reason: null
}
}
const actionText = computed(() => props.patient.in_observable ? 'Просмотр контроля' : 'Постановка на контроль')
const submit = () => {
formRef.value?.validate((errors) => {
if (!errors) {
emits('onSubmit', {
...props.patient,
...model.value
})
cancel()
}
else {
alert(errors)
}
})
}
const cancel = () => {
resetForm()
show.value = false
loading.value = true
}
const onAfterEnter = () => {
model.value.observable_reason = props.patient.observable?.observable_reason
loading.value = false
}
</script>
<template>
<NModal v-model:show="show"
:mask-closable="false"
@afterLeave="cancel"
@afterEnter="onAfterEnter"
segmented
draggable
class="max-w-lg relative overflow-hidden"
preset="card"
>
<template #header>
<NSpace vertical :size="1" class="text-base font-normal">
<NText strong>
{{ patient.full_name ?? '' }}
</NText>
<NText depth="3" class="text-sm">
{{ actionText }}
</NText>
</NSpace>
</template>
<NForm ref="form" :model="model" :rules="rules">
<NFormItem label="Опишите причину" path="observable_reason" :show-feedback="false">
<NInput type="textarea" :rows="6" :resizable="false" v-model:value="model.observable_reason" />
</NFormItem>
</NForm>
<template #action>
<NSpace align="center" justify="end">
<NButton type="primary" secondary @click="submit">
Сохранить
</NButton>
</NSpace>
</template>
<div v-if="loading" class="absolute inset-0 z-10 flex items-center justify-center" style="background-color: var(--n-color);">
<NSpin size="small" description="Загрузка..." />
</div>
</NModal>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,239 @@
<script setup>
import {NModal, NList, NListItem, NThing, NAvatar, NIcon, NDrawer, NDrawerContent,
NText, NDivider, NForm, NFormItem, NInput, NFlex, NButton, NScrollbar, NEmpty
} from 'naive-ui'
import {computed, ref, watch} from "vue";
import { TbAlertCircle, TbPencil, TbTrashX, TbCirclePlus, TbCheck, TbX } from 'vue-icons-plus/tb'
import {format, isValid} from "date-fns";
const show = defineModel('show')
const props = defineProps({
report: Object,
canSaveReport: Boolean
})
const emits = defineEmits([
'onSubmit'
])
const formRef = ref()
const createDrawerShow = ref(false)
const rules = {
comment: {
required: true,
message: 'Заполните этот блок',
trigger: 'blur'
}
}
const selectedEvent = ref(null)
const drawerCreatingMode = ref(true) // or false = editing
const model = ref({
unwantedEvents: []
})
// Создание в сторе и открытие drawer с формой нежелательного события
const onCreateEvent = () => {
drawerCreatingMode.value = true
const createDate = format(new Date(), 'Создано dd.MM.yyyy в HH:mm')
model.value.unwantedEvents.push({
title: `Нежелательное событие №${model.value.unwantedEvents.length + 1}`,
comment: '',
created_at: createDate
})
const length = model.value.unwantedEvents.length
selectedEvent.value = model.value.unwantedEvents[length - 1]
createDrawerShow.value = true
}
const onEditEvent = (event) => {
drawerCreatingMode.value = false
selectedEvent.value = event
createDrawerShow.value = true
}
const hasDisableAddButton = computed(() => {
return false
})
const onDeleteEvent = (event) => {
const indexOfDelete = model.value.unwantedEvents.findIndex(itm => itm === event)
if (typeof event.unwanted_event_id !== 'undefined') {
axios.delete(`/api/report/unwanted-event/${event.unwanted_event_id}`)
.then(() => {
model.value.unwantedEvents.splice(indexOfDelete, 1)
})
} else {
model.value.unwantedEvents.splice(indexOfDelete, 1)
}
}
const onCancelDrawerEvent = (event) => {
onDeleteEvent(event)
createDrawerShow.value = false
}
const onCreateDrawerEvent = () => {
formRef.value?.validate((errors) => {
if (!errors) {
createDrawerShow.value = false
}
else {
}
})
}
const onBeforeLeaveModal = () => {
selectedEvent.value = null
drawerCreatingMode.value = true
createDrawerShow.value = false
emits('onSubmit', model.value.unwantedEvents)
}
const cancel = () => {
resetForm()
show.value = false
loading.value = true
}
const onAfterEnter = () => {
model.value.unwantedEvents = props.report?.unwanted_events ?? []
}
// watch(() => props.report, (newReport) => {
// model.value.unwantedEvents = newReport?.unwanted_events ?? []
// })
</script>
<template>
<NModal v-model:show="show"
title="Нежелательные события"
preset="card"
:mask-closable="false"
:close-on-esc="false"
@afterEnter="onAfterEnter"
@before-leave="onBeforeLeaveModal"
class="max-w-4xl overflow-clip h-[calc(100vh-220px)]"
>
<template v-if="model.unwantedEvents.length">
<NScrollbar class="max-h-[calc(100vh-282px)] pr-3">
<NList>
<NListItem v-for="event in model.unwantedEvents">
<NThing>
<template #avatar>
<NAvatar>
<NIcon>
<TbAlertCircle class="text-red-400" />
</NIcon>
</NAvatar>
</template>
<template #header>
{{ event.title }}
</template>
<template #description>
<NText depth="3">
{{ event.created_at }}
</NText>
</template>
<NText>
{{ event.comment }}
</NText>
<template v-if="canSaveReport" #action>
<NFlex align="center">
<NButton secondary size="small" @click="onEditEvent(event)" :disabled="hasDisableAddButton">
<template #icon>
<TbPencil />
</template>
Редактировать
</NButton>
<NDivider vertical />
<NButton type="error" secondary size="small" @click="onDeleteEvent(event)" :disabled="hasDisableAddButton">
<template #icon>
<TbTrashX />
</template>
Удалить
</NButton>
</NFlex>
</template>
</NThing>
</NListItem>
</NList>
</NScrollbar>
</template>
<template v-else>
<div class="h-full flex items-center justify-center">
<NEmpty description="Нежелательных событий не найдено!">
<template #extra>
<NButton v-if="canSaveReport" type="primary" secondary @click="onCreateEvent()" size="small" :disabled="hasDisableAddButton">
<template #icon>
<TbCirclePlus />
</template>
Создать
</NButton>
</template>
</NEmpty>
</div>
</template>
<template v-if="canSaveReport" #action>
<NFlex id="modal-action" align="center" justify="space-between">
<NButton type="primary" secondary @click="onCreateEvent()" :disabled="hasDisableAddButton">
<template #icon>
<TbCirclePlus />
</template>
Создать событие
</NButton>
</NFlex>
</template>
<NDrawer
:show="createDrawerShow"
placement="bottom"
:max-height="600"
:min-height="400"
:default-height="400"
resizable
:trap-focus="false"
:block-scroll="false"
:mask-closable="false"
to="#modal-action"
>
<NDrawerContent>
<template #header>
<template v-if="drawerCreatingMode">Создание события</template>
<template v-else>Редактирование события</template>
</template>
<NForm ref="formRef" :model="selectedEvent" :rules="rules">
<NFormItem :show-label="false" path="comment">
<NInput type="textarea" :rows="8" v-model:value="selectedEvent.comment" />
</NFormItem>
</NForm>
<template #footer>
<NFlex align="center">
<NButton v-if="drawerCreatingMode" type="error" secondary @click="onCancelDrawerEvent(selectedEvent)">
<template #icon>
<TbX />
</template>
Отменить создание
</NButton>
<NDivider v-if="drawerCreatingMode" vertical />
<NButton type="primary" @click="onCreateDrawerEvent">
<template #icon>
<TbCheck />
</template>
<template v-if="drawerCreatingMode">Создать</template>
<template v-else>Сохранить</template>
</NButton>
</NFlex>
</template>
</NDrawerContent>
</NDrawer>
</NModal>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,96 @@
<script setup>
import PatientTypeSection from "./PatientSection.vue";
import PatientDataTable from "./PatientDataTable.vue";
import {NTabPane, NTabs} from "naive-ui";
import PatientTypeSectionItem from "./PatientSectionItem.vue";
import {computed, ref} from "vue";
import {usePatientColumns} from "../../../Composables/usePatientColumns.js";
import {router} from "@inertiajs/vue3";
import OperationInfoModal from "./OperationInfoModal.vue";
const props = defineProps({
patients: {
type: Array,
default: () => ([])
}
})
const operationsInModal = ref(null)
const showOperationsModal = ref(false)
const patientsByGroup = computed(() => {
const groups = {
urgent: [],
planned: [],
deceased: [],
in_department: [],
recipient: [],
discharged: [],
transferred: [],
reanimations: [],
observables: []
}
if (!props.patients.hasOwnProperty('data')) return groups
for (const p of props.patients.data) {
// Группировка по срочности
if (p.patient_urgency === 'urgent') groups.urgent.push(p)
else if (p.patient_urgency === 'planned') groups.planned.push(p)
// Группировка по реанимации
if (p.in_reanimation === true) groups.reanimations.push(p)
// Группировка по наблюдению
if (p.in_observable === true) groups.observables.push(p)
// Группировка по статусу (дублирование нужно, если один пациент может быть в двух таблицах)
if (groups.hasOwnProperty(p.patient_status)) {
groups[p.patient_status].push(p)
}
}
return groups
})
const {
planOrEmergencyColumns, observableColumns, reanimationColumns, dischargedColumns,
deceasedColumns, transferredColumns,
} = usePatientColumns({
onAddObservable: (row) => {
},
onShowOperationModal: (operations) => {
operationsInModal.value = operations
showOperationsModal.value = true
}
})
</script>
<template>
<PatientTypeSection>
<PatientTypeSectionItem label="Планово" :counter="patientsByGroup.planned.length">
<PatientDataTable :data="patientsByGroup.planned" :columns="planOrEmergencyColumns" />
</PatientTypeSectionItem>
<PatientTypeSectionItem label="Экстренно" :counter="patientsByGroup.urgent.length">
<PatientDataTable :data="patientsByGroup.urgent" :columns="planOrEmergencyColumns" />
</PatientTypeSectionItem>
<PatientTypeSectionItem label="Выбывшие" :counter="patientsByGroup.discharged.length + patientsByGroup.deceased.length">
<NTabs type="segment" animated>
<NTabPane name="1" :tab="`Выписанные (${patientsByGroup.discharged.length})`">
<PatientDataTable :data="patientsByGroup.discharged" :columns="dischargedColumns" />
</NTabPane>
<NTabPane name="2" :tab="`Умершие (${patientsByGroup.deceased.length})`">
<PatientDataTable :data="patientsByGroup.deceased" :columns="deceasedColumns" />
</NTabPane>
<NTabPane name="3" :tab="`Переведенные (${patientsByGroup.transferred.length})`">
<PatientDataTable :data="patientsByGroup.transferred" :columns="transferredColumns" />
</NTabPane>
</NTabs>
</PatientTypeSectionItem>
</PatientTypeSection>
<OperationInfoModal :operations="operationsInModal" v-model:show="showOperationsModal" />
</template>
<style scoped>
</style>

View File

@@ -1,12 +1,15 @@
<script setup>
import {NModal, NThing, NTag, NButton, NSpace, NDrawer, NDrawerContent, NInput} from "naive-ui";
import {NModal, NThing, NTag, NButton, NSpace, NDrawer, NDrawerContent, NInput, NText} from "naive-ui";
import {ref, watch} from "vue";
import {TbClockCheck, TbCalendar, TbTag} from 'vue-icons-plus/tb'
import {format} from "date-fns";
import AppPanel from "../../../Components/AppPanel.vue";
import UrgencyBadge from "../../../Components/UrgencyBadge.vue";
import OperationUrgencyTag from "./Tags/OperationUrgencyTag.vue";
const props = defineProps({
operations: Array
operations: Array,
patient: Object
})
const show = defineModel('show')
@@ -21,19 +24,28 @@ const onShowInfoDrawer = (operation) => {
<template>
<NModal v-model:show="show"
title="Операции"
:mask-closable="false"
:segmented="{ content: true }"
segmented
class="max-w-xl overflow-clip h-[400px]"
draggable
content-scrollable
preset="card"
id="modal-operation"
>
<template #header>
<NSpace vertical :size="1" class="text-base font-normal">
<NText strong>
{{ patient.full_name ?? '' }}
</NText>
<NText depth="3" class="text-sm">
Проведенные операции
</NText>
</NSpace>
</template>
<div class="space-y-4">
<NThing v-for="operation in operations">
<template #header>
Операция
Операция {{operation.number}}
</template>
<template #header-extra>
<NTag size="small" type="info" round :bordered="false">
@@ -45,18 +57,19 @@ const onShowInfoDrawer = (operation) => {
</template>
<template #description>
<NSpace align="center" size="small">
<NTag type="success" round :bordered="false">
<NTag type="success" round :bordered="false" size="small">
<template #icon>
<TbCalendar size="18" />
<TbCalendar size="16" />
</template>
{{format(operation.start_date, 'dd.MM.yyyy')}}
</NTag>
<NTag type="success" round :bordered="false">
<NTag type="success" round :bordered="false" size="small">
<template #icon>
<TbTag size="18" />
<TbTag size="16" />
</template>
{{operation.code_service}}
</NTag>
<OperationUrgencyTag :urgency-id="operation.urgent_status" size="small" />
</NSpace>
</template>
{{operation.name_service}}
@@ -70,7 +83,7 @@ const onShowInfoDrawer = (operation) => {
<NDrawer v-model:show="showInfoDrawer" width="100%" height="100%" to="#modal-operation" placement="bottom">
<NDrawerContent closable>
<template #header>
Операция
Операция {{showedOperation.number}}
</template>
<AppPanel no-padding header="Описание" class="h-full!">
<NInput type="textarea" :resizable="false" class="h-full!" readonly v-model:value="showedOperation.description" />

View File

@@ -1,7 +1,6 @@
<script setup>
import {NDataTable, NSpace, NInput, NButton, NFlex} from "naive-ui";
import {TbSearch} from 'vue-icons-plus/tb'
import {computed, h, ref} from "vue";
import {NDataTable} from "naive-ui";
import {computed, h, ref, watch} from "vue";
import IndexColumn from "./DataTableColumns/IndexColumn.vue";
const props = defineProps({
@@ -19,6 +18,7 @@ const props = defineProps({
})
const patients = ref([...props.data])
const loading = ref(false)
const tableColumns = computed(() => {
const baseColumns = [...props.columns]
@@ -36,46 +36,39 @@ const tableColumns = computed(() => {
return baseColumns
})
const searchArg = ref(null)
const findPatient = (arg) => {
// TODO: сделать поиск пациента через БДц
}
const rowProps = (row) => {
const style = []
if (row.admitted_today) {
style.push('--n-merged-td-color: #047857')
} else if (row.nurse_changes?.length) {
style.push('--n-merged-td-color: rgba(217, 119, 6, 0.15)')
}
return {
style: style,
style: style.join('; '),
title: row.nurse_changes?.length
? `Медсестра изменила: ${row.nurse_changes_labels.join(', ')}`
: undefined,
}
}
watch(() => props.data, (newData) => {
patients.value = newData
})
</script>
<template>
<NSpace vertical>
<NFlex :wrap="false">
<NInput v-model:value="searchArg" placeholder="Поиск пациента" @input="value => findPatient(value)" />
<NButton secondary @click="findPatient(searchArg)">
<template #icon>
<TbSearch />
</template>
Найти
</NButton>
</NFlex>
<NDataTable :columns="tableColumns"
:data="patients"
table-layout="fixed"
max-height="234"
min-height="234"
:loading="loading"
size="small"
:row-props="rowProps"
/>
</NSpace>
<NDataTable :columns="tableColumns"
:data="patients"
table-layout="fixed"
max-height="234"
min-height="234"
:loading="loading"
size="small"
:row-props="rowProps"
/>
</template>
<style scoped>

View File

@@ -11,7 +11,7 @@ const props = defineProps({
}
})
const counterText = computed(() => props.counter ? `(${props.counter})` : '')
const counterText = computed(() => props.counter ? `(${props.counter})` : '(0)')
const header = computed(() => `${props.label} ${counterText.value}`)
</script>

View File

@@ -42,8 +42,6 @@ const handleCollapseItemDragEnter = (e, itemName) => {
const handleItemDropped = (event) => {
const { item, fromStatus, toStatus } = event
console.log(event)
// Добавляем в целевую таблицу
if (toStatus && patientsData.value[toStatus]) {
// Проверяем, нет ли уже такого элемента

View File

@@ -3,7 +3,22 @@ import AppPanel from "../../../Components/AppPanel.vue";
import {NNumberAnimation, NStatistic} from "naive-ui";
const props = defineProps({
to: Number,
counter: {
type: [String, Number],
default: null
},
isDoubleCounter: {
type: Boolean,
default: false
},
counterSuffix: {
type: [String, Number],
default: null
},
percent: {
type: Boolean,
default: false
}
})
</script>
@@ -12,10 +27,18 @@ const props = defineProps({
style="--n-padding-top: 0; --n-padding-bottom: 0; --n-padding-left: 8px; --n-padding-right: 8px;">
<div class="w-full h-full flex flex items-center justify-center">
<NStatistic class="text-center">
<NNumberAnimation :from="0" :to="to" />
<template #label>
<slot />
</template>
<template v-if="isDoubleCounter">
<NNumberAnimation :from="0" :to="counter" />
<span v-if="percent" style="color: var(--n-close-icon-color)">%</span>
<span style="color: var(--n-close-icon-color)"> / </span>
<NNumberAnimation :from="0" :to="counterSuffix" />
<span v-if="percent" style="color: var(--n-close-icon-color)">%</span>
</template>
<NNumberAnimation v-else :from="0" :to="counter" />
<span v-if="percent" style="color: var(--n-close-icon-color)">%</span>
</NStatistic>
</div>
</AppPanel>

View File

@@ -5,6 +5,22 @@ import {computed, ref, watch} from "vue";
import {router, Link} from "@inertiajs/vue3";
import {useDebounceFn} from "@vueuse/core";
const show = defineModel('show')
const props = defineProps({
targetPath: {
type: String,
default: '/report'
},
submitLabel: {
type: String,
default: 'Перейти к заполнению сводной'
},
withExistsCheck: {
type: Boolean,
default: true
}
})
const reportStore = useReportStore()
const formRef = ref()
@@ -44,7 +60,7 @@ const fetchDepartments = () => {
}
const checkReportExists = async (userId, departmentId) => {
if (!userId || !departmentId) {
if (!props.withExistsCheck || !userId || !departmentId) {
reportExists.value = false;
existingReportId.value = null;
return;
@@ -64,7 +80,6 @@ const checkReportExists = async (userId, departmentId) => {
console.error('Ошибка при проверке отчета:', error);
reportExists.value = false;
existingReportId.value = null;
} finally {
}
}
@@ -124,7 +139,7 @@ const onSubmit = (e) => {
formRef.value?.validate((errors) => {
if (!errors) {
if (reportExists.value) return
router.visit(`/report`, {
router.visit(props.targetPath, {
data: {
userId: reportStore.reportInfo.userId,
departmentId: reportStore.reportInfo.departmentId
@@ -166,9 +181,9 @@ const onAfterLeave = () => {
/>
</NFormItem>
</NForm>
<NAlert v-if="reportExists" type="warning">
<NAlert v-if="reportExists && withExistsCheck" type="warning">
Сводная уже создана.
<NButton :tag="Link" text :href="`/report?userId=${reportStore.reportInfo.userId}&departmentId=${reportStore.reportInfo.departmentId}`">
<NButton :tag="Link" text :href="`${targetPath}?userId=${reportStore.reportInfo.userId}&departmentId=${reportStore.reportInfo.departmentId}`">
Перейти
</NButton>
</NAlert>
@@ -177,8 +192,8 @@ const onAfterLeave = () => {
type="primary"
block
@click="onSubmit"
:disabled="reportExists">
Перейти к заполнению сводной
:disabled="withExistsCheck && reportExists">
{{ submitLabel }}
</NButton>
</template>
</NModal>

View File

@@ -0,0 +1,26 @@
<script setup>
import {NTag} from "naive-ui"
import {computed} from "vue";
const props = defineProps({
urgencyId: {
type: Number,
default: 0
},
})
const urgentIds = [4, 5]
const plannedIds = [6]
const typeForUrgency = computed(() => plannedIds.includes(props.urgencyId) ? 'success' : 'error')
const textForUrgency = computed(() => plannedIds.includes(props.urgencyId) ? 'Планово' : 'Экстренно')
</script>
<template>
<NTag :type="typeForUrgency" round :bordered="false" size="small">
{{ textForUrgency }}
</NTag>
</template>
<style scoped>
</style>

View File

@@ -8,6 +8,9 @@ const open = defineModel('open')
import { TbAlertCircle, TbPencil, TbTrashX, TbCirclePlus, TbCheck, TbX } from 'vue-icons-plus/tb'
import {format, isValid} from "date-fns";
const props = defineProps({
canSaveReport: Boolean
})
const reportStore = useReportStore()
const formRef = ref()
const createDrawerShow = ref(false)
@@ -68,9 +71,7 @@ const onCancelDrawerEvent = (event) => {
const onCreateDrawerEvent = () => {
formRef.value?.validate((errors) => {
if (!errors) {
console.log(createDrawerShow.value)
createDrawerShow.value = false
console.log(createDrawerShow.value)
}
else {

View File

@@ -0,0 +1,37 @@
<script setup>
import HeaderWidget from "../HeaderWidget.vue"
import {computed} from "vue";
const props = defineProps({
counter: Number
})
const counterClass = computed(() => {
const value = typeof props.counter === 'number' ? props.counter : 0
if (value < 60) return 'counter-success'
if (value < 100) return 'counter-warning'
return 'counter-danger'
})
</script>
<template>
<HeaderWidget label="Загруженность"
:counter="counter"
:counter-class="counterClass"
percent
/>
</template>
<style scoped>
:deep(.counter-success) {
color: var(--n-color-target) !important;
}
:deep(.counter-warning) {
color: var(--n-feedback-text-color-warning) !important;
}
:deep(.counter-danger) {
color: var(--n-asterisk-color) !important;
}
</style>

View File

@@ -0,0 +1,32 @@
<script setup>
import {NModal, NSpace} from 'naive-ui'
import ReportWidget from "../ReportWidget.vue";
import {ref} from "vue";
const props = defineProps({
operations: Array,
planned: Number,
urgent: Number,
})
const showOperationsModal = ref(false)
</script>
<template>
<ReportWidget :counter="urgent" is-double-counter :counter-suffix="planned">
<NSpace vertical :size="1">
<div>Операций</div>
<div>Э / П</div>
</NSpace>
</ReportWidget>
<NModal v-model:show="showOperationsModal" preset="card" segmented class="max-w-screen md:max-w-3xl lg:max-w-6xl">
<template #header>
Кому провели операции на сегодня
</template>
</NModal>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,52 @@
<script setup>
import HeaderWidget from "../HeaderWidget.vue"
import {computed} from "vue";
const props = defineProps({
dutyCurrent: Number,
nurseCurrent: Number,
beds: Number,
})
const getColorClassByAbsolute = (current) => {
const freeBeds = props.beds - current
if (freeBeds >= 5) return 'counter-success' // Свободно 5+ коек
if (freeBeds >= 2) return 'counter-warning' // Свободно 2-4 койки
// if (freeBeds >= 0) return 'counter-warning-high' // Свободно 0-1 койка
return 'counter-danger' // Переполнение
}
const dutyClass = computed(() => {
const dutyValue = typeof props.dutyCurrent === 'number' ? props.dutyCurrent : 0
return getColorClassByAbsolute(dutyValue)
})
const nurseClass = computed(() => {
const nurseValue = typeof props.nurseCurrent === 'number' ? props.nurseCurrent : 0
return getColorClassByAbsolute(nurseValue)
})
</script>
<template>
<HeaderWidget label="Состоит"
is-double-counter
:counter="dutyCurrent"
:counter-suffix="nurseCurrent"
:counter-class="dutyClass"
:counter-suffix-class="nurseClass"
/>
</template>
<style scoped>
:deep(.counter-success) {
color: var(--n-color-target) !important;
}
:deep(.counter-warning) {
color: var(--n-feedback-text-color-warning) !important;
}
:deep(.counter-danger) {
color: var(--n-asterisk-color) !important;
}
</style>