first commit
Some checks failed
tests / ci (8.5) (push) Has been cancelled
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled

This commit is contained in:
brusnitsyn
2026-04-03 17:20:05 +09:00
commit 3edc8e667e
358 changed files with 39258 additions and 0 deletions

View File

@@ -0,0 +1,648 @@
<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>