649 lines
33 KiB
Vue
649 lines
33 KiB
Vue
<script setup lang="ts">
|
||
import { Form, Head, Link } from '@inertiajs/vue3';
|
||
import { computed, ref, watch } from 'vue';
|
||
import MedicalReportController from '@/actions/App/Http/Controllers/MedicalReportController';
|
||
import Heading from '@/components/Heading.vue';
|
||
import InputError from '@/components/InputError.vue';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Label } from '@/components/ui/label';
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from '@/components/ui/select';
|
||
import type {
|
||
MedicalReport,
|
||
MedicalReportDepartment,
|
||
MedicalReportSheet,
|
||
MedicalReportSheetField,
|
||
MedicalReportStructuredEntry,
|
||
MedicalReportStructuredSection,
|
||
MedicalReportStructuredTemplate,
|
||
MedicalReportSummary,
|
||
} from '@/types';
|
||
|
||
type Props = {
|
||
report: MedicalReport;
|
||
departments: MedicalReportDepartment[];
|
||
selectedDepartment?: string | null;
|
||
selectedSheet?: string | null;
|
||
sheets: MedicalReportSheet[];
|
||
structuredTemplate?: MedicalReportStructuredTemplate | null;
|
||
currentSheet?: {
|
||
key: string;
|
||
name: string;
|
||
fields: MedicalReportSheetField[];
|
||
} | null;
|
||
summary: MedicalReportSummary;
|
||
};
|
||
|
||
const props = defineProps<Props>();
|
||
|
||
const groupedFields = computed(() => {
|
||
return (props.currentSheet?.fields ?? []).reduce(
|
||
(groups, field) => {
|
||
const group = groups[field.row_label] ?? [];
|
||
group.push(field);
|
||
groups[field.row_label] = group;
|
||
|
||
return groups;
|
||
},
|
||
{} as Record<string, MedicalReportSheetField[]>,
|
||
);
|
||
});
|
||
|
||
const structuredEntries = ref<MedicalReportStructuredEntry[]>([]);
|
||
const structuredSectionEntries = ref<Record<string, MedicalReportStructuredEntry[]>>({});
|
||
|
||
watch(
|
||
() => props.structuredTemplate?.entries ?? [],
|
||
(entries) => {
|
||
structuredEntries.value = entries.map((entry) => ({ ...entry }));
|
||
},
|
||
{ immediate: true },
|
||
);
|
||
|
||
watch(
|
||
() => props.structuredTemplate?.sections ?? [],
|
||
(sections) => {
|
||
structuredSectionEntries.value = sections.reduce(
|
||
(entries, section) => {
|
||
entries[section.key] = section.entries.map((entry) => ({ ...entry }));
|
||
|
||
return entries;
|
||
},
|
||
{} as Record<string, MedicalReportStructuredEntry[]>,
|
||
);
|
||
},
|
||
{ immediate: true },
|
||
);
|
||
|
||
const addStructuredEntry = (): void => {
|
||
structuredEntries.value.push({ ...(props.structuredTemplate?.empty_entry ?? {}) });
|
||
};
|
||
|
||
const removeStructuredEntry = (index: number): void => {
|
||
structuredEntries.value.splice(index, 1);
|
||
};
|
||
|
||
const addStructuredSectionEntry = (section: MedicalReportStructuredSection): void => {
|
||
const entries = structuredSectionEntries.value[section.key] ?? [];
|
||
entries.push({ ...section.empty_entry });
|
||
structuredSectionEntries.value[section.key] = entries;
|
||
};
|
||
|
||
const removeStructuredSectionEntry = (sectionKey: string, index: number): void => {
|
||
structuredSectionEntries.value[sectionKey]?.splice(index, 1);
|
||
};
|
||
|
||
const structuredGridClass = (count: number): string => {
|
||
if (count >= 4) {
|
||
return 'md:grid-cols-2 xl:grid-cols-4';
|
||
}
|
||
|
||
if (count === 3) {
|
||
return 'md:grid-cols-3';
|
||
}
|
||
|
||
if (count === 2) {
|
||
return 'md:grid-cols-2';
|
||
}
|
||
|
||
return 'md:grid-cols-1';
|
||
};
|
||
</script>
|
||
|
||
<template>
|
||
<Head :title="report.name" />
|
||
|
||
<div class="flex flex-col gap-6 p-4 md:p-6">
|
||
<section
|
||
class="rounded-3xl border border-slate-200 bg-linear-to-br from-white via-slate-50 to-emerald-50 p-6 shadow-sm"
|
||
>
|
||
<div class="grid gap-6 xl:grid-cols-[1.3fr_1fr]">
|
||
<div class="space-y-3">
|
||
<div class="inline-flex w-fit rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold tracking-[0.18em] text-emerald-700 uppercase">
|
||
Отчет по отделениям
|
||
</div>
|
||
<Heading
|
||
title="Каждое отделение заполняет только свои показатели"
|
||
description="Отделения создаются только из реальных отчетов. Здесь они вводят свои показатели, а для экономистов формируется отдельный свод."
|
||
/>
|
||
|
||
<div class="flex flex-wrap gap-2">
|
||
<div class="rounded-full border border-slate-950 bg-slate-950 px-4 py-2 text-sm text-white">
|
||
Ввод отделений
|
||
</div>
|
||
<Link
|
||
:href="MedicalReportController.economists({ medicalReport: report.id })"
|
||
class="rounded-full border border-slate-200 bg-white px-4 py-2 text-sm text-slate-700 transition-colors hover:border-slate-300"
|
||
>
|
||
Свод экономистов
|
||
</Link>
|
||
<Link
|
||
:href="MedicalReportController.builder({ medicalReport: report.id })"
|
||
class="rounded-full border border-slate-200 bg-white px-4 py-2 text-sm text-slate-700 transition-colors hover:border-slate-300"
|
||
>
|
||
Конструктор форм
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid gap-3 sm:grid-cols-2">
|
||
<div class="rounded-2xl border border-slate-200 bg-white/80 p-4">
|
||
<div class="text-xs uppercase tracking-[0.16em] text-slate-500">
|
||
Отделений
|
||
</div>
|
||
<div class="mt-2 text-2xl font-semibold text-slate-900">
|
||
{{ summary.department_count }}
|
||
</div>
|
||
</div>
|
||
<div class="rounded-2xl border border-slate-200 bg-white/80 p-4">
|
||
<div class="text-xs uppercase tracking-[0.16em] text-slate-500">
|
||
Блоков ввода
|
||
</div>
|
||
<div class="mt-2 text-2xl font-semibold text-slate-900">
|
||
{{ summary.sheet_count }}
|
||
</div>
|
||
</div>
|
||
<div class="rounded-2xl border border-slate-200 bg-white/80 p-4">
|
||
<div class="text-xs uppercase tracking-[0.16em] text-slate-500">
|
||
Полей отделения
|
||
</div>
|
||
<div class="mt-2 text-2xl font-semibold text-slate-900">
|
||
{{ summary.editable_count }}
|
||
</div>
|
||
</div>
|
||
<div class="rounded-2xl border border-slate-200 bg-white/80 p-4">
|
||
<div class="text-xs uppercase tracking-[0.16em] text-slate-500">
|
||
Уже заполнено
|
||
</div>
|
||
<div class="mt-2 text-2xl font-semibold text-slate-900">
|
||
{{ summary.filled_count }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="grid gap-6 xl:grid-cols-[320px_1fr]">
|
||
<aside class="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm">
|
||
<div class="mb-4 text-sm font-semibold text-slate-900">
|
||
Отделения
|
||
</div>
|
||
|
||
<div class="space-y-2">
|
||
<Link
|
||
v-for="department in departments"
|
||
:key="department.key"
|
||
:href="
|
||
MedicalReportController.show(
|
||
{ medicalReport: report.id },
|
||
{ query: { department: department.key } },
|
||
)
|
||
"
|
||
class="block rounded-2xl border px-4 py-3 transition-colors"
|
||
:class="
|
||
selectedDepartment === department.key
|
||
? 'border-emerald-300 bg-emerald-50 text-emerald-900'
|
||
: 'border-slate-200 bg-slate-50 text-slate-700 hover:border-slate-300 hover:bg-white'
|
||
"
|
||
>
|
||
<div class="text-[11px] uppercase tracking-[0.16em] text-slate-400">
|
||
{{ department.profile_name ?? 'Без профиля' }}
|
||
</div>
|
||
<div class="font-medium">{{ department.name }}</div>
|
||
<div class="mt-1 text-xs text-slate-500">
|
||
{{ department.report_input_type_label ?? 'Тип ввода не задан' }}
|
||
</div>
|
||
<div class="mt-1 text-xs text-slate-500">
|
||
Заполнено полей: {{ department.filled_count }}
|
||
</div>
|
||
<div class="mt-1 text-xs">
|
||
<span
|
||
:class="
|
||
department.has_template
|
||
? 'text-emerald-600'
|
||
: 'text-amber-600'
|
||
"
|
||
>
|
||
{{
|
||
department.has_template
|
||
? 'Шаблон настроен'
|
||
: 'Шаблон в работе'
|
||
}}
|
||
</span>
|
||
</div>
|
||
</Link>
|
||
</div>
|
||
</aside>
|
||
|
||
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm">
|
||
<div
|
||
class="mb-6 flex flex-col gap-4 border-b border-slate-100 pb-5 md:flex-row md:items-end md:justify-between"
|
||
>
|
||
<div>
|
||
<div class="text-xs uppercase tracking-[0.16em] text-slate-500">
|
||
Отделение
|
||
</div>
|
||
<h1 class="mt-2 text-2xl font-semibold text-slate-950">
|
||
{{
|
||
departments.find(
|
||
(department) =>
|
||
department.key === selectedDepartment,
|
||
)?.name ?? 'Отделение не выбрано'
|
||
}}
|
||
</h1>
|
||
<div
|
||
v-if="departments.find((department) => department.key === selectedDepartment)?.report_input_type_label"
|
||
class="mt-2 inline-flex rounded-full bg-sky-100 px-3 py-1 text-xs font-semibold text-sky-700"
|
||
>
|
||
{{
|
||
departments.find(
|
||
(department) => department.key === selectedDepartment,
|
||
)?.report_input_type_label
|
||
}}
|
||
</div>
|
||
<p class="mt-1 text-sm text-slate-500">
|
||
Доступны только те блоки первой таблицы Excel, в которых у этого отделения есть свои показатели.
|
||
</p>
|
||
</div>
|
||
|
||
<div class="text-sm text-slate-500">
|
||
Обновлено:
|
||
{{
|
||
report.updated_at
|
||
? new Date(report.updated_at).toLocaleString('ru-RU')
|
||
: 'не сохранялось'
|
||
}}
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-if="!structuredTemplate && sheets.length === 0"
|
||
class="rounded-2xl bg-slate-50 p-6 text-sm text-slate-500"
|
||
>
|
||
<div>
|
||
Для выбранного отделения пока не найдено полей в первой таблице Excel.
|
||
</div>
|
||
<div class="mt-3">
|
||
Источники:
|
||
</div>
|
||
<ul class="mt-2 space-y-1 text-xs text-slate-600">
|
||
<li
|
||
v-for="source in departments.find((department) => department.key === selectedDepartment)?.sources ?? []"
|
||
:key="source.path"
|
||
>
|
||
{{ source.name }}
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<template v-else-if="structuredTemplate">
|
||
<div class="mb-6 rounded-3xl border border-emerald-200 bg-emerald-50/70 p-4">
|
||
<div class="text-sm font-semibold text-emerald-900">
|
||
{{ structuredTemplate.title }}
|
||
</div>
|
||
<div class="mt-1 text-sm text-emerald-700">
|
||
{{ structuredTemplate.description }}
|
||
</div>
|
||
</div>
|
||
|
||
<Form
|
||
v-bind="MedicalReportController.updateStructured.form({ medicalReport: report.id })"
|
||
class="space-y-6"
|
||
v-slot="{ errors, processing, recentlySuccessful }"
|
||
>
|
||
<input type="hidden" name="department" :value="selectedDepartment ?? ''" />
|
||
<input type="hidden" name="template_key" :value="structuredTemplate.key" />
|
||
|
||
<template v-if="structuredTemplate.sections?.length">
|
||
<div
|
||
v-for="section in structuredTemplate.sections"
|
||
:key="section.key"
|
||
class="space-y-4 rounded-3xl border border-slate-200 bg-slate-50/80 p-4"
|
||
>
|
||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||
<div>
|
||
<div class="text-lg font-semibold text-slate-900">
|
||
{{ section.title }}
|
||
</div>
|
||
<div class="mt-1 text-sm text-slate-500">
|
||
Экономистам уйдет только блок "Итого" по этой секции.
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||
<div
|
||
v-for="total in section.totals"
|
||
:key="`${section.key}-${total.key}`"
|
||
class="rounded-2xl border border-white bg-white px-4 py-3"
|
||
>
|
||
<div class="text-xs uppercase tracking-[0.16em] text-slate-500">
|
||
{{ total.label }}
|
||
</div>
|
||
<div class="mt-2 text-lg font-semibold text-slate-900">
|
||
{{ total.value || '0' }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="space-y-4">
|
||
<div
|
||
v-for="(entry, index) in structuredSectionEntries[section.key] ?? []"
|
||
:key="`${section.key}-${index}`"
|
||
class="rounded-3xl border border-slate-200 bg-white p-4"
|
||
>
|
||
<div class="mb-4 flex items-center justify-between gap-4">
|
||
<div class="text-sm font-semibold text-slate-900">
|
||
Строка {{ index + 1 }}
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
:disabled="(structuredSectionEntries[section.key] ?? []).length === 1"
|
||
@click="removeStructuredSectionEntry(section.key, index)"
|
||
>
|
||
Удалить
|
||
</Button>
|
||
</div>
|
||
|
||
<div
|
||
class="grid gap-4"
|
||
:class="structuredGridClass(section.fields.length)"
|
||
>
|
||
<div
|
||
v-for="field in section.fields"
|
||
:key="`${section.key}-${field.key}`"
|
||
class="rounded-2xl border border-slate-100 bg-slate-50 p-4"
|
||
>
|
||
<Label
|
||
v-if="field.type === 'department-select'"
|
||
class="text-sm leading-5 text-slate-800"
|
||
>
|
||
{{ field.label }}
|
||
</Label>
|
||
<Label
|
||
v-else
|
||
:for="`${section.key}-${field.key}-${index}`"
|
||
class="text-sm leading-5 text-slate-800"
|
||
>
|
||
{{ field.label }}
|
||
</Label>
|
||
|
||
<input
|
||
v-if="field.type === 'department-select'"
|
||
type="hidden"
|
||
:name="`sections[${section.key}][entries][${index}][${field.key}]`"
|
||
:value="entry[field.key] ?? ''"
|
||
/>
|
||
<Select
|
||
v-if="field.type === 'department-select'"
|
||
v-model="entry[field.key]"
|
||
>
|
||
<SelectTrigger class="mt-3 w-full">
|
||
<SelectValue :placeholder="`Выберите: ${field.label}`" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem
|
||
v-for="option in structuredTemplate.department_options"
|
||
:key="option.id"
|
||
:value="String(option.id)"
|
||
>
|
||
{{ option.name }}
|
||
</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<Input
|
||
v-else
|
||
:id="`${section.key}-${field.key}-${index}`"
|
||
v-model="entry[field.key]"
|
||
:name="`sections[${section.key}][entries][${index}][${field.key}]`"
|
||
class="mt-3"
|
||
/>
|
||
<InputError
|
||
class="mt-2"
|
||
:message="errors[`sections.${section.key}.entries.${index}.${field.key}`]"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<Button type="button" variant="outline" @click="addStructuredSectionEntry(section)">
|
||
Добавить строку в секцию
|
||
</Button>
|
||
</div>
|
||
</template>
|
||
|
||
<div v-else class="space-y-4">
|
||
<div
|
||
v-for="(entry, index) in structuredEntries"
|
||
:key="index"
|
||
class="rounded-3xl border border-slate-200 bg-slate-50/80 p-4"
|
||
>
|
||
<div class="mb-4 flex items-center justify-between gap-4">
|
||
<div class="text-sm font-semibold text-slate-900">
|
||
Строка {{ index + 1 }}
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
:disabled="structuredEntries.length === 1"
|
||
@click="removeStructuredEntry(index)"
|
||
>
|
||
Удалить
|
||
</Button>
|
||
</div>
|
||
|
||
<div
|
||
class="grid gap-4"
|
||
:class="structuredGridClass(structuredTemplate.fields?.length ?? 1)"
|
||
>
|
||
<div
|
||
v-for="field in structuredTemplate.fields ?? []"
|
||
:key="field.key"
|
||
class="rounded-2xl border border-white bg-white p-4"
|
||
>
|
||
<Label
|
||
v-if="field.type === 'department-select'"
|
||
class="text-sm leading-5 text-slate-800"
|
||
>
|
||
{{ field.label }}
|
||
</Label>
|
||
<Label
|
||
v-else
|
||
:for="`${field.key}-${index}`"
|
||
class="text-sm leading-5 text-slate-800"
|
||
>
|
||
{{ field.label }}
|
||
</Label>
|
||
|
||
<input
|
||
v-if="field.type === 'department-select'"
|
||
type="hidden"
|
||
:name="`entries[${index}][${field.key}]`"
|
||
:value="entry[field.key] ?? ''"
|
||
/>
|
||
<Select
|
||
v-if="field.type === 'department-select'"
|
||
v-model="entry[field.key]"
|
||
>
|
||
<SelectTrigger class="mt-3 w-full">
|
||
<SelectValue :placeholder="`Выберите: ${field.label}`" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem
|
||
v-for="option in structuredTemplate.department_options"
|
||
:key="option.id"
|
||
:value="String(option.id)"
|
||
>
|
||
{{ option.name }}
|
||
</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<Input
|
||
v-else
|
||
:id="`${field.key}-${index}`"
|
||
v-model="entry[field.key]"
|
||
:name="`entries[${index}][${field.key}]`"
|
||
class="mt-3"
|
||
/>
|
||
<InputError
|
||
class="mt-2"
|
||
:message="errors[`entries.${index}.${field.key}`]"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="structuredTemplate.totals?.length" class="grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
|
||
<div
|
||
v-for="total in structuredTemplate.totals"
|
||
:key="total.key"
|
||
class="rounded-2xl border border-slate-200 bg-white px-4 py-3"
|
||
>
|
||
<div class="text-xs uppercase tracking-[0.16em] text-slate-500">
|
||
{{ total.label }}
|
||
</div>
|
||
<div class="mt-2 text-lg font-semibold text-slate-900">
|
||
{{ total.value || '0' }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<Button type="button" variant="outline" @click="addStructuredEntry">
|
||
Добавить строку
|
||
</Button>
|
||
</div>
|
||
|
||
<div class="flex flex-wrap items-center gap-4">
|
||
<Button :disabled="processing">
|
||
Сохранить данные отделения
|
||
</Button>
|
||
<p v-if="recentlySuccessful" class="text-sm text-emerald-600">
|
||
Изменения сохранены.
|
||
</p>
|
||
</div>
|
||
</Form>
|
||
</template>
|
||
|
||
<template v-else>
|
||
<div class="mb-6 flex flex-wrap gap-2">
|
||
<Link
|
||
v-for="sheet in sheets"
|
||
:key="sheet.key"
|
||
:href="
|
||
MedicalReportController.show(
|
||
{ medicalReport: report.id },
|
||
{
|
||
query: {
|
||
department: selectedDepartment,
|
||
sheet: sheet.key,
|
||
},
|
||
},
|
||
)
|
||
"
|
||
class="rounded-full border px-4 py-2 text-sm transition-colors"
|
||
:class="
|
||
selectedSheet === sheet.key
|
||
? 'border-slate-950 bg-slate-950 text-white'
|
||
: 'border-slate-200 bg-slate-50 text-slate-700 hover:border-slate-300'
|
||
"
|
||
>
|
||
{{ sheet.name }}
|
||
<span class="ml-2 text-xs opacity-70">
|
||
{{ sheet.filled_count }}/{{ sheet.editable_count }}
|
||
</span>
|
||
</Link>
|
||
</div>
|
||
|
||
<Form
|
||
v-if="currentSheet"
|
||
v-bind="MedicalReportController.update.form({ medicalReport: report.id })"
|
||
class="space-y-6"
|
||
v-slot="{ errors, processing, recentlySuccessful }"
|
||
>
|
||
<input
|
||
type="hidden"
|
||
name="department"
|
||
:value="selectedDepartment ?? ''"
|
||
/>
|
||
<input type="hidden" name="sheet" :value="currentSheet.key" />
|
||
|
||
<div
|
||
v-for="(fields, groupTitle) in groupedFields"
|
||
:key="groupTitle"
|
||
class="rounded-3xl border border-slate-200 bg-slate-50/80 p-4"
|
||
>
|
||
<div class="mb-4 text-sm font-semibold text-slate-900">
|
||
{{ groupTitle }}
|
||
</div>
|
||
|
||
<div class="grid gap-4 md:grid-cols-2 2xl:grid-cols-3">
|
||
<div
|
||
v-for="field in fields"
|
||
:key="field.coordinate"
|
||
class="rounded-2xl border border-white bg-white p-4"
|
||
>
|
||
<Label :for="field.coordinate" class="text-sm leading-5 text-slate-800">
|
||
{{ field.column_label }}
|
||
</Label>
|
||
<div class="mt-1 text-xs text-slate-500">
|
||
{{ field.description }}
|
||
</div>
|
||
<Input
|
||
:id="field.coordinate"
|
||
:name="`values[${field.coordinate}]`"
|
||
:default-value="field.value"
|
||
class="mt-3"
|
||
/>
|
||
<InputError
|
||
class="mt-2"
|
||
:message="errors[`values.${field.coordinate}`]"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-4">
|
||
<Button :disabled="processing">
|
||
Сохранить данные отделения
|
||
</Button>
|
||
<p v-if="recentlySuccessful" class="text-sm text-emerald-600">
|
||
Изменения сохранены.
|
||
</p>
|
||
</div>
|
||
</Form>
|
||
</template>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</template>
|