Files
econom-calculator/resources/js/pages/medical-reports/Builder.vue
brusnitsyn 3edc8e667e
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
first commit
2026-04-03 17:20:05 +09:00

615 lines
36 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3';
import { 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,
MedicalReportBuilderDepartment,
MedicalReportBuilderSection,
MedicalReportBuilderTemplate,
MedicalReportStructuredEntry,
} from '@/types';
type Props = {
report: MedicalReport;
selectedDepartment?: string | null;
departments: MedicalReportBuilderDepartment[];
builder?: {
department: {
key: string;
name: string;
profile_name?: string | null;
report_input_type?: string | null;
report_input_type_label?: string | null;
sources?: Array<{
name: string;
path: string;
extension: string;
}>;
};
template: MedicalReportBuilderTemplate;
uses_database_template: boolean;
starter_source: string;
} | null;
};
const props = defineProps<Props>();
const template = ref<MedicalReportBuilderTemplate | null>(null);
watch(
() => props.builder?.template,
(value) => {
template.value = value
? JSON.parse(JSON.stringify(value))
: null;
},
{ immediate: true },
);
const addSection = (): void => {
if (!template.value) {
return;
}
template.value.schema.sections.push({
key: `section_${template.value.schema.sections.length + 1}`,
title: `Секция ${template.value.schema.sections.length + 1}`,
economist_label: `${template.value.name} / Секция ${template.value.schema.sections.length + 1}`,
fields: [
{ key: 'name', label: 'Показатель', type: 'text' },
{ key: 'value', label: 'Значение', type: 'number' },
],
export_metrics: [{ key: 'value_total', label: 'Значение', source_field: 'value', aggregation: 'sum' }],
default_entries: [{ name: '', value: '0' }],
});
};
const removeSection = (index: number): void => {
template.value?.schema.sections.splice(index, 1);
};
const addField = (section: MedicalReportBuilderSection): void => {
const key = `field_${section.fields.length + 1}`;
section.fields.push({
key,
label: `Поле ${section.fields.length + 1}`,
type: 'text',
});
section.default_entries = section.default_entries.map((entry) => ({
...entry,
[key]: '',
}));
};
const removeField = (section: MedicalReportBuilderSection, fieldIndex: number): void => {
const [field] = section.fields.splice(fieldIndex, 1);
if (!field) {
return;
}
section.default_entries = section.default_entries.map((entry) => {
const nextEntry = { ...entry };
delete nextEntry[field.key];
return nextEntry;
});
section.export_metrics = section.export_metrics.filter((metric) => metric.source_field !== field.key);
};
const addDefaultEntry = (section: MedicalReportBuilderSection): void => {
const entry = section.fields.reduce(
(carry, field) => {
carry[field.key] = field.type === 'number' ? '0' : '';
return carry;
},
{} as MedicalReportStructuredEntry,
);
section.default_entries.push(entry);
};
const removeDefaultEntry = (section: MedicalReportBuilderSection, index: number): void => {
section.default_entries.splice(index, 1);
};
const addExportMetric = (section: MedicalReportBuilderSection): void => {
const numericField = section.fields.find((field) => field.type === 'number');
const hasDepartmentSelect = section.fields.some((field) => field.type === 'department-select');
section.export_metrics.push({
key: `metric_${section.export_metrics.length + 1}`,
label: `Метрика ${section.export_metrics.length + 1}`,
source_field: numericField?.key ?? '',
aggregation: 'sum',
analysis_column: '',
row_mode: hasDepartmentSelect ? 'entry_department' : 'fixed_unit',
target_unit_slug: '',
});
};
const removeExportMetric = (section: MedicalReportBuilderSection, index: number): void => {
section.export_metrics.splice(index, 1);
};
const numericFields = (section: MedicalReportBuilderSection) => {
return section.fields.filter((field) => field.type === 'number');
};
</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-sky-50 p-6 shadow-sm">
<div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div class="space-y-3">
<div class="inline-flex w-fit rounded-full bg-sky-100 px-3 py-1 text-xs font-semibold tracking-[0.18em] text-sky-700 uppercase">
Конструктор форм
</div>
<Heading
title="Уникальные шаблоны отчетов отделений"
description="Здесь настраивается структура формы: секции, поля и строки по умолчанию. Именно эта схема будет использоваться отделением при вводе отчета."
/>
</div>
<div class="flex flex-wrap gap-2">
<Link
:href="MedicalReportController.show({ 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.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>
<div class="rounded-full border border-slate-950 bg-slate-950 px-4 py-2 text-sm text-white">
Конструктор
</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.builder(
{ medicalReport: report.id },
{ query: { department: department.key } },
)
"
class="block rounded-2xl border px-4 py-3 transition-colors"
:class="
selectedDepartment === department.key
? 'border-sky-300 bg-sky-50 text-sky-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">
<span :class="department.has_builder_template ? 'text-sky-600' : 'text-amber-600'">
{{ department.has_builder_template ? 'Настроено в конструкторе' : 'Используется автосхема' }}
</span>
</div>
</Link>
</div>
</aside>
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm">
<div v-if="!builder || !template" class="rounded-2xl bg-slate-50 p-6 text-sm text-slate-500">
Отделение не выбрано.
</div>
<Form
v-else
v-bind="MedicalReportController.upsertBuilder.form({ medicalReport: report.id })"
class="space-y-6"
v-slot="{ errors, processing, recentlySuccessful }"
>
<input type="hidden" name="department" :value="builder.department.key" />
<div class="flex flex-col gap-4 border-b border-slate-100 pb-5 md:flex-row md:items-start 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">
{{ builder.department.name }}
</h1>
<p class="mt-1 text-sm text-slate-500">
{{
builder.uses_database_template
? 'Форма уже сохраняется из конструктора.'
: builder.starter_source === 'blueprint'
? 'Сейчас показан стартовый шаблон по типу ввода этого подразделения.'
: 'Сейчас показан стартовый шаблон, собранный из существующей схемы.'
}}
</p>
</div>
<div class="flex flex-wrap gap-2">
<div class="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
Источников: {{ builder.department.sources?.length ?? 0 }}
</div>
<div
v-if="builder.department.report_input_type_label"
class="rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-700"
>
{{ builder.department.report_input_type_label }}
</div>
</div>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="rounded-2xl border border-slate-200 bg-slate-50 p-4">
<Label for="builder-name">Название шаблона</Label>
<Input id="builder-name" v-model="template.name" name="name" class="mt-3" />
<InputError class="mt-2" :message="errors.name" />
</div>
<div class="rounded-2xl border border-slate-200 bg-slate-50 p-4">
<Label for="schema-title">Заголовок формы</Label>
<Input id="schema-title" v-model="template.schema.title" name="schema[title]" class="mt-3" />
<InputError class="mt-2" :message="errors['schema.title']" />
</div>
</div>
<div class="rounded-2xl border border-slate-200 bg-slate-50 p-4">
<Label for="builder-description">Описание</Label>
<textarea
id="builder-description"
v-model="template.description"
name="description"
class="mt-3 min-h-24 w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 outline-none ring-0"
/>
<input type="hidden" name="schema[description]" :value="template.description" />
<InputError class="mt-2" :message="errors.description || errors['schema.description']" />
</div>
<div class="space-y-4">
<div
v-for="(section, sectionIndex) in template.schema.sections"
:key="`${section.key}-${sectionIndex}`"
class="rounded-3xl border border-slate-200 bg-slate-50/80 p-4"
>
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div class="text-lg font-semibold text-slate-900">
Секция {{ sectionIndex + 1 }}
</div>
<Button type="button" variant="outline" size="sm" :disabled="template.schema.sections.length === 1" @click="removeSection(sectionIndex)">
Удалить секцию
</Button>
</div>
<div class="grid gap-4 md:grid-cols-3">
<div class="rounded-2xl border border-white bg-white p-4">
<Label :for="`section-key-${sectionIndex}`">Ключ</Label>
<Input :id="`section-key-${sectionIndex}`" v-model="section.key" :name="`schema[sections][${sectionIndex}][key]`" class="mt-3" />
<InputError class="mt-2" :message="errors[`schema.sections.${sectionIndex}.key`]" />
</div>
<div class="rounded-2xl border border-white bg-white p-4">
<Label :for="`section-title-${sectionIndex}`">Название секции</Label>
<Input :id="`section-title-${sectionIndex}`" v-model="section.title" :name="`schema[sections][${sectionIndex}][title]`" class="mt-3" />
<InputError class="mt-2" :message="errors[`schema.sections.${sectionIndex}.title`]" />
</div>
<div class="rounded-2xl border border-white bg-white p-4">
<Label :for="`section-economist-${sectionIndex}`">Заголовок для экономистов</Label>
<Input :id="`section-economist-${sectionIndex}`" v-model="section.economist_label" :name="`schema[sections][${sectionIndex}][economist_label]`" class="mt-3" />
<InputError class="mt-2" :message="errors[`schema.sections.${sectionIndex}.economist_label`]" />
</div>
</div>
<div class="mt-4 space-y-3">
<div class="flex items-center justify-between">
<div class="text-sm font-semibold text-slate-900">Поля секции</div>
<Button type="button" variant="outline" size="sm" @click="addField(section)">
Добавить поле
</Button>
</div>
<div
v-for="(field, fieldIndex) in section.fields"
:key="`${section.key}-${field.key}-${fieldIndex}`"
class="grid gap-4 rounded-2xl border border-slate-200 bg-white p-4 md:grid-cols-[1fr_1fr_220px_auto]"
>
<div>
<Label :for="`field-key-${sectionIndex}-${fieldIndex}`">Ключ поля</Label>
<Input :id="`field-key-${sectionIndex}-${fieldIndex}`" v-model="field.key" :name="`schema[sections][${sectionIndex}][fields][${fieldIndex}][key]`" class="mt-3" />
<InputError class="mt-2" :message="errors[`schema.sections.${sectionIndex}.fields.${fieldIndex}.key`]" />
</div>
<div>
<Label :for="`field-label-${sectionIndex}-${fieldIndex}`">Подпись</Label>
<Input :id="`field-label-${sectionIndex}-${fieldIndex}`" v-model="field.label" :name="`schema[sections][${sectionIndex}][fields][${fieldIndex}][label]`" class="mt-3" />
<InputError class="mt-2" :message="errors[`schema.sections.${sectionIndex}.fields.${fieldIndex}.label`]" />
</div>
<div>
<Label>Тип</Label>
<input type="hidden" :name="`schema[sections][${sectionIndex}][fields][${fieldIndex}][type]`" :value="field.type" />
<Select v-model="field.type">
<SelectTrigger class="mt-3 w-full">
<SelectValue placeholder="Тип поля" />
</SelectTrigger>
<SelectContent>
<SelectItem value="text">Текст</SelectItem>
<SelectItem value="number">Число</SelectItem>
<SelectItem value="department-select">Выбор отделения</SelectItem>
</SelectContent>
</Select>
<InputError class="mt-2" :message="errors[`schema.sections.${sectionIndex}.fields.${fieldIndex}.type`]" />
</div>
<div class="flex items-end">
<Button type="button" variant="outline" size="sm" :disabled="section.fields.length === 1" @click="removeField(section, fieldIndex)">
Удалить
</Button>
</div>
</div>
</div>
<div class="mt-6 space-y-3">
<div class="flex items-center justify-between">
<div class="text-sm font-semibold text-slate-900">Экспорт для экономистов</div>
<Button type="button" variant="outline" size="sm" @click="addExportMetric(section)">
Добавить метрику
</Button>
</div>
<div
v-for="(metric, metricIndex) in section.export_metrics"
:key="`${section.key}-metric-${metricIndex}`"
class="grid gap-4 rounded-2xl border border-slate-200 bg-white p-4 md:grid-cols-2 xl:grid-cols-4"
>
<div>
<Label :for="`metric-key-${sectionIndex}-${metricIndex}`">Ключ</Label>
<Input
:id="`metric-key-${sectionIndex}-${metricIndex}`"
v-model="metric.key"
:name="`schema[sections][${sectionIndex}][export_metrics][${metricIndex}][key]`"
class="mt-3"
/>
<InputError class="mt-2" :message="errors[`schema.sections.${sectionIndex}.export_metrics.${metricIndex}.key`]" />
</div>
<div>
<Label :for="`metric-label-${sectionIndex}-${metricIndex}`">Подпись</Label>
<Input
:id="`metric-label-${sectionIndex}-${metricIndex}`"
v-model="metric.label"
:name="`schema[sections][${sectionIndex}][export_metrics][${metricIndex}][label]`"
class="mt-3"
/>
<InputError class="mt-2" :message="errors[`schema.sections.${sectionIndex}.export_metrics.${metricIndex}.label`]" />
</div>
<div>
<Label>Числовое поле</Label>
<input
type="hidden"
:name="`schema[sections][${sectionIndex}][export_metrics][${metricIndex}][source_field]`"
:value="metric.source_field"
/>
<Select v-model="metric.source_field">
<SelectTrigger class="mt-3 w-full">
<SelectValue placeholder="Поле для агрегации" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="field in numericFields(section)"
:key="`${section.key}-metric-field-${field.key}`"
:value="field.key"
>
{{ field.label }}
</SelectItem>
</SelectContent>
</Select>
<InputError class="mt-2" :message="errors[`schema.sections.${sectionIndex}.export_metrics.${metricIndex}.source_field`]" />
</div>
<div>
<Label>Агрегация</Label>
<input
type="hidden"
:name="`schema[sections][${sectionIndex}][export_metrics][${metricIndex}][aggregation]`"
:value="metric.aggregation"
/>
<Select v-model="metric.aggregation">
<SelectTrigger class="mt-3 w-full">
<SelectValue placeholder="Агрегация" />
</SelectTrigger>
<SelectContent>
<SelectItem value="sum">Сумма</SelectItem>
</SelectContent>
</Select>
<InputError class="mt-2" :message="errors[`schema.sections.${sectionIndex}.export_metrics.${metricIndex}.aggregation`]" />
</div>
<div>
<Label>Колонка анализа</Label>
<input
type="hidden"
:name="`schema[sections][${sectionIndex}][export_metrics][${metricIndex}][analysis_column]`"
:value="metric.analysis_column ?? ''"
/>
<Select v-model="metric.analysis_column">
<SelectTrigger class="mt-3 w-full">
<SelectValue placeholder="Колонка для экономистов" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="column in template.analysis_columns ?? []"
:key="`${section.key}-analysis-${column.key}`"
:value="column.key"
>
{{ column.coordinate }} · {{ column.label }}
</SelectItem>
</SelectContent>
</Select>
<InputError class="mt-2" :message="errors[`schema.sections.${sectionIndex}.export_metrics.${metricIndex}.analysis_column`]" />
</div>
<div>
<Label>Строка анализа</Label>
<input
type="hidden"
:name="`schema[sections][${sectionIndex}][export_metrics][${metricIndex}][row_mode]`"
:value="metric.row_mode ?? ''"
/>
<Select v-model="metric.row_mode">
<SelectTrigger class="mt-3 w-full">
<SelectValue placeholder="Куда писать" />
</SelectTrigger>
<SelectContent>
<SelectItem value="entry_department">Из выбранного отделения строки</SelectItem>
<SelectItem value="fixed_unit">В фиксированное подразделение</SelectItem>
</SelectContent>
</Select>
<InputError class="mt-2" :message="errors[`schema.sections.${sectionIndex}.export_metrics.${metricIndex}.row_mode`]" />
</div>
<div v-if="metric.row_mode === 'fixed_unit'">
<Label>Фиксированное подразделение</Label>
<input
type="hidden"
:name="`schema[sections][${sectionIndex}][export_metrics][${metricIndex}][target_unit_slug]`"
:value="metric.target_unit_slug ?? ''"
/>
<Select v-model="metric.target_unit_slug">
<SelectTrigger class="mt-3 w-full">
<SelectValue placeholder="Подразделение финансистов" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="unit in template.analysis_units ?? []"
:key="`${section.key}-unit-${unit.slug}`"
:value="unit.slug"
>
{{ unit.name }}
</SelectItem>
</SelectContent>
</Select>
<InputError class="mt-2" :message="errors[`schema.sections.${sectionIndex}.export_metrics.${metricIndex}.target_unit_slug`]" />
</div>
<div class="flex items-end">
<Button
type="button"
variant="outline"
size="sm"
:disabled="section.export_metrics.length === 1"
@click="removeExportMetric(section, metricIndex)"
>
Удалить
</Button>
</div>
</div>
</div>
<div class="mt-6 space-y-3">
<div class="flex items-center justify-between">
<div class="text-sm font-semibold text-slate-900">Значения по умолчанию</div>
<Button type="button" variant="outline" size="sm" @click="addDefaultEntry(section)">
Добавить строку
</Button>
</div>
<div
v-for="(entry, entryIndex) in section.default_entries"
:key="`${section.key}-default-${entryIndex}`"
class="rounded-2xl border border-slate-200 bg-white p-4"
>
<div class="mb-4 flex items-center justify-between gap-3">
<div class="text-sm font-semibold text-slate-900">
Строка по умолчанию {{ entryIndex + 1 }}
</div>
<Button type="button" variant="outline" size="sm" :disabled="section.default_entries.length === 1" @click="removeDefaultEntry(section, entryIndex)">
Удалить
</Button>
</div>
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div
v-for="field in section.fields"
:key="`${section.key}-${field.key}-default-${entryIndex}`"
class="rounded-2xl border border-slate-100 bg-slate-50 p-4"
>
<Label :for="`${section.key}-${field.key}-${entryIndex}`">
{{ field.label }}
</Label>
<input
v-if="field.type === 'department-select'"
type="hidden"
:name="`schema[sections][${sectionIndex}][default_entries][${entryIndex}][${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 template.department_options ?? []"
:key="option.id"
:value="String(option.id)"
>
{{ option.name }}
</SelectItem>
</SelectContent>
</Select>
<Input
v-else
:id="`${section.key}-${field.key}-${entryIndex}`"
v-model="entry[field.key]"
:name="`schema[sections][${sectionIndex}][default_entries][${entryIndex}][${field.key}]`"
class="mt-3"
/>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-4">
<Button type="button" variant="outline" @click="addSection">
Добавить секцию
</Button>
<Button :disabled="processing">
Сохранить шаблон
</Button>
<p v-if="recentlySuccessful" class="text-sm text-emerald-600">
Шаблон сохранен.
</p>
</div>
</Form>
</div>
</section>
</div>
</template>