* добавил удаление карты, если она была добавлена не из МИС

* добавил диалог при удалении карты
* добавил сохранение движения
* добавил вывод сохраненного отчета
* изменил логику сохранения отчета
This commit is contained in:
brusnitsyn
2026-05-06 17:03:41 +09:00
parent fe2264dfce
commit 2026a1ca9f
22 changed files with 928 additions and 195 deletions

View File

@@ -0,0 +1,53 @@
<script setup>
import { NModal, NSpace, NButton, NText, NFlex, NSpin } from 'naive-ui'
const props = defineProps({
show: Boolean,
loading: Boolean,
title: String,
content: String,
positiveText: { type: String, default: 'Подтвердить' },
negativeText: { type: String, default: 'Отмена' },
maskClosable: { type: Boolean, default: false },
positiveProps: { type: Object, default: { type: 'error', secondary: true } },
negativeProps: { type: Object, default: { type: 'primary', secondary: true } }
})
const emit = defineEmits(['update:show', 'confirm', 'cancel'])
const handleAction = (type) => {
emit(type)
}
</script>
<template>
<NModal
:show="show"
:mask-closable="maskClosable"
@update:show="(val) => emit('update:show', val)"
@after-leave="emit('after-leave')"
preset="card"
class="max-w-sm relative overflow-clip"
:title="title"
>
<NFlex vertical size="large">
<NSpace vertical :size="0">
<NText v-if="content" tag="p">{{ content }}</NText>
</NSpace>
<NSpace vertical size="small">
<NButton v-if="negativeText" block v-bind="negativeProps" @click="handleAction('cancel')">
{{ negativeText }}
</NButton>
<NButton v-if="positiveText" block v-bind="positiveProps" @click="handleAction('confirm')">
{{ positiveText }}
</NButton>
</NSpace>
</NFlex>
<div v-if="loading" class="absolute inset-0" style="background-color: color-mix(in srgb, var(--n-color-embedded-modal), transparent 50%);">
<div class="flex flex-col items-center justify-center h-full">
<NSpin description="Загрузка" />
</div>
</div>
</NModal>
</template>

View File

@@ -0,0 +1,40 @@
<script setup>
import {dialogQueue, closeDialog, cleanupDialog} from '../Composables/useAppDialog'
import AppDialog from './AppDialog.vue'
const handleConfirm = async (dialog) => {
dialog.loading = true
try {
// Ждём выполнения вашего запроса
if (typeof dialog.onConfirm === 'function') {
await dialog.onConfirm()
}
closeDialog(dialog.id, true)
} catch (error) {
console.error('Ошибка при подтверждении:', error)
dialog.loading = false // Оставляем диалог открытым при ошибке
}
}
</script>
<template>
<Teleport to="body">
<AppDialog
v-for="dialog in dialogQueue"
:key="dialog.id"
:show="dialog.show"
:loading="dialog.loading"
:title="dialog.title"
:content="dialog.content"
:positive-text="dialog.positiveText"
:negative-text="dialog.negativeText"
:positive-props="dialog.positiveProps"
:negative-props="dialog.negativeProps"
:mask-closable="dialog.maskClosable"
@confirm="handleConfirm(dialog)"
@cancel="closeDialog(dialog.id, false)"
@update:show="(val) => !val && dialog.show && !dialog.loading && closeDialog(dialog.id, false)"
@after-leave="cleanupDialog(dialog.id)"
/>
</Teleport>
</template>

View File

@@ -120,37 +120,52 @@ const modelComputed = computed({
const formattedValue = computed(() => {
const value = reportStore.timestampCurrentRange
if (authStore.isHeadOfDepartment || authStore.isAdmin) {
if (props.isOneDay) {
const dateToFormat = Array.isArray(value) ? value[1] : value
return formatRussianDate(dateToFormat)
} else if (Array.isArray(value) && value.length >= 2 && value[0] && value[1]) { // Для админа - диапазон дат
return formatRussianDateRange(value)
}
// Если что-то пошло не так, форматируем как одиночную дату
if (value) {
const dateToFormat = Array.isArray(value) ? value[0] : value
return formatRussianDate(dateToFormat)
}
return ''
} else {
// Для врача - одиночная дата
let dateToFormat
if (Array.isArray(value)) {
dateToFormat = value[1] || value[0]
} else {
dateToFormat = value
}
// Если выбрана сегодняшняя дата - показываем текущее время
if (dateToFormat) {
return formatRussianDate(dateToFormat)
}
return ''
if (props.isOneDay) {
const dateToFormat = Array.isArray(value) ? value[1] : value
return formatRussianDate(dateToFormat)
} else if (Array.isArray(value) && value.length >= 2 && value[0] && value[1]) { // Для админа - диапазон дат
return formatRussianDateRange(value)
}
// Если что-то пошло не так, форматируем как одиночную дату
if (value) {
const dateToFormat = Array.isArray(value) ? value[0] : value
return formatRussianDate(dateToFormat)
}
return ''
// if (authStore.isHeadOfDepartment || authStore.isAdmin) {
// if (props.isOneDay) {
// const dateToFormat = Array.isArray(value) ? value[1] : value
// return formatRussianDate(dateToFormat)
// } else if (Array.isArray(value) && value.length >= 2 && value[0] && value[1]) { // Для админа - диапазон дат
// return formatRussianDateRange(value)
// }
//
// // Если что-то пошло не так, форматируем как одиночную дату
// if (value) {
// const dateToFormat = Array.isArray(value) ? value[0] : value
// return formatRussianDate(dateToFormat)
// }
//
// return ''
// } else {
// // Для врача - одиночная дата
// let dateToFormat
//
// if (Array.isArray(value)) {
// dateToFormat = value[1] || value[0]
// } else {
// dateToFormat = value
// }
//
// // Если выбрана сегодняшняя дата - показываем текущее время
// if (dateToFormat) {
// return formatRussianDate(dateToFormat)
// }
// return ''
// }
})
const classComputed = computed(() => {

View File

@@ -0,0 +1,37 @@
import { h, ref, render, nextTick } from 'vue'
import AppDialog from '../Components/AppDialog.vue'
// Глобальная очередь диалогов
export const dialogQueue = ref([])
let idCounter = 0
// Вызывается при клике на кнопку / Esc / клик по маске
export function closeDialog(id, confirmed = false) {
const dialog = dialogQueue.value.find(d => d.id === id)
if (dialog && dialog.show) {
dialog.show = false // Запускает leave-анимацию
dialog.resolve(confirmed) // Резолвим промис сразу для лучшего UX
}
}
// Вызывается после завершения leave-анимации
export function cleanupDialog(id) {
dialogQueue.value = dialogQueue.value.filter(d => d.id !== id)
}
export function useAppDialog({title, content, positiveProps, negativeProps, positiveText = 'Подтвердить', negativeText = 'Отмена', maskClosable = false, onConfirm } = {}) {
return new Promise((resolve) => {
const id = idCounter++
// 1. Добавляем скрытым, чтобы сработала enter-анимация
dialogQueue.value.push({
id, show: false, title, content, loading: false, onConfirm,
positiveText, negativeText, positiveProps, negativeProps, maskClosable, resolve
})
// 2. Переключаем в visible на следующем тике
nextTick(() => {
const dialog = dialogQueue.value.find(d => d.id === id)
if (dialog) dialog.show = true
})
})
}

View File

@@ -1,99 +1,85 @@
<script setup>
import {NLayout, NLayoutSider, NConfigProvider, NLayoutHeader, ruRU, dateRuRU, darkTheme, NEl, NAlert, NSpin, NText} from "naive-ui";
import SideMenu from "./Components/SideMenu.vue";
import {NLayout, NLayoutHeader, NEl, NAlert, NSpin, NText} from "naive-ui";
import AppHeader from "./Components/AppHeader.vue";
import {useGlobalLoading} from "../Composables/useGlobalLoading.js";
import AppDialogManager from "../Components/AppDialogManager.vue";
const {isGlobalLoading} = useGlobalLoading()
const themeOverrides = {
Modal: {
peers: {
Dialog: {
borderRadius: '8px'
},
Card: {
borderRadius: '8px'
},
}
}
}
</script>
<template>
<NConfigProvider :theme="darkTheme" :theme-overrides="themeOverrides" :locale="ruRU" :date-locale="dateRuRU">
<NLayout position="absolute">
<AppDialogManager />
<NLayout position="absolute">
<NLayoutHeader style="height: 48px;" bordered>
<AppHeader>
<template #headerExtra>
<slot name="headerExtra" />
</template>
<NLayoutHeader style="height: 48px;" bordered>
<AppHeader>
<template #headerExtra>
<slot name="headerExtra" />
</template>
<template #headerSuffix>
<slot name="headerSuffix" />
</template>
</AppHeader>
</NLayoutHeader>
<template #headerSuffix>
<slot name="headerSuffix" />
</template>
</AppHeader>
</NLayoutHeader>
<Transition name="wait-alert">
<NEl
v-if="isGlobalLoading"
class="wait-layer pointer-events-none fixed inset-x-0 top-[56px] z-[2600] flex justify-center px-3"
>
<NAlert
type="info"
:show-icon="false"
class="wait-banner w-full max-w-md"
style="
<Transition name="wait-alert">
<NEl
v-if="isGlobalLoading"
class="wait-layer pointer-events-none fixed inset-x-0 top-[56px] z-[2600] flex justify-center px-3"
>
<NAlert
type="info"
:show-icon="false"
class="wait-banner w-full max-w-md"
style="
--n-color: color-mix(in oklch, var(--info-color) 12%, transparent);
--n-border: 1px solid color-mix(in oklch, var(--info-color) 36%, transparent);
"
>
<div class="flex items-center justify-center gap-2.5 text-[13px] leading-none">
<NSpin :size="14" />
<NText depth="1" class="tracking-[0.1px]">Подождите, загружаем данные</NText>
</div>
</NAlert>
</NEl>
</Transition>
<NEl class="pointer-events-none fixed inset-x-0 top-12 bottom-0 overflow-hidden z-0">
<div class="absolute inset-x-0 top-[-12rem] mx-auto h-80 w-80 rounded-full blur-3xl"
style="background: color-mix(in oklch, var(--primary-color) 22%, transparent);" />
<div class="absolute right-[-8rem] top-48 h-72 w-72 rounded-full blur-3xl"
style="background: color-mix(in oklch, var(--warning-color) 28%, transparent);" />
<div class="absolute left-[-10rem] bottom-10 h-80 w-80 rounded-full blur-3xl"
style="background: color-mix(in oklch, var(--info-color) 24%, transparent);" />
<div class="grid-overlay absolute inset-0 opacity-65" />
</NEl>
<NLayout
has-sider
position="absolute"
class="top-12! bottom-0! relative overflow-hidden z-10"
content-class="relative z-10"
:native-scrollbar="false"
style="--n-color: transparent;"
>
<!-- <NLayoutSider-->
<!-- :native-scrollbar="false"-->
<!-- width="290"-->
<!-- class="h-[100vh-48px]!"-->
<!-- >-->
<!-- <SideMenu />-->
<!-- </NLayoutSider>-->
<NLayout style="--n-color: transparent;">
<div>
<slot name="header" />
>
<div class="flex items-center justify-center gap-2.5 text-[13px] leading-none">
<NSpin :size="14" />
<NText depth="1" class="tracking-[0.1px]">Подождите, загружаем данные</NText>
</div>
<slot />
</NLayout>
</NLayout>
</NAlert>
</NEl>
</Transition>
<NEl class="pointer-events-none fixed inset-x-0 top-12 bottom-0 overflow-hidden z-0">
<div class="absolute inset-x-0 top-[-12rem] mx-auto h-80 w-80 rounded-full blur-3xl"
style="background: color-mix(in oklch, var(--primary-color) 22%, transparent);" />
<div class="absolute right-[-8rem] top-48 h-72 w-72 rounded-full blur-3xl"
style="background: color-mix(in oklch, var(--warning-color) 28%, transparent);" />
<div class="absolute left-[-10rem] bottom-10 h-80 w-80 rounded-full blur-3xl"
style="background: color-mix(in oklch, var(--info-color) 24%, transparent);" />
<div class="grid-overlay absolute inset-0 opacity-65" />
</NEl>
<NLayout
has-sider
position="absolute"
class="top-12! bottom-0! relative overflow-hidden z-10"
content-class="relative z-10"
:native-scrollbar="false"
style="--n-color: transparent;"
>
<!-- <NLayoutSider-->
<!-- :native-scrollbar="false"-->
<!-- width="290"-->
<!-- class="h-[100vh-48px]!"-->
<!-- >-->
<!-- <SideMenu />-->
<!-- </NLayoutSider>-->
<NLayout style="--n-color: transparent;">
<div>
<slot name="header" />
</div>
<slot />
</NLayout>
</NLayout>
</NConfigProvider>
</NLayout>
</template>
<style scoped>

View File

@@ -0,0 +1,42 @@
<script setup>
import {NFlex, NButton} from 'naive-ui'
import {TbPencil, TbTrash} from 'vue-icons-plus/tb'
import {computed} from "vue";
const props = defineProps({
row: {
type: Object
}
})
const emits = defineEmits(['clickEdit', 'clickDelete'])
const onClickEdit = () => {
emits('clickEdit', props.row.id)
}
const onClickDelete = () => {
emits('clickDelete', props.row.id)
}
const isMisType = computed(() => props.row.source_type === 'mis')
const isManualType = computed(() => props.row.source_type === 'manual')
</script>
<template>
<NFlex align="center" justify="end">
<NButton v-if="isManualType" type="error" secondary size="tiny" @click="onClickDelete">
<template #icon>
<TbTrash />
</template>
Удалить
</NButton>
<NButton secondary size="tiny" @click="onClickEdit">
<template #icon>
<TbPencil />
</template>
Редактировать
</NButton>
</NFlex>
</template>
<style scoped>
</style>

View File

@@ -160,10 +160,10 @@ const onChangeSearch = (historyId) => {
form.value.urgency_id = res.data.urgency_id
form.value.visit_result_id = res.data.visit_result_id
form.value.birth_date = res.data.birth_date ? format(new Date(res.data.birth_date), 'yyyy-MM-dd HH:mm:ss') : null
form.value.death_date = res.data.death_date ? format(new Date(res.data.death_date), 'yyyy-MM-dd HH:mm:ss') : null
form.value.extract_date = res.data.extract_date ? format(new Date(res.data.extract_date), 'yyyy-MM-dd HH:mm:ss') : null
form.value.recipient_date = res.data.recipient_date ? format(new Date(res.data.recipient_date), 'yyyy-MM-dd HH:mm:ss') : null
form.value.birth_date = res.data.birth_date
form.value.death_date = res.data.death_date
form.value.extract_date = res.data.extract_date
form.value.recipient_date = res.data.recipient_date
})
}

View File

@@ -28,6 +28,7 @@ const props = defineProps({
const form = ref({
patient_source: 'mis',
original_id: null,
patient_id: null,
full_name: '',
urgency_id: 1,
@@ -128,7 +129,9 @@ const fetchPatient = async (historyId) => {
loading.value = true
await axios.get(`/api/nurse/patients/${historyId}`)
.then(res => {
form.value.patient_source = res.data.source_type
form.value.patient_id = historyId
form.value.original_id = res.data.original_id
form.value.full_name = res.data.full_name
form.value.urgency_id = res.data.urgency_id
form.value.visit_result_id = res.data.visit_result_id

View File

@@ -5,12 +5,14 @@ import AppContainer from "../../../Components/AppContainer.vue";
import AppPanel from "../../../Components/AppPanel.vue";
import DatePickerQuery from "../../../Components/DatePickerQuery.vue";
import UrgencyBadge from "../../../Components/UrgencyBadge.vue";
import {h, ref, shallowRef} from "vue"
import {h, onMounted, ref, shallowRef} from "vue"
import {TbCirclePlus, TbPencil} from 'vue-icons-plus/tb'
import {useAuthStore} from "../../../Stores/auth.js";
import AddMedicalHistoryModal from "../Components/AddMedicalHistoryModal.vue";
import EditMedicalHistoryModal from "../Components/EditMedicalHistoryModal.vue";
import {router} from "@inertiajs/vue3";
import ActionsColumnDataTable from "../Components/ActionsColumnDataTable.vue";
import {useAppDialog} from "../../../Composables/useAppDialog.js";
const props = defineProps({
inDepartmentHistories: {
@@ -44,6 +46,7 @@ const showEditMedicalHistoryModal = shallowRef(false)
const editHistoryId = ref(null)
const authStore = useAuthStore()
const userDepartment = authStore.userDepartment
const loading = ref(false)
const columns = [
{
@@ -74,8 +77,13 @@ const columns = [
align: 'end',
render: (row) => {
return h(
NButton, { size: 'tiny', type: 'default', secondary: true, onClick: () => onClickEditButton(row.id) },
{ default: () => 'Редактировать', icon: () => h(TbPencil, { size: '18px' }) })
ActionsColumnDataTable,
{
row: row,
onClickDelete: (historyId) => onClickDeleteButton(historyId),
onClickEdit: (historyId) => onClickEditButton(historyId),
}
)
}
}
]
@@ -85,6 +93,32 @@ const onClickEditButton = (historyId) => {
editHistoryId.value = historyId
}
const onClickDeleteButton = async (historyId) => {
const confirmed = await useAppDialog({
title: 'Удалить историю?',
content: 'Это действие необратимо',
onConfirm: async () => {
await axios.delete(`/api/nurse/patients/${historyId}`)
}
})
if (confirmed) {
loading.value = true
router.reload({
only: [
'inDepartmentHistories',
'recipientHistories',
'dischargedHistories',
'deceasedHistories',
'transferredHistories'
],
onSuccess: () => {
loading.value = false
}
})
}
}
const submit = () => {
router.post('/nurse/report/save', {}, {
onSuccess: () => {
@@ -111,7 +145,7 @@ const formattedLabel = (word, count) => {
</AppPanel>
<AppPanel header="Пациенты в отделении" header-include-body>
<template #header-extra>
<NButton secondary @click="showAddMedicalHistoryModal = true">
<NButton secondary :loading="loading" @click="showAddMedicalHistoryModal = true">
<template #icon>
<TbCirclePlus />
</template>
@@ -127,6 +161,7 @@ const formattedLabel = (word, count) => {
table-layout="fixed"
max-height="calc(100vh - 435px)"
min-height="calc(100vh - 435px)"
:loading="loading"
/>
</NTabPane>
<NTabPane name="income" :tab="formattedLabel('Поступившие', recipientHistories.length)">
@@ -135,6 +170,7 @@ const formattedLabel = (word, count) => {
table-layout="fixed"
max-height="calc(100vh - 435px)"
min-height="calc(100vh - 435px)"
:loading="loading"
/>
</NTabPane>
<NTabPane name="outcome" :tab="formattedLabel('Выписанные', dischargedHistories.length)">
@@ -143,6 +179,7 @@ const formattedLabel = (word, count) => {
table-layout="fixed"
max-height="calc(100vh - 435px)"
min-height="calc(100vh - 435px)"
:loading="loading"
/>
</NTabPane>
<NTabPane name="dead" :tab="formattedLabel('Умершие', deceasedHistories.length)">
@@ -151,6 +188,7 @@ const formattedLabel = (word, count) => {
table-layout="fixed"
max-height="calc(100vh - 435px)"
min-height="calc(100vh - 435px)"
:loading="loading"
/>
</NTabPane>
<NTabPane name="transfer" :tab="formattedLabel('Переведенные', transferredHistories.length)">
@@ -159,11 +197,12 @@ const formattedLabel = (word, count) => {
table-layout="fixed"
max-height="calc(100vh - 435px)"
min-height="calc(100vh - 435px)"
:loading="loading"
/>
</NTabPane>
</NTabs>
</AppPanel>
<NButton secondary size="large" @click="submit">
<NButton secondary size="large" @click="submit" :loading="loading">
Сохранить отчет
</NButton>
</AppContainer>

View File

@@ -6,6 +6,7 @@ import * as Sentry from "@sentry/vue";
import {startGlobalLoading, stopGlobalLoading} from "./Composables/useGlobalLoading.js";
import './bootstrap';
import '../css/app.css';
import {NConfigProvider, NDialogProvider, NMessageProvider, ruRU, dateRuRU, darkTheme} from "naive-ui";
router.on('start', () => {
startGlobalLoading()
@@ -23,6 +24,21 @@ router.on('exception', () => {
stopGlobalLoading()
})
const themeOverrides = {
Modal: {
peers: {
Dialog: {
borderRadius: '8px'
},
Card: {
borderRadius: '8px'
},
}
},
Dialog: {
borderRadius: '8px'
},
}
createInertiaApp({
id: 'onboard',
@@ -31,8 +47,19 @@ createInertiaApp({
return pages[`./Pages/${name}.vue`]
},
setup({el, App, props, plugin}) {
const vueApp = createApp({
render: () => h(App, props)
const vueApp = createApp({
render: () => h(NConfigProvider, {
theme: darkTheme,
themeOverrides: themeOverrides,
locale: ruRU,
dateLocale: dateRuRU
}, {
default: () => h(NDialogProvider, {}, {
default: () => h(NMessageProvider, {}, {
default: () => h(App, props)
})
})
})
})
const pinia = createPinia()