Merge pull request #2 from ma1andes/add_filters

Generate filtering/sorting and searchable dropdowns
This commit is contained in:
2026-03-24 14:46:52 +03:00
committed by GitHub
37 changed files with 1367 additions and 411 deletions

View File

@@ -1449,9 +1449,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1466,9 +1463,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1483,9 +1477,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1500,9 +1491,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1517,9 +1505,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1534,9 +1519,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1551,9 +1533,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1568,9 +1547,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1585,9 +1561,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1602,9 +1575,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1619,9 +1589,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1636,9 +1603,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1653,9 +1617,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [

View File

@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"build": "vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},

View File

@@ -15,6 +15,22 @@ const httpClient = async (url: string, options: fetchUtils.Options = {}) => {
});
};
function buildQueryString(query: Record<string, unknown>) {
const search = new URLSearchParams();
Object.entries(query).forEach(([key, val]) => {
if (val === undefined || val === null || val === '') return;
if (Array.isArray(val)) {
val.forEach((v) => {
if (v === undefined || v === null || v === '') return;
search.append(key, String(v));
});
return;
}
search.set(key, String(val));
});
return search.toString();
}
const dataProvider: DataProvider = {
getList: async (resource, params) => {
const { page, perPage } = params.pagination!;
@@ -22,23 +38,15 @@ const dataProvider: DataProvider = {
const start = (page - 1) * perPage;
const end = page * perPage;
const query: Record<string, string> = {
_start: String(start),
_end: String(end),
const query: Record<string, unknown> = {
_start: start,
_end: end,
_sort: field,
_order: order,
...(params.filter ?? {}),
};
if (params.filter) {
Object.keys(params.filter).forEach((key) => {
const val = params.filter[key];
if (val !== undefined && val !== null && val !== '') {
query[key] = String(val);
}
});
}
const queryString = new URLSearchParams(query).toString();
const queryString = buildQueryString(query);
const url = `${apiUrl}/${resource}?${queryString}`;
const { json, headers } = await httpClient(url);
@@ -67,15 +75,16 @@ const dataProvider: DataProvider = {
const start = (page - 1) * perPage;
const end = page * perPage;
const query: Record<string, string> = {
_start: String(start),
_end: String(end),
const query: Record<string, unknown> = {
_start: start,
_end: end,
_sort: field,
_order: order,
[params.target]: String(params.id),
[params.target]: params.id,
...(params.filter ?? {}),
};
const queryString = new URLSearchParams(query).toString();
const queryString = buildQueryString(query);
const url = `${apiUrl}/${resource}?${queryString}`;
const { json, headers } = await httpClient(url);

View File

@@ -1,13 +1,14 @@
import { Create, SimpleForm, TextInput, NumberInput } from 'react-admin';
export const EquipmentTypeCreate = () => (
<Create>
<SimpleForm>
<TextInput source="code" label="Код" isRequired />
<TextInput source="name" label="Наименование" isRequired />
<TextInput source="code" label="Код вида оборудования" isRequired />
<TextInput source="name" label="Наименование вида" isRequired />
<TextInput source="manufacturer" label="Производитель" />
<NumberInput source="maintenanceIntervalHours" label="Периодичность ТО (ч)" />
<NumberInput source="overhaulIntervalHours" label="Периодичность КР (ч)" />
<NumberInput source="maintenanceIntervalHours" label="Периодичность ТО, моточасов" />
<NumberInput source="overhaulIntervalHours" label="Периодичность КР, моточасов" />
</SimpleForm>
</Create>
);

View File

@@ -1,13 +1,14 @@
import { Edit, SimpleForm, TextInput, NumberInput } from 'react-admin';
export const EquipmentTypeEdit = () => (
<Edit>
<SimpleForm>
<TextInput source="code" label="Код" disabled />
<TextInput source="name" label="Наименование" isRequired />
<TextInput source="code" label="Код вида оборудования" disabled />
<TextInput source="name" label="Наименование вида" isRequired />
<TextInput source="manufacturer" label="Производитель" />
<NumberInput source="maintenanceIntervalHours" label="Периодичность ТО (ч)" />
<NumberInput source="overhaulIntervalHours" label="Периодичность КР (ч)" />
<NumberInput source="maintenanceIntervalHours" label="Периодичность ТО, моточасов" />
<NumberInput source="overhaulIntervalHours" label="Периодичность КР, моточасов" />
</SimpleForm>
</Edit>
);

View File

@@ -1,13 +1,38 @@
import { List, Datagrid, TextField, NumberField } from 'react-admin';
import {
List,
Datagrid,
TextField,
TextInput,
TopToolbar,
FilterButton,
CreateButton,
ExportButton,
NumberField
} from 'react-admin';
const equipmentTypeFilters = [
<TextInput key="q" source="q" label="Поиск" alwaysOn />,
<TextInput key="name" source="name" label="Наименование вида" />,
<TextInput key="manufacturer" source="manufacturer" label="Производитель" />
];
const EquipmentTypeListActions = () => (
<TopToolbar>
<FilterButton filters={equipmentTypeFilters} />
<CreateButton />
<ExportButton />
</TopToolbar>
);
export const EquipmentTypeList = () => (
<List>
<List actions={<EquipmentTypeListActions />} filters={equipmentTypeFilters} sort={{ field: 'code', order: 'ASC' }}>
<Datagrid rowClick="show">
<TextField source="code" label="Код" />
<TextField source="name" label="Наименование" />
<TextField source="code" label="Код вида оборудования" />
<TextField source="name" label="Наименование вида" />
<TextField source="manufacturer" label="Производитель" />
<NumberField source="maintenanceIntervalHours" label="Периодичность ТО (ч)" />
<NumberField source="overhaulIntervalHours" label="Периодичность КР (ч)" />
<NumberField source="maintenanceIntervalHours" label="Периодичность ТО, моточасов" />
<NumberField source="overhaulIntervalHours" label="Периодичность КР, моточасов" />
</Datagrid>
</List>
);

View File

@@ -3,11 +3,11 @@ import { Show, SimpleShowLayout, TextField, NumberField } from 'react-admin';
export const EquipmentTypeShow = () => (
<Show>
<SimpleShowLayout>
<TextField source="code" label="Код" />
<TextField source="name" label="Наименование" />
<TextField source="code" label="Код вида оборудования" />
<TextField source="name" label="Наименование вида" />
<TextField source="manufacturer" label="Производитель" />
<NumberField source="maintenanceIntervalHours" label="Периодичность ТО (ч)" />
<NumberField source="overhaulIntervalHours" label="Периодичность КР (ч)" />
<NumberField source="maintenanceIntervalHours" label="Периодичность ТО, моточасов" />
<NumberField source="overhaulIntervalHours" label="Периодичность КР, моточасов" />
</SimpleShowLayout>
</Show>
);

View File

@@ -1,12 +1,4 @@
import {
Create,
SimpleForm,
TextInput,
NumberInput,
DateInput,
SelectInput,
ReferenceInput,
} from 'react-admin';
import { Create, SimpleForm, TextInput, NumberInput, DateInput, SelectInput, ReferenceInput, AutocompleteInput } from 'react-admin';
const statusChoices = [
{ id: 'Active', name: 'В эксплуатации' },
@@ -19,18 +11,18 @@ export const EquipmentCreate = () => (
<Create>
<SimpleForm>
<TextInput source="inventoryNumber" label="Инвентарный номер" isRequired />
<TextInput source="serialNumber" label="Заводской номер" />
<TextInput source="name" label="Наименование" isRequired />
<ReferenceInput source="equipmentTypeCode" reference="equipment-types" label="Вид оборудования">
<SelectInput optionText="name" optionValue="code" isRequired />
<TextInput source="serialNumber" label="Заводской (серийный) номер" />
<TextInput source="name" label="Наименование единицы оборудования" isRequired />
<ReferenceInput source="equipmentTypeCode" reference="equipment-types">
<AutocompleteInput label="Вид оборудования" optionText={(record) => record.code ? `${record.code}${record.name ?? record.code}` : (record.name ?? record.id)} filterToQuery={(searchText) => ({ q: searchText })} />
</ReferenceInput>
<SelectInput source="status" label="Статус" choices={statusChoices} defaultValue="Active" />
<TextInput source="location" label="Место эксплуатации" />
<SelectInput source="status" label="Текущий статус" choices={statusChoices} emptyText="Не выбрано" />
<TextInput source="location" label="Место эксплуатации / скважина / куст" />
<DateInput source="commissionedAt" label="Дата ввода в эксплуатацию" />
<NumberInput source="totalEngineHours" label="Общая наработка (ч)" />
<NumberInput source="engineHoursSinceLastRepair" label="Наработка с последнего ремонта (ч)" />
<NumberInput source="totalEngineHours" label="Общая наработка, моточасов" />
<NumberInput source="engineHoursSinceLastRepair" label="Наработка с последнего ремонта, моточасов" />
<DateInput source="lastRepairAt" label="Дата последнего ремонта" />
<TextInput source="notes" label="Примечания" multiline />
<TextInput source="notes" label="Примечания" />
</SimpleForm>
</Create>
);

View File

@@ -1,12 +1,4 @@
import {
Edit,
SimpleForm,
TextInput,
NumberInput,
DateInput,
SelectInput,
ReferenceInput,
} from 'react-admin';
import { Edit, SimpleForm, TextInput, NumberInput, DateInput, SelectInput, ReferenceInput, AutocompleteInput } from 'react-admin';
const statusChoices = [
{ id: 'Active', name: 'В эксплуатации' },
@@ -18,19 +10,20 @@ const statusChoices = [
export const EquipmentEdit = () => (
<Edit>
<SimpleForm>
<TextInput source="id" label="id" disabled />
<TextInput source="inventoryNumber" label="Инвентарный номер" isRequired />
<TextInput source="serialNumber" label="Заводской номер" />
<TextInput source="name" label="Наименование" isRequired />
<ReferenceInput source="equipmentTypeCode" reference="equipment-types" label="Вид оборудования">
<SelectInput optionText="name" optionValue="code" isRequired />
<TextInput source="serialNumber" label="Заводской (серийный) номер" />
<TextInput source="name" label="Наименование единицы оборудования" isRequired />
<ReferenceInput source="equipmentTypeCode" reference="equipment-types">
<AutocompleteInput label="Вид оборудования" optionText={(record) => record.code ? `${record.code}${record.name ?? record.code}` : (record.name ?? record.id)} filterToQuery={(searchText) => ({ q: searchText })} />
</ReferenceInput>
<SelectInput source="status" label="Статус" choices={statusChoices} />
<TextInput source="location" label="Место эксплуатации" />
<SelectInput source="status" label="Текущий статус" choices={statusChoices} emptyText="Не выбрано" />
<TextInput source="location" label="Место эксплуатации / скважина / куст" />
<DateInput source="commissionedAt" label="Дата ввода в эксплуатацию" />
<NumberInput source="totalEngineHours" label="Общая наработка (ч)" />
<NumberInput source="engineHoursSinceLastRepair" label="Наработка с последнего ремонта (ч)" />
<NumberInput source="totalEngineHours" label="Общая наработка, моточасов" />
<NumberInput source="engineHoursSinceLastRepair" label="Наработка с последнего ремонта, моточасов" />
<DateInput source="lastRepairAt" label="Дата последнего ремонта" />
<TextInput source="notes" label="Примечания" multiline />
<TextInput source="notes" label="Примечания" />
</SimpleForm>
</Edit>
);

View File

@@ -2,9 +2,18 @@ import {
List,
Datagrid,
TextField,
TextInput,
TopToolbar,
FilterButton,
CreateButton,
ExportButton,
NumberField,
DateField,
SelectField,
ReferenceField,
SelectArrayInput,
ReferenceInput,
AutocompleteInput
} from 'react-admin';
const statusChoices = [
@@ -14,17 +23,44 @@ const statusChoices = [
{ id: 'WriteOff', name: 'Списано' },
];
const equipmentFilters = [
<TextInput key="q" source="q" label="Поиск" alwaysOn />,
<TextInput key="inventoryNumber" source="inventoryNumber" label="Инвентарный номер" />,
<TextInput key="serialNumber" source="serialNumber" label="Заводской (серийный) номер" />,
<TextInput key="name" source="name" label="Наименование единицы оборудования" />,
<ReferenceInput key="equipmentTypeCode" source="equipmentTypeCode" reference="equipment-types" label="Вид оборудования">
<AutocompleteInput optionText={(record) => record.code ? `${record.code}${record.name ?? record.code}` : (record.name ?? record.id)} filterToQuery={(searchText) => ({ q: searchText })} />
</ReferenceInput>,
<SelectArrayInput key="status" source="status" label="Текущий статус" choices={statusChoices} />,
<TextInput key="location" source="location" label="Место эксплуатации / скважина / куст" />,
<TextInput key="notes" source="notes" label="Примечания" />
];
const EquipmentListActions = () => (
<TopToolbar>
<FilterButton filters={equipmentFilters} />
<CreateButton />
<ExportButton />
</TopToolbar>
);
export const EquipmentList = () => (
<List>
<List actions={<EquipmentListActions />} filters={equipmentFilters} sort={{ field: 'inventoryNumber', order: 'ASC' }}>
<Datagrid rowClick="show">
<TextField source="id" label="id" />
<TextField source="inventoryNumber" label="Инвентарный номер" />
<TextField source="name" label="Наименование" />
<TextField source="serialNumber" label="Заводской (серийный) номер" />
<TextField source="name" label="Наименование единицы оборудования" />
<ReferenceField source="equipmentTypeCode" reference="equipment-types" label="Вид оборудования" link="show">
<TextField source="name" />
<TextField source="code" />
</ReferenceField>
<SelectField source="status" label="Статус" choices={statusChoices} />
<TextField source="location" label="Место эксплуатации" />
<NumberField source="totalEngineHours" label="Наработка (ч)" />
<SelectField source="status" label="Текущий статус" choices={statusChoices} />
<TextField source="location" label="Место эксплуатации / скважина / куст" />
<DateField source="commissionedAt" label="Дата ввода в эксплуатацию" />
<NumberField source="totalEngineHours" label="Общая наработка, моточасов" />
<NumberField source="engineHoursSinceLastRepair" label="Наработка с последнего ремонта, моточасов" />
<DateField source="lastRepairAt" label="Дата последнего ремонта" />
<TextField source="notes" label="Примечания" />
</Datagrid>
</List>
);

View File

@@ -1,12 +1,4 @@
import {
Show,
SimpleShowLayout,
TextField,
NumberField,
DateField,
SelectField,
ReferenceField,
} from 'react-admin';
import { Show, SimpleShowLayout, TextField, NumberField, DateField, SelectField, ReferenceField } from 'react-admin';
const statusChoices = [
{ id: 'Active', name: 'В эксплуатации' },
@@ -14,21 +6,21 @@ const statusChoices = [
{ id: 'Reserve', name: 'В резерве' },
{ id: 'WriteOff', name: 'Списано' },
];
export const EquipmentShow = () => (
<Show>
<SimpleShowLayout>
<TextField source="id" label="id" />
<TextField source="inventoryNumber" label="Инвентарный номер" />
<TextField source="serialNumber" label="Заводской номер" />
<TextField source="name" label="Наименование" />
<TextField source="serialNumber" label="Заводской (серийный) номер" />
<TextField source="name" label="Наименование единицы оборудования" />
<ReferenceField source="equipmentTypeCode" reference="equipment-types" label="Вид оборудования" link="show">
<TextField source="name" />
<TextField source="code" />
</ReferenceField>
<SelectField source="status" label="Статус" choices={statusChoices} />
<TextField source="location" label="Место эксплуатации" />
<SelectField source="status" label="Текущий статус" choices={statusChoices} />
<TextField source="location" label="Место эксплуатации / скважина / куст" />
<DateField source="commissionedAt" label="Дата ввода в эксплуатацию" />
<NumberField source="totalEngineHours" label="Общая наработка (ч)" />
<NumberField source="engineHoursSinceLastRepair" label="Наработка с последнего ремонта (ч)" />
<NumberField source="totalEngineHours" label="Общая наработка, моточасов" />
<NumberField source="engineHoursSinceLastRepair" label="Наработка с последнего ремонта, моточасов" />
<DateField source="lastRepairAt" label="Дата последнего ремонта" />
<TextField source="notes" label="Примечания" />
</SimpleShowLayout>

View File

@@ -1,12 +1,4 @@
import {
Create,
SimpleForm,
TextInput,
NumberInput,
DateInput,
SelectInput,
ReferenceInput,
} from 'react-admin';
import { Create, SimpleForm, TextInput, NumberInput, DateInput, SelectInput, ReferenceInput, AutocompleteInput } from 'react-admin';
const repairKindChoices = [
{ id: 'TO', name: 'Техническое обслуживание' },
@@ -29,18 +21,18 @@ export const RepairOrderCreate = () => (
<Create>
<SimpleForm>
<TextInput source="number" label="Номер заявки" isRequired />
<ReferenceInput source="equipmentId" reference="equipment" label="Оборудование">
<SelectInput optionText="name" isRequired />
<ReferenceInput source="equipmentId" reference="equipment">
<AutocompleteInput label="Оборудование" optionText={(record) => record.inventoryNumber ? `${record.inventoryNumber}${record.name ?? record.inventoryNumber}` : (record.name ?? record.id)} filterToQuery={(searchText) => ({ q: searchText })} />
</ReferenceInput>
<SelectInput source="repairKind" label="Вид ремонта" choices={repairKindChoices} isRequired />
<SelectInput source="status" label="Статус" choices={statusChoices} defaultValue="Draft" />
<DateInput source="plannedAt" label="Плановая дата начала" isRequired />
<SelectInput source="repairKind" label="Вид ремонта" choices={repairKindChoices} emptyText="Не выбрано" />
<SelectInput source="status" label="Статус" choices={statusChoices} emptyText="Не выбрано" />
<DateInput source="plannedAt" label="Плановая дата начала" />
<DateInput source="startedAt" label="Фактическая дата начала" />
<DateInput source="completedAt" label="Фактическая дата завершения" />
<TextInput source="contractor" label="Подрядная организация" />
<NumberInput source="engineHoursAtRepair" label="Наработка на момент ремонта (ч)" />
<TextInput source="description" label="Описание работ / дефекта" multiline />
<TextInput source="notes" label="Примечания" multiline />
<TextInput source="contractor" label="Подрядная организация (если внешний ремонт)" />
<NumberInput source="engineHoursAtRepair" label="Наработка на момент ремонта, моточасов" />
<TextInput source="description" label="Описание работ / дефекта" />
<TextInput source="notes" label="Примечания" />
</SimpleForm>
</Create>
);

View File

@@ -1,12 +1,4 @@
import {
Edit,
SimpleForm,
TextInput,
NumberInput,
DateInput,
SelectInput,
ReferenceInput,
} from 'react-admin';
import { Edit, SimpleForm, TextInput, NumberInput, DateInput, SelectInput, ReferenceInput, AutocompleteInput } from 'react-admin';
const repairKindChoices = [
{ id: 'TO', name: 'Техническое обслуживание' },
@@ -28,19 +20,20 @@ const statusChoices = [
export const RepairOrderEdit = () => (
<Edit>
<SimpleForm>
<TextInput source="id" label="id" disabled />
<TextInput source="number" label="Номер заявки" isRequired />
<ReferenceInput source="equipmentId" reference="equipment" label="Оборудование">
<SelectInput optionText="name" isRequired />
<ReferenceInput source="equipmentId" reference="equipment">
<AutocompleteInput label="Оборудование" optionText={(record) => record.inventoryNumber ? `${record.inventoryNumber}${record.name ?? record.inventoryNumber}` : (record.name ?? record.id)} filterToQuery={(searchText) => ({ q: searchText })} />
</ReferenceInput>
<SelectInput source="repairKind" label="Вид ремонта" choices={repairKindChoices} isRequired />
<SelectInput source="status" label="Статус" choices={statusChoices} />
<DateInput source="plannedAt" label="Плановая дата начала" isRequired />
<SelectInput source="repairKind" label="Вид ремонта" choices={repairKindChoices} emptyText="Не выбрано" />
<SelectInput source="status" label="Статус" choices={statusChoices} emptyText="Не выбрано" />
<DateInput source="plannedAt" label="Плановая дата начала" />
<DateInput source="startedAt" label="Фактическая дата начала" />
<DateInput source="completedAt" label="Фактическая дата завершения" />
<TextInput source="contractor" label="Подрядная организация" />
<NumberInput source="engineHoursAtRepair" label="Наработка на момент ремонта (ч)" />
<TextInput source="description" label="Описание работ / дефекта" multiline />
<TextInput source="notes" label="Примечания" multiline />
<TextInput source="contractor" label="Подрядная организация (если внешний ремонт)" />
<NumberInput source="engineHoursAtRepair" label="Наработка на момент ремонта, моточасов" />
<TextInput source="description" label="Описание работ / дефекта" />
<TextInput source="notes" label="Примечания" />
</SimpleForm>
</Edit>
);

View File

@@ -2,9 +2,19 @@ import {
List,
Datagrid,
TextField,
TextInput,
TopToolbar,
FilterButton,
CreateButton,
ExportButton,
NumberField,
DateField,
SelectField,
ReferenceField,
SelectArrayInput,
SelectInput,
ReferenceInput,
AutocompleteInput
} from 'react-admin';
const repairKindChoices = [
@@ -24,17 +34,44 @@ const statusChoices = [
{ id: 'Cancelled', name: 'Отменена' },
];
const repairOrderFilters = [
<TextInput key="q" source="q" label="Поиск" alwaysOn />,
<TextInput key="number" source="number" label="Номер заявки" />,
<ReferenceInput key="equipmentId" source="equipmentId" reference="equipment" label="Оборудование">
<AutocompleteInput optionText={(record) => record.inventoryNumber ? `${record.inventoryNumber}${record.name ?? record.inventoryNumber}` : (record.name ?? record.id)} filterToQuery={(searchText) => ({ q: searchText })} />
</ReferenceInput>,
<SelectInput key="repairKind" source="repairKind" label="Вид ремонта" choices={repairKindChoices} emptyText="Все" />,
<SelectArrayInput key="status" source="status" label="Статус" choices={statusChoices} />,
<TextInput key="contractor" source="contractor" label="Подрядная организация (если внешний ремонт)" />,
<TextInput key="description" source="description" label="Описание работ / дефекта" />,
<TextInput key="notes" source="notes" label="Примечания" />
];
const RepairOrderListActions = () => (
<TopToolbar>
<FilterButton filters={repairOrderFilters} />
<CreateButton />
<ExportButton />
</TopToolbar>
);
export const RepairOrderList = () => (
<List>
<List actions={<RepairOrderListActions />} filters={repairOrderFilters} sort={{ field: 'number', order: 'ASC' }}>
<Datagrid rowClick="show">
<TextField source="number" label="Номер" />
<TextField source="id" label="id" />
<TextField source="number" label="Номер заявки" />
<ReferenceField source="equipmentId" reference="equipment" label="Оборудование" link="show">
<TextField source="name" />
<TextField source="inventoryNumber" />
</ReferenceField>
<SelectField source="repairKind" label="Вид ремонта" choices={repairKindChoices} />
<SelectField source="status" label="Статус" choices={statusChoices} />
<DateField source="plannedAt" label="Плановая дата" />
<TextField source="contractor" label="Подрядчик" />
<DateField source="plannedAt" label="Плановая дата начала" />
<DateField source="startedAt" label="Фактическая дата начала" />
<DateField source="completedAt" label="Фактическая дата завершения" />
<TextField source="contractor" label="Подрядная организация (если внешний ремонт)" />
<NumberField source="engineHoursAtRepair" label="Наработка на момент ремонта, моточасов" />
<TextField source="description" label="Описание работ / дефекта" />
<TextField source="notes" label="Примечания" />
</Datagrid>
</List>
);

View File

@@ -1,12 +1,4 @@
import {
Show,
SimpleShowLayout,
TextField,
NumberField,
DateField,
SelectField,
ReferenceField,
} from 'react-admin';
import { Show, SimpleShowLayout, TextField, NumberField, DateField, SelectField, ReferenceField } from 'react-admin';
const repairKindChoices = [
{ id: 'TO', name: 'Техническое обслуживание' },
@@ -24,21 +16,21 @@ const statusChoices = [
{ id: 'Done', name: 'Выполнена' },
{ id: 'Cancelled', name: 'Отменена' },
];
export const RepairOrderShow = () => (
<Show>
<SimpleShowLayout>
<TextField source="id" label="id" />
<TextField source="number" label="Номер заявки" />
<ReferenceField source="equipmentId" reference="equipment" label="Оборудование" link="show">
<TextField source="name" />
<TextField source="inventoryNumber" />
</ReferenceField>
<SelectField source="repairKind" label="Вид ремонта" choices={repairKindChoices} />
<SelectField source="status" label="Статус" choices={statusChoices} />
<DateField source="plannedAt" label="Плановая дата начала" />
<DateField source="startedAt" label="Фактическая дата начала" />
<DateField source="completedAt" label="Фактическая дата завершения" />
<TextField source="contractor" label="Подрядная организация" />
<NumberField source="engineHoursAtRepair" label="Наработка на момент ремонта (ч)" />
<TextField source="contractor" label="Подрядная организация (если внешний ремонт)" />
<NumberField source="engineHoursAtRepair" label="Наработка на момент ремонта, моточасов" />
<TextField source="description" label="Описание работ / дефекта" />
<TextField source="notes" label="Примечания" />
</SimpleShowLayout>

View File

@@ -233,3 +233,4 @@ entity RepairOrder {
type text;
}
}

713
generation/generate.mjs Normal file
View File

@@ -0,0 +1,713 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
// Always resolve repo root relative to this script location
// <repo>/generation/generate.mjs -> root is parent folder of generation/
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT = path.resolve(__dirname, '..');
function readFile(p) {
return fs.readFileSync(p, 'utf8');
}
function writeFile(p, content) {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, content, 'utf8');
}
function toKebab(s) {
return s
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.replace(/_/g, '-')
.toLowerCase();
}
function pluralize(resource) {
// Minimal heuristic; can be improved later.
if (resource === 'equipment') return 'equipment';
if (resource.endsWith('s')) return `${resource}es`;
return `${resource}s`;
}
function upperFirst(s) {
return s ? s[0].toUpperCase() + s.slice(1) : s;
}
function lowerFirst(s) {
return s ? s[0].toLowerCase() + s.slice(1) : s;
}
function toIdentifierFromKebab(kebab) {
// "repair-order" -> "repairOrder"
return kebab.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
}
function parseBlocks(text, kind) {
// kind: 'enum' | 'entity'
const blocks = [];
const lines = text.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const m = line.match(new RegExp(`^\\s*${kind}\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*\\{\\s*$`));
if (!m) continue;
const name = m[1];
let depth = 0;
const start = i;
let end = i;
for (let j = i; j < lines.length; j++) {
const l = lines[j];
if (l.includes('{')) depth += (l.match(/\{/g) || []).length;
if (l.includes('}')) depth -= (l.match(/\}/g) || []).length;
if (depth === 0 && j > i) {
end = j;
break;
}
}
const body = lines.slice(start + 1, end).join('\n');
blocks.push({ name, body, startLine: start, endLine: end });
i = end;
}
return blocks;
}
function parseEnum(body) {
const values = [];
const labels = {};
const re = /value\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{([\s\S]*?)\}/gm;
let m;
while ((m = re.exec(body))) {
values.push(m[1]);
const label = (m[2].match(/label\s+"([^"]+)"/m) || [])[1];
labels[m[1]] = label || m[1];
}
return { values, labels };
}
function parseEntity(body) {
const attrs = [];
const entityLabel = (body.match(/description\s+"([^"]+)"/m) || [])[1];
const lines = body.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(/^\s*attribute\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{\s*$/);
if (!m) continue;
const name = m[1];
let depth = 0;
const start = i;
let end = i;
for (let j = i; j < lines.length; j++) {
const l = lines[j];
if (l.includes('{')) depth += (l.match(/\{/g) || []).length;
if (l.includes('}')) depth -= (l.match(/\}/g) || []).length;
if (depth === 0 && j > i) {
end = j;
break;
}
}
const abody = lines.slice(start + 1, end).join('\n');
const type = (abody.match(/^\s*type\s+([A-Za-z_][A-Za-z0-9_]*)\s*;/m) || [])[1];
const isRequired = /^\s*is required\s*;/m.test(abody);
const isUnique = /^\s*is unique\s*;/m.test(abody);
const isPrimary = /^\s*key primary\s*;/m.test(abody);
const defaultValue = (abody.match(/^\s*default\s+([A-Za-z_][A-Za-z0-9_]*)\s*;/m) || [])[1];
const foreignRel = (abody.match(/relates\s+([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)\s*;/m) || []).slice(1);
const description = (abody.match(/description\s+"([^"]+)"/m) || [])[1];
attrs.push({
name,
type,
label: description || name,
isRequired,
isUnique,
isPrimary,
defaultValue,
foreign: foreignRel.length ? { entity: foreignRel[0], field: foreignRel[1] } : null,
});
i = end;
}
const pk = attrs.find((a) => a.isPrimary);
if (!pk) throw new Error('Entity missing primary key attribute');
return { attributes: attrs, primaryKey: pk.name, label: entityLabel };
}
function parseDomainDSL(dslText) {
const enums = {};
const entities = {};
for (const b of parseBlocks(dslText, 'enum')) {
enums[b.name] = parseEnum(b.body);
}
for (const b of parseBlocks(dslText, 'entity')) {
entities[b.name] = parseEntity(b.body);
}
return { enums, entities };
}
function getEntityAttrNames(entity) {
return new Set(entity.attributes.map((a) => a.name));
}
function getBestSortField(entity, pk) {
const attrs = getEntityAttrNames(entity);
if (attrs.has('inventoryNumber')) return 'inventoryNumber';
if (attrs.has('number')) return 'number';
if (attrs.has('code')) return 'code';
if (attrs.has('name')) return 'name';
return pk;
}
function getReferenceDisplayExpr(foreignEntity) {
const attrs = getEntityAttrNames(foreignEntity);
if (attrs.has('inventoryNumber')) {
return "(record) => record.inventoryNumber ? `${record.inventoryNumber} — ${record.name ?? record.inventoryNumber}` : (record.name ?? record.id)";
}
if (attrs.has('code')) {
return "(record) => record.code ? `${record.code} — ${record.name ?? record.code}` : (record.name ?? record.id)";
}
if (attrs.has('number')) {
return "(record) => record.number ? `${record.number} — ${record.name ?? record.number}` : (record.name ?? record.id)";
}
if (attrs.has('name')) {
return "(record) => record.name ?? record.id";
}
return "(record) => record.id";
}
function getAttributeLabel(attr, allEntities) {
if (attr.label && attr.label !== attr.name) return attr.label;
if (attr.name === 'status') return 'Статус';
if (attr.name === 'equipmentId') return 'Оборудование';
if (attr.name === 'equipmentTypeCode') return 'Вид оборудования';
if (attr.foreign) return allEntities[attr.foreign.entity]?.label || attr.name;
return attr.name;
}
function prismaScalarType(dslType) {
switch (dslType) {
case 'string':
case 'text':
return 'String';
case 'uuid':
return 'String';
case 'integer':
return 'Int';
case 'decimal':
return 'Decimal';
case 'date':
return 'DateTime';
default:
// enum type name
return dslType;
}
}
function generatePrismaEnum(name, values) {
return `enum ${name} {\n${values.map((v) => ` ${v}`).join('\n')}\n}\n`;
}
function generatePrismaModel(name, entity, allEntities) {
const lines = [];
lines.push(`model ${name} {`);
for (const attr of entity.attributes) {
if (attr.foreign) {
// Keep scalar FK field, relation field added below
}
const scalar = prismaScalarType(attr.type);
const optional = attr.isRequired || attr.isPrimary ? '' : '?';
const parts = [` ${attr.name} ${scalar}${optional}`];
if (attr.isPrimary) {
if (attr.type === 'uuid' || attr.name === 'id') parts.push('@id @default(uuid())');
else parts.push('@id');
}
if (attr.isUnique && !attr.isPrimary) parts.push('@unique');
if (attr.defaultValue) parts.push(`@default(${attr.defaultValue})`);
lines.push(`${parts.join(' ')}`);
}
// Add relations for foreign keys
for (const attr of entity.attributes.filter((a) => a.foreign)) {
const relEntity = attr.foreign.entity;
const relField = attr.foreign.field;
const relName = lowerFirst(relEntity);
// relation field must not collide; fallback to relEntity name if needed
const relationFieldName = entity.attributes.some((a) => a.name === relName) ? `${relName}Ref` : relName;
lines.push(
` ${relationFieldName} ${relEntity} @relation(fields: [${attr.name}], references: [${relField}])`
);
}
// Add back-relations (required by Prisma when a relation field exists)
// For each other entity that has a FK pointing to this model, create a list field.
for (const [otherName, otherEntity] of Object.entries(allEntities)) {
for (const fk of otherEntity.attributes.filter((a) => a.foreign)) {
if (fk.foreign.entity !== name) continue;
const candidate = pluralize(lowerFirst(otherName));
const fieldName = lines.some((l) => l.startsWith(` ${candidate} `))
? `${candidate}List`
: candidate;
// Avoid duplicates if multiple FKs exist (basic de-dupe)
if (lines.some((l) => l.startsWith(` ${fieldName} `))) continue;
lines.push(` ${fieldName} ${otherName}[]`);
}
}
lines.push('}');
return `${lines.join('\n')}\n`;
}
function ensurePrismaSchema({ enums, entities }, prismaPath, apply) {
const existing = fs.existsSync(prismaPath) ? readFile(prismaPath) : '';
const hasGenerator = /generator\s+client\s*\{/m.test(existing);
const header = hasGenerator
? existing.split(/\n(?=enum|model)\b/)[0].trimEnd() + '\n\n'
: `generator client {\n provider = "prisma-client-js"\n}\n\ndatasource db {\n provider = "postgresql"\n url = env("DATABASE_URL")\n}\n\n`;
const out = [header];
// Preserve existing enum/model blocks not in DSL? For now, regenerate from DSL only.
for (const [name, e] of Object.entries(enums)) out.push(generatePrismaEnum(name, e.values) + '\n');
for (const [name, ent] of Object.entries(entities)) out.push(generatePrismaModel(name, ent, entities) + '\n');
const next = out.join('').trimEnd() + '\n';
if (!apply) return { changed: next !== existing, content: next };
writeFile(prismaPath, next);
return { changed: true, content: next };
}
function renderBackendModule(entityName, entity, resourceName, pk) {
const className = entityName;
const moduleName = `${className}Module`;
const serviceName = `${className}Service`;
const controllerName = `${className}Controller`;
const folder = toKebab(entityName);
// DTOs
const enumTypes = entity.attributes
.filter((a) => !['string', 'text', 'uuid', 'integer', 'decimal', 'date'].includes(a.type))
.map((a) => a.type);
const enumUnion = (typeName) => {
// Unknown labels in DSL aren't needed; keep as string union to avoid importing Prisma enums.
return `'${typeName}'`;
};
const dtoType = (attr) => {
switch (attr.type) {
case 'uuid':
case 'string':
case 'text':
return 'string';
case 'integer':
return 'number';
case 'decimal':
return 'string';
case 'date':
return 'string';
default:
// enum
return 'string';
}
};
const createDtoLines = [];
createDtoLines.push(`export class Create${className}Dto {`);
for (const a of entity.attributes) {
if (a.isPrimary && a.type === 'uuid') continue; // generated
const opt = a.isRequired && !(a.isPrimary && a.type !== 'uuid') ? '!' : '?';
createDtoLines.push(` ${a.name}${opt}: ${dtoType(a)};`);
}
createDtoLines.push('}');
const updateDtoLines = [];
updateDtoLines.push(`export class Update${className}Dto {`);
if (pk !== 'id') updateDtoLines.push(` id?: string;`);
for (const a of entity.attributes) {
if (pk !== 'id' && a.name === 'id') continue;
updateDtoLines.push(` ${a.name}?: ${dtoType(a)};`);
}
updateDtoLines.push('}');
const controller = `import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common';\nimport { Response } from 'express';\nimport { Roles } from '../../auth/decorators/roles.decorator';\nimport { RealmRole } from '../../auth/roles/realm-role.enum';\nimport { ${serviceName} } from './${folder}.service';\nimport { Create${className}Dto } from './dto/create-${folder}.dto';\nimport { Update${className}Dto } from './dto/update-${folder}.dto';\n\n@Controller('${resourceName}')\nexport class ${controllerName} {\n constructor(private readonly service: ${serviceName}) {}\n\n @Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)\n @Get()\n async findAll(@Query() query: any, @Res() res: Response) {\n const result = await this.service.findAll(query);\n res.set('Content-Range', \`${resourceName} \${query._start || 0}-\${query._end || result.total}/\${result.total}\`);\n res.set('Access-Control-Expose-Headers', 'Content-Range');\n return res.json(result.data);\n }\n\n @Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)\n @Get(':${pk}')\n findOne(@Param('${pk}') id: string) {\n return this.service.findOne(id);\n }\n\n @Roles(RealmRole.Editor, RealmRole.Admin)\n @Post()\n create(@Body() dto: Create${className}Dto) {\n return this.service.create(dto);\n }\n\n @Roles(RealmRole.Editor, RealmRole.Admin)\n @Patch(':${pk}')\n update(@Param('${pk}') id: string, @Body() dto: Update${className}Dto) {\n return this.service.update(id, dto);\n }\n\n @Roles(RealmRole.Admin)\n @Delete(':${pk}')\n remove(@Param('${pk}') id: string) {\n return this.service.remove(id);\n }\n}\n`;
const service = `import { Injectable } from '@nestjs/common';\nimport { Prisma } from '@prisma/client';\nimport { PrismaService } from '../../prisma/prisma.service';\nimport { Create${className}Dto } from './dto/create-${folder}.dto';\nimport { Update${className}Dto } from './dto/update-${folder}.dto';\n\nfunction serializeRecord(record: any) {\n return {\n ...record,\n${entity.attributes
.filter((a) => a.type === 'decimal')
.map((a) => ` ${a.name}: record.${a.name}?.toString() ?? null,`)
.join('\n')}\n${entity.attributes
.filter((a) => a.type === 'date')
.map((a) => ` ${a.name}: record.${a.name}?.toISOString() ?? null,`)
.join('\n')}\n };\n}\n\n@Injectable()\nexport class ${serviceName} {\n constructor(private readonly prisma: PrismaService) {}\n\n async findAll(query: { _start?: string; _end?: string; _sort?: string; _order?: string; [key: string]: any }) {\n const start = parseInt(query._start) || 0;\n const end = parseInt(query._end) || 10;\n const take = end - start;\n const skip = start;\n const sortField = query._sort || '${getBestSortField(entity, pk)}';\n const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';\n\n const where: any = {};\n\n if (query.q) {\n const q = String(query.q);\n const ors: any[] = [];\n ${entity.attributes
.filter((a) => ['string', 'text'].includes(a.type))
.slice(0, 6)
.map((a) => `ors.push({ ${a.name}: { contains: q, mode: 'insensitive' } });`)
.join('\n ')}\n if (ors.length) where.OR = ors;\n }\n\n ${entity.attributes
.filter((a) => ['string', 'text'].includes(a.type) && !a.foreign)
.map((a) => `if (query.${a.name}) where.${a.name} = { contains: query.${a.name}, mode: 'insensitive' };`)
.join('\n ')}\n\n ${entity.attributes
.filter((a) => a.foreign)
.map((a) => `if (query.${a.name}) where.${a.name} = query.${a.name};`)
.join('\n ')}\n\n // Enum multi-value support (e.g. status=A&status=B)\n ${entity.attributes
.filter((a) => !['string', 'text', 'uuid', 'integer', 'decimal', 'date'].includes(a.type))
.map((a) => `if (query.${a.name}) { const vals = Array.isArray(query.${a.name}) ? query.${a.name} : [query.${a.name}]; where.${a.name} = vals.length > 1 ? { in: vals } : vals[0]; }`)
.join('\n ')}\n\n if (query.id) {\n const ids = Array.isArray(query.id) ? query.id : [query.id];\n where.${pk} = { in: ids };\n }\n\n const [data, total] = await Promise.all([\n this.prisma.${lowerFirst(className)}.findMany({ where, skip, take, orderBy: { [sortField]: sortOrder } }),\n this.prisma.${lowerFirst(className)}.count({ where }),\n ]);\n\n const mapped = ${pk === 'id' ? 'data.map(serializeRecord)' : `data.map((r: any) => ({ id: r.${pk}, ...serializeRecord(r) }))`};\n return { data: mapped, total };\n }\n\n async findOne(id: string) {\n const record = await this.prisma.${lowerFirst(className)}.findUniqueOrThrow({ where: { ${pk}: id } as any });\n return ${pk === 'id' ? 'serializeRecord(record)' : `{ id: (record as any).${pk}, ...serializeRecord(record) }`};\n }\n\n async create(dto: Create${className}Dto) {\n const data: any = { ...(dto as any) };\n${entity.attributes
.filter((a) => a.type === 'date')
.map((a) => ` if (data.${a.name}) data.${a.name} = new Date(data.${a.name});`)
.join('\n')}\n${entity.attributes
.filter((a) => a.type === 'decimal')
.map((a) => ` if (data.${a.name}) data.${a.name} = new Prisma.Decimal(data.${a.name});`)
.join('\n')}\n\n const record = await this.prisma.${lowerFirst(className)}.create({ data });\n return ${pk === 'id' ? 'serializeRecord(record)' : `{ id: (record as any).${pk}, ...serializeRecord(record) }`};\n }\n\n async update(id: string, dto: Update${className}Dto) {\n const data: any = { ...(dto as any) };\n delete data.id;\n delete data.${pk};\n${entity.attributes
.filter((a) => a.type === 'date')
.map((a) => ` if (data.${a.name}) data.${a.name} = new Date(data.${a.name});`)
.join('\n')}\n${entity.attributes
.filter((a) => a.type === 'decimal')
.map((a) => ` if (data.${a.name} !== undefined && data.${a.name} !== null) data.${a.name} = new Prisma.Decimal(data.${a.name});`)
.join('\n')}\n\n const record = await this.prisma.${lowerFirst(className)}.update({ where: { ${pk}: id } as any, data });\n return ${pk === 'id' ? 'serializeRecord(record)' : `{ id: (record as any).${pk}, ...serializeRecord(record) }`};\n }\n\n async remove(id: string) {\n const record = await this.prisma.${lowerFirst(className)}.delete({ where: { ${pk}: id } as any });\n return ${pk === 'id' ? 'serializeRecord(record)' : `{ id: (record as any).${pk}, ...serializeRecord(record) }`};\n }\n}\n`;
let serviceContent = service
.replace(
`const sortField = query._sort || '${getBestSortField(entity, pk)}';\n const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';`,
`const sortField = query._sort || '${getBestSortField(entity, pk)}';\n const prismaSortField = sortField === 'id' ? '${pk}' : sortField;\n const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';`
)
.replace('orderBy: { [sortField]: sortOrder }', 'orderBy: { [prismaSortField]: sortOrder }')
.replace(
`data.map((r: any) => ({ id: r.${pk}, ...serializeRecord(r) }))`,
`data.map((item: any) => ({ id: item.${pk}, ...serializeRecord(item) }))`
);
if (pk !== 'id') {
serviceContent = serviceContent.replace(
`const data: any = { ...(dto as any) };\n delete data.id;\n delete data.${pk};`,
`const { id: _pk, ${pk}, ...rest } = (dto as any);\n const data: any = { ...rest };`
);
}
const mod = `import { Module } from '@nestjs/common';\nimport { ${controllerName} } from './${folder}.controller';\nimport { ${serviceName} } from './${folder}.service';\n\n@Module({\n controllers: [${controllerName}],\n providers: [${serviceName}],\n})\nexport class ${moduleName} {}\n`;
return {
folder,
files: {
[`server/src/modules/${folder}/${folder}.controller.ts`]: controller,
[`server/src/modules/${folder}/${folder}.service.ts`]: serviceContent,
[`server/src/modules/${folder}/${folder}.module.ts`]: mod,
[`server/src/modules/${folder}/dto/create-${folder}.dto.ts`]: createDtoLines.join('\n') + '\n',
[`server/src/modules/${folder}/dto/update-${folder}.dto.ts`]: updateDtoLines.join('\n') + '\n',
},
moduleName,
importPath: `./modules/${folder}/${folder}.module`,
};
}
function renderFrontendResource(entityName, entity, resourceName, pk, enums, allEntities) {
const folder = toKebab(entityName);
const className = entityName;
const enumAttrs = entity.attributes.filter(
(a) => !['string', 'text', 'uuid', 'integer', 'decimal', 'date'].includes(a.type)
);
const statusEnumAttr = enumAttrs.find((a) => a.name === 'status');
const identBase = toIdentifierFromKebab(folder);
const filtersIdent = `${identBase}Filters`;
const sortField = getBestSortField(entity, pk);
const hasNumber = entity.attributes.some((a) => ['integer', 'decimal'].includes(a.type));
const hasDate = entity.attributes.some((a) => a.type === 'date');
const hasFK = entity.attributes.some((a) => a.foreign);
const hasNonStatusEnum = enumAttrs.some((a) => a.name !== 'status');
const listImportSet = new Set([
'List',
'Datagrid',
'TextField',
'TextInput',
'TopToolbar',
'FilterButton',
'CreateButton',
'ExportButton',
]);
if (hasNumber) listImportSet.add('NumberField');
if (hasDate) listImportSet.add('DateField');
if (enumAttrs.length) listImportSet.add('SelectField');
if (hasFK) listImportSet.add('ReferenceField');
if (statusEnumAttr) listImportSet.add('SelectArrayInput');
if (hasNonStatusEnum) listImportSet.add('SelectInput');
if (hasFK) {
listImportSet.add('ReferenceInput');
listImportSet.add('AutocompleteInput');
}
const listImports = Array.from(listImportSet);
const choiceConsts = [];
for (const a of enumAttrs) {
const enumName = a.type;
const values = enums?.[enumName]?.values ?? [];
const labels = enums?.[enumName]?.labels ?? {};
const constName = `${a.name}Choices`;
if (a.name === 'status') {
choiceConsts.push(
`const statusChoices = [\n${values.map((v) => ` { id: '${v}', name: '${labels[v] ?? v}' },`).join('\n')}\n];\n`
);
} else {
choiceConsts.push(
`const ${constName} = [\n${values.map((v) => ` { id: '${v}', name: '${labels[v] ?? v}' },`).join('\n')}\n];\n`
);
}
}
const filterInputs = [];
// Always include q if any string fields
if (entity.attributes.some((a) => ['string', 'text'].includes(a.type))) {
filterInputs.push(`<TextInput key="q" source="q" label="Поиск" alwaysOn />`);
}
for (const a of entity.attributes) {
const label = getAttributeLabel(a, allEntities);
if (a.name === pk) continue;
if (a.foreign) {
const referenceDisplay = getReferenceDisplayExpr(allEntities[a.foreign.entity]);
filterInputs.push(
`<ReferenceInput key="${a.name}" source="${a.name}" reference="${pluralize(toKebab(a.foreign.entity))}" label="${label}">\n <AutocompleteInput optionText={${referenceDisplay}} filterToQuery={(searchText) => ({ q: searchText })} />\n </ReferenceInput>`
);
continue;
}
if (['string', 'text', 'uuid'].includes(a.type)) {
filterInputs.push(`<TextInput key="${a.name}" source="${a.name}" label="${label}" />`);
continue;
}
if (['integer', 'decimal'].includes(a.type)) continue;
if (a.type === 'date') continue;
// enum
if (a.name === 'status') {
filterInputs.push(`<SelectArrayInput key="${a.name}" source="${a.name}" label="${label}" choices={statusChoices} />`);
} else {
filterInputs.push(`<SelectInput key="${a.name}" source="${a.name}" label="${label}" choices={${a.name}Choices} emptyText="Все" />`);
}
}
const listFields = [];
for (const a of entity.attributes) {
const label = getAttributeLabel(a, allEntities);
if (a.foreign) {
const referenceEntity = allEntities[a.foreign.entity];
const referenceAttrs = getEntityAttrNames(referenceEntity);
const fieldSource = referenceAttrs.has('inventoryNumber')
? 'inventoryNumber'
: referenceAttrs.has('code')
? 'code'
: referenceAttrs.has('number')
? 'number'
: 'name';
listFields.push(
`<ReferenceField source="${a.name}" reference="${pluralize(toKebab(a.foreign.entity))}" label="${label}" link="show">\n <TextField source="${fieldSource}" />\n </ReferenceField>`
);
continue;
}
if (a.type === 'date') {
listFields.push(`<DateField source="${a.name}" label="${label}" />`);
} else if (['integer', 'decimal'].includes(a.type)) {
listFields.push(`<NumberField source="${a.name}" label="${label}" />`);
} else if (!['string', 'text', 'uuid', 'integer', 'decimal', 'date'].includes(a.type)) {
listFields.push(`<SelectField source="${a.name}" label="${label}" choices={${a.name === 'status' ? 'statusChoices' : `${a.name}Choices`}} />`);
} else {
listFields.push(`<TextField source="${a.name}" label="${label}" />`);
}
}
const list = `import {\n ${listImports.join(',\n ')}\n} from 'react-admin';\n\n${choiceConsts.join('\n')}\nconst ${filtersIdent} = [\n ${filterInputs.join(',\n ')}\n];\n\nconst ${className}ListActions = () => (\n <TopToolbar>\n <FilterButton filters={${filtersIdent}} />\n <CreateButton />\n <ExportButton />\n </TopToolbar>\n);\n\nexport const ${className}List = () => (\n <List actions={<${className}ListActions />} filters={${filtersIdent}} sort={{ field: '${sortField}', order: 'ASC' }}>\n <Datagrid rowClick=\"show\">\n ${listFields.join('\n ')}\n </Datagrid>\n </List>\n);\n`;
const formField = (a, mode) => {
const label = getAttributeLabel(a, allEntities);
if (a.isPrimary && mode === 'create' && a.type === 'uuid') return null;
if (a.isPrimary && mode === 'edit') {
return `<TextInput source="${a.name}" label="${label}" disabled />`;
}
if (a.foreign) {
const referenceDisplay = getReferenceDisplayExpr(allEntities[a.foreign.entity]);
return `<ReferenceInput source="${a.name}" reference="${pluralize(toKebab(a.foreign.entity))}">\n <AutocompleteInput label="${label}" optionText={${referenceDisplay}} filterToQuery={(searchText) => ({ q: searchText })} />\n </ReferenceInput>`;
}
if (a.type === 'date') return `<DateInput source="${a.name}" label="${label}" />`;
if (['integer', 'decimal'].includes(a.type)) return `<NumberInput source="${a.name}" label="${label}" />`;
if (!['string', 'text', 'uuid', 'integer', 'decimal', 'date'].includes(a.type)) {
if (a.name === 'status' && statusEnumAttr) return `<SelectInput source="${a.name}" label="${label}" choices={statusChoices} emptyText="Не выбрано" />`;
return `<SelectInput source="${a.name}" label="${label}" choices={${a.name}Choices} emptyText="Не выбрано" />`;
}
return `<TextInput source="${a.name}" label="${label}" ${a.isRequired ? 'isRequired' : ''} />`;
};
const formImportSet = new Set(['SimpleForm', 'TextInput']);
if (hasNumber) formImportSet.add('NumberInput');
if (hasDate) formImportSet.add('DateInput');
if (enumAttrs.length) formImportSet.add('SelectInput');
if (hasFK) {
formImportSet.add('ReferenceInput');
formImportSet.add('AutocompleteInput');
}
const createImports = ['Create', ...Array.from(formImportSet)].join(', ');
const create = `import { ${createImports} } from 'react-admin';\n\n${choiceConsts.join('\n')}\nexport const ${className}Create = () => (\n <Create>\n <SimpleForm>\n ${entity.attributes.map((a) => formField(a, 'create')).filter(Boolean).join('\n ')}\n </SimpleForm>\n </Create>\n);\n`;
const editImports = ['Edit', ...Array.from(formImportSet)].join(', ');
const edit = `import { ${editImports} } from 'react-admin';\n\n${choiceConsts.join('\n')}\nexport const ${className}Edit = () => (\n <Edit>\n <SimpleForm>\n ${entity.attributes.map((a) => formField(a, 'edit')).filter(Boolean).join('\n ')}\n </SimpleForm>\n </Edit>\n);\n`;
const showImportSet = new Set(['Show', 'SimpleShowLayout', 'TextField']);
if (hasNumber) showImportSet.add('NumberField');
if (hasDate) showImportSet.add('DateField');
if (enumAttrs.length) showImportSet.add('SelectField');
if (hasFK) showImportSet.add('ReferenceField');
const showFields = [];
for (const a of entity.attributes) {
const label = getAttributeLabel(a, allEntities);
if (a.foreign) {
const referenceEntity = allEntities[a.foreign.entity];
const referenceAttrs = getEntityAttrNames(referenceEntity);
const fieldSource = referenceAttrs.has('inventoryNumber')
? 'inventoryNumber'
: referenceAttrs.has('code')
? 'code'
: referenceAttrs.has('number')
? 'number'
: 'name';
showFields.push(
`<ReferenceField source="${a.name}" reference="${pluralize(toKebab(a.foreign.entity))}" label="${label}" link="show">\n <TextField source="${fieldSource}" />\n </ReferenceField>`
);
continue;
}
if (a.type === 'date') {
showFields.push(`<DateField source="${a.name}" label="${label}" />`);
} else if (['integer', 'decimal'].includes(a.type)) {
showFields.push(`<NumberField source="${a.name}" label="${label}" />`);
} else if (!['string', 'text', 'uuid', 'integer', 'decimal', 'date'].includes(a.type)) {
showFields.push(`<SelectField source="${a.name}" label="${label}" choices={${a.name === 'status' ? 'statusChoices' : `${a.name}Choices`}} />`);
} else {
showFields.push(`<TextField source="${a.name}" label="${label}" />`);
}
}
const show = `import { ${Array.from(showImportSet).join(', ')} } from 'react-admin';\n\n${choiceConsts.join('\n')}export const ${className}Show = () => (\n <Show>\n <SimpleShowLayout>\n ${showFields.join('\n ')}\n </SimpleShowLayout>\n </Show>\n);\n`;
return {
files: {
[`client/src/resources/${folder}/${className}List.tsx`]: list,
[`client/src/resources/${folder}/${className}Create.tsx`]: create,
[`client/src/resources/${folder}/${className}Edit.tsx`]: edit,
[`client/src/resources/${folder}/${className}Show.tsx`]: show,
},
resourceName,
className,
folder,
};
}
function upsertInFile(filePath, apply, updater) {
const abs = path.join(ROOT, filePath);
const existing = fs.existsSync(abs) ? readFile(abs) : '';
const next = updater(existing);
if (apply) writeFile(abs, next);
return { changed: next !== existing, content: next };
}
function ensureAppModule(apply, backendModules) {
return upsertInFile('server/src/app.module.ts', apply, (src) => {
let out = src;
for (const m of backendModules) {
if (!out.includes(`import { ${m.moduleName} }`)) {
const importLine = `import { ${m.moduleName} } from '${m.importPath}';`;
const importMatches = [...out.matchAll(/^import\s+.*;$/gm)];
if (importMatches.length) {
const lastImport = importMatches[importMatches.length - 1];
const insertAt = lastImport.index + lastImport[0].length;
out = `${out.slice(0, insertAt)}\n${importLine}${out.slice(insertAt)}`;
} else {
out = `${importLine}\n${out}`;
}
}
}
out = out.replace(/imports:\s*\[\s*([\s\S]*?)\s*\],/m, (match, inner) => {
let block = inner;
for (const m of backendModules) {
if (!block.includes(m.moduleName)) block = block.replace(/\s*\],?\s*$/m, '') + `\n ${m.moduleName},`;
}
// normalize trailing comma/indent by reusing original replacement style
return `imports: [${block}\n ],`;
});
return out;
});
}
function ensureClientApp(apply, frontendResources) {
return upsertInFile('client/src/App.tsx', apply, (src) => {
let out = src;
for (const r of frontendResources) {
const imports = [
`import { ${r.className}List } from './resources/${r.folder}/${r.className}List';`,
`import { ${r.className}Create } from './resources/${r.folder}/${r.className}Create';`,
`import { ${r.className}Edit } from './resources/${r.folder}/${r.className}Edit';`,
`import { ${r.className}Show } from './resources/${r.folder}/${r.className}Show';`,
];
for (const imp of imports) {
if (!out.includes(imp)) {
const importMatches = [...out.matchAll(/^import\s+.*;$/gm)];
if (importMatches.length) {
const lastImport = importMatches[importMatches.length - 1];
const insertAt = lastImport.index + lastImport[0].length;
out = `${out.slice(0, insertAt)}\n${imports.join('\n')}${out.slice(insertAt)}`;
} else {
out = `${imports.join('\n')}\n${out}`;
}
break;
}
}
if (!out.includes(`name="${r.resourceName}"`)) {
out = out.replace(
/<\/Admin>/m,
` <Resource\n name="${r.resourceName}"\n options={{ label: '${r.className}' }}\n list={${r.className}List}\n create={${r.className}Create}\n edit={${r.className}Edit}\n show={${r.className}Show}\n />\n </Admin>`
);
}
}
return out;
});
}
function main() {
const args = process.argv.slice(2);
const apply = args.includes('--apply');
const dslArgIdx = args.indexOf('--dsl');
const dslPath = dslArgIdx >= 0 ? args[dslArgIdx + 1] : 'domain/TOiR.domain.dsl';
const absDsl = path.resolve(ROOT, dslPath);
const dslText = readFile(absDsl);
const parsed = parseDomainDSL(dslText);
// Prisma schema
const prismaPath = path.join(ROOT, 'server/prisma/schema.prisma');
ensurePrismaSchema(parsed, prismaPath, apply);
// Backend modules + frontend resources
const backendModules = [];
const frontendResources = [];
for (const [entityName, ent] of Object.entries(parsed.entities)) {
const pk = ent.primaryKey;
const resource = pluralize(toKebab(entityName));
const be = renderBackendModule(entityName, ent, resource, pk);
const fe = renderFrontendResource(entityName, ent, resource, pk, parsed.enums, parsed.entities);
backendModules.push(be);
frontendResources.push(fe);
if (apply) {
for (const [rel, content] of Object.entries(be.files)) writeFile(path.join(ROOT, rel), content);
for (const [rel, content] of Object.entries(fe.files)) writeFile(path.join(ROOT, rel), content);
}
}
ensureAppModule(apply, backendModules);
ensureClientApp(apply, frontendResources);
process.stdout.write(
`${apply ? 'Generated' : 'Planned'} ${Object.keys(parsed.entities).length} entities from ${dslPath}\n`
);
}
main();

View File

@@ -56,6 +56,20 @@ The backend remains derived from `domain/*.dsl` inside the existing LLM-first pi
- call `$connect()`
- do not use `beforeExit`
## Filtering contract
- List endpoints must support React Admin query parameters:
- `_start`, `_end`, `_sort`, `_order`
- arbitrary field filters from query string
- `q` for reference autocomplete search
- String/text search filters may use `contains` with case-insensitive mode.
- Foreign key filters must use exact-match semantics (no `contains` for FK scalar keys).
- Enum filters must support both single and repeated query params:
- `status=Draft`
- `status=Draft&status=Approved`
- Repeated enum params must map to Prisma `{ in: [...] }`.
- Sorting must use real model scalar fields only; natural-key entities must not fallback to fake physical `id`.
## Reproducibility invariants
- A freshly generated backend must be bootstrappable with ordinary Nest + Prisma commands from `prompts/runtime-rules.md`.

View File

@@ -25,6 +25,13 @@ The frontend stays a React Admin SPA generated from `domain/*.dsl` and anchored
- Each entity becomes a React Admin resource with list/create/edit/show views.
- Resource names must stay aligned with backend path segments.
- Foreign keys must use `ReferenceInput` / `ReferenceField`.
- Foreign keys shown in list/show views must stay clickable via `ReferenceField link="show"` to open full details of the related resource.
- Lists must expose filters through `List` `filters` and an actions toolbar with `FilterButton`.
- For enum fields where multi-select is required (for example `status`), use `SelectArrayInput` in list filters.
- For foreign key filters and form selection use `ReferenceInput` + `AutocompleteInput` with `filterToQuery={(searchText) => ({ q: searchText })}`.
- Form mapping must stay type-safe:
- `integer` / `decimal` -> `NumberInput`
- `date` -> `DateInput`
## Provider seams
@@ -43,6 +50,8 @@ The frontend stays a React Admin SPA generated from `domain/*.dsl` and anchored
- Every resource record must include `id`.
- Natural-key resources must preserve route, update, and sort compatibility with React Admin contracts.
- Frontend requests must continue to work when the real primary key is not named `id`.
- `dataProvider` query serialization must preserve repeated query params for array filters (for example enum multi-select).
- `Resource` wiring in `App.tsx` must keep `show={...}` registration for all generated resources.
## Reproducibility invariants

View File

@@ -42,6 +42,19 @@ Validation is now a lightweight automated gate instead of a prose-only checklist
2. OIDC discovery
3. certs fallback
### Filter checks
- list resources expose filter UI (including `FilterButton`)
- reference filters use `ReferenceInput` + `AutocompleteInput` with `filterToQuery`
- data provider preserves repeated query params for array filters
- backend FK filters keep exact-match semantics
- enum repeated params are mapped to Prisma `in`
- typed form mapping is preserved:
- `integer` / `decimal` -> `NumberInput`
- `date` -> `DateInput`
- reference fields intended for navigation keep `ReferenceField link="show"`
- resources keep `show={...}` registration in `App.tsx`
### Natural-key checks
- response records expose `id`

View File

@@ -18,6 +18,7 @@
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"generate:from-dsl": "node ../generation/generate.mjs --apply --dsl domain/TOiR.domain.dsl",
"postinstall": "prisma generate"
},
"prisma": {

View File

@@ -0,0 +1,68 @@
-- CreateEnum
CREATE TYPE "EquipmentStatus" AS ENUM ('Active', 'Repair', 'Reserve', 'WriteOff');
-- CreateEnum
CREATE TYPE "RepairKind" AS ENUM ('TO', 'TR', 'TRE', 'KR', 'AR', 'MP');
-- CreateEnum
CREATE TYPE "RepairOrderStatus" AS ENUM ('Draft', 'Approved', 'InWork', 'Done', 'Cancelled');
-- CreateTable
CREATE TABLE "EquipmentType" (
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"manufacturer" TEXT,
"maintenanceIntervalHours" INTEGER,
"overhaulIntervalHours" INTEGER,
CONSTRAINT "EquipmentType_pkey" PRIMARY KEY ("code")
);
-- CreateTable
CREATE TABLE "Equipment" (
"id" TEXT NOT NULL,
"inventoryNumber" TEXT NOT NULL,
"serialNumber" TEXT,
"name" TEXT NOT NULL,
"equipmentTypeCode" TEXT NOT NULL,
"status" "EquipmentStatus" NOT NULL DEFAULT 'Active',
"location" TEXT,
"commissionedAt" TIMESTAMP(3),
"totalEngineHours" DECIMAL(65,30),
"engineHoursSinceLastRepair" DECIMAL(65,30),
"lastRepairAt" TIMESTAMP(3),
"notes" TEXT,
CONSTRAINT "Equipment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RepairOrder" (
"id" TEXT NOT NULL,
"number" TEXT NOT NULL,
"equipmentId" TEXT NOT NULL,
"repairKind" "RepairKind" NOT NULL,
"status" "RepairOrderStatus" NOT NULL DEFAULT 'Draft',
"plannedAt" TIMESTAMP(3) NOT NULL,
"startedAt" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
"contractor" TEXT,
"engineHoursAtRepair" DECIMAL(65,30),
"description" TEXT,
"notes" TEXT,
CONSTRAINT "RepairOrder_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Equipment_inventoryNumber_key" ON "Equipment"("inventoryNumber");
-- CreateIndex
CREATE UNIQUE INDEX "RepairOrder_number_key" ON "RepairOrder"("number");
-- AddForeignKey
ALTER TABLE "Equipment" ADD CONSTRAINT "Equipment_equipmentTypeCode_fkey" FOREIGN KEY ("equipmentTypeCode") REFERENCES "EquipmentType"("code") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RepairOrder" ADD CONSTRAINT "RepairOrder_equipmentId_fkey" FOREIGN KEY ("equipmentId") REFERENCES "Equipment"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -46,7 +46,6 @@ model Equipment {
serialNumber String?
name String
equipmentTypeCode String
equipmentType EquipmentType @relation(fields: [equipmentTypeCode], references: [code])
status EquipmentStatus @default(Active)
location String?
commissionedAt DateTime?
@@ -54,6 +53,7 @@ model Equipment {
engineHoursSinceLastRepair Decimal?
lastRepairAt DateTime?
notes String?
equipmentType EquipmentType @relation(fields: [equipmentTypeCode], references: [code])
repairOrders RepairOrder[]
}
@@ -61,7 +61,6 @@ model RepairOrder {
id String @id @default(uuid())
number String @unique
equipmentId String
equipment Equipment @relation(fields: [equipmentId], references: [id])
repairKind RepairKind
status RepairOrderStatus @default(Draft)
plannedAt DateTime
@@ -71,4 +70,5 @@ model RepairOrder {
engineHoursAtRepair Decimal?
description String?
notes String?
equipment Equipment @relation(fields: [equipmentId], references: [id])
}

View File

@@ -3,89 +3,161 @@ import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const equipmentType = await prisma.equipmentType.upsert({
where: { code: 'pump' },
update: {},
create: {
const equipmentTypes = [
{
code: 'pump',
name: 'Насосный агрегат',
manufacturer: 'АО НасосПром',
maintenanceIntervalHours: 2000,
overhaulIntervalHours: 16000,
},
});
const equipmentType2 = await prisma.equipmentType.upsert({
where: { code: 'compressor' },
update: {},
create: {
{
code: 'compressor',
name: 'Компрессорная установка',
manufacturer: 'ОАО Компрессормаш',
maintenanceIntervalHours: 1500,
overhaulIntervalHours: 12000,
},
});
{
code: 'generator',
name: 'Дизель-генератор',
manufacturer: 'АО ЭнергоМаш',
maintenanceIntervalHours: 500,
overhaulIntervalHours: 6000,
},
{
code: 'valve',
name: 'Запорная арматура',
manufacturer: 'ЗАО АрматурПром',
maintenanceIntervalHours: 1000,
overhaulIntervalHours: 10000,
},
{
code: 'sensor',
name: 'Датчик давления',
manufacturer: 'ООО ПриборСервис',
maintenanceIntervalHours: 800,
overhaulIntervalHours: 8000,
},
{
code: 'motor',
name: 'Электродвигатель',
manufacturer: 'ПАО ЭлектроМотор',
maintenanceIntervalHours: 1200,
overhaulIntervalHours: 14000,
},
{
code: 'fan',
name: 'Вентилятор',
manufacturer: 'АО ВентПром',
maintenanceIntervalHours: 700,
overhaulIntervalHours: 9000,
},
{
code: 'heat-exchanger',
name: 'Теплообменник',
manufacturer: 'ОАО ТеплоТех',
maintenanceIntervalHours: 1800,
overhaulIntervalHours: 15000,
},
{
code: 'filter',
name: 'Фильтровальная установка',
manufacturer: 'ООО ФильтрТех',
maintenanceIntervalHours: 600,
overhaulIntervalHours: 7000,
},
{
code: 'separator',
name: 'Сепаратор',
manufacturer: 'АО СепараторМаш',
maintenanceIntervalHours: 1600,
overhaulIntervalHours: 13000,
},
{
code: 'transformer',
name: 'Трансформатор',
manufacturer: 'ПАО ТрансЭнерго',
maintenanceIntervalHours: 2500,
overhaulIntervalHours: 20000,
},
] as const;
const equipment = await prisma.equipment.upsert({
where: { inventoryNumber: 'INV-001' },
update: {},
for (const type of equipmentTypes) {
await prisma.equipmentType.upsert({
where: { code: type.code },
update: { ...type },
create: { ...type },
});
}
const equipmentRecords: { id: string; inventoryNumber: string; name: string }[] = [];
for (let i = 1; i <= 11; i++) {
const type = equipmentTypes[(i - 1) % equipmentTypes.length];
const inventoryNumber = `INV-${String(i).padStart(3, '0')}`;
const serialNumber = `SN-2026-${String(i).padStart(4, '0')}`;
const record = await prisma.equipment.upsert({
where: { inventoryNumber },
update: {
serialNumber,
name: `${type.name} #${i}`,
equipmentTypeCode: type.code,
status: i % 5 === 0 ? 'Repair' : 'Active',
location: i % 2 === 0 ? `Площадка ${Math.ceil(i / 2)}` : `Цех ${Math.ceil(i / 3)}`,
commissionedAt: new Date(2022, (i - 1) % 12, 1 + ((i - 1) % 28)),
totalEngineHours: 1000 + i * 350,
engineHoursSinceLastRepair: 200 + i * 25,
},
create: {
inventoryNumber: 'INV-001',
serialNumber: 'SN-2024-0001',
name: 'Насос ЦНС 180-212',
equipmentTypeCode: 'pump',
status: 'Active',
location: 'Куст №5, скважина 42',
commissionedAt: new Date('2023-06-15'),
totalEngineHours: 4500,
engineHoursSinceLastRepair: 1200,
inventoryNumber,
serialNumber,
name: `${type.name} #${i}`,
equipmentTypeCode: type.code,
status: i % 5 === 0 ? 'Repair' : 'Active',
location: i % 2 === 0 ? `Площадка ${Math.ceil(i / 2)}` : `Цех ${Math.ceil(i / 3)}`,
commissionedAt: new Date(2022, (i - 1) % 12, 1 + ((i - 1) % 28)),
totalEngineHours: 1000 + i * 350,
engineHoursSinceLastRepair: 200 + i * 25,
},
});
equipmentRecords.push({ id: record.id, inventoryNumber: record.inventoryNumber, name: record.name });
}
const equipment2 = await prisma.equipment.upsert({
where: { inventoryNumber: 'INV-002' },
update: {},
create: {
inventoryNumber: 'INV-002',
serialNumber: 'SN-2024-0002',
name: 'Компрессор 4ВМ10-120/9',
equipmentTypeCode: 'compressor',
status: 'Active',
location: 'ГКС-3',
commissionedAt: new Date('2022-03-10'),
totalEngineHours: 8200,
engineHoursSinceLastRepair: 800,
},
});
const repairKinds = ['TO', 'TR', 'TRE', 'KR', 'AR', 'MP'] as const;
const statuses = ['Draft', 'Approved', 'InWork', 'Done', 'Cancelled'] as const;
for (let i = 1; i <= 11; i++) {
const number = `RO-2026-${String(i).padStart(3, '0')}`;
const equipment = equipmentRecords[(i - 1) % equipmentRecords.length];
await prisma.repairOrder.upsert({
where: { number: 'RO-2026-001' },
update: {},
create: {
number: 'RO-2026-001',
where: { number },
update: {
equipmentId: equipment.id,
repairKind: 'TO',
status: 'Approved',
plannedAt: new Date('2026-04-01'),
contractor: 'ООО СервисРемонт',
engineHoursAtRepair: 4500,
description: 'Плановое техническое обслуживание насосного агрегата',
repairKind: repairKinds[(i - 1) % repairKinds.length],
status: statuses[(i - 1) % statuses.length],
plannedAt: new Date(2026, ((i - 1) % 12), 1 + ((i - 1) % 28)),
startedAt: i % 4 === 0 ? new Date(2026, ((i - 1) % 12), 2 + ((i - 1) % 28)) : null,
completedAt: i % 5 === 0 ? new Date(2026, ((i - 1) % 12), 5 + ((i - 1) % 28)) : null,
contractor: i % 3 === 0 ? 'ООО СервисРемонт' : 'АО ТехПодряд',
engineHoursAtRepair: 1000 + i * 350,
description: `Заявка на ремонт ${equipment.inventoryNumber} (${equipment.name})`,
notes: i % 2 === 0 ? 'Тестовая заметка' : null,
},
});
await prisma.repairOrder.upsert({
where: { number: 'RO-2026-002' },
update: {},
create: {
number: 'RO-2026-002',
equipmentId: equipment2.id,
repairKind: 'TR',
status: 'Draft',
plannedAt: new Date('2026-05-15'),
description: 'Текущий ремонт компрессорной установки',
number,
equipmentId: equipment.id,
repairKind: repairKinds[(i - 1) % repairKinds.length],
status: statuses[(i - 1) % statuses.length],
plannedAt: new Date(2026, ((i - 1) % 12), 1 + ((i - 1) % 28)),
startedAt: i % 4 === 0 ? new Date(2026, ((i - 1) % 12), 2 + ((i - 1) % 28)) : null,
completedAt: i % 5 === 0 ? new Date(2026, ((i - 1) % 12), 5 + ((i - 1) % 28)) : null,
contractor: i % 3 === 0 ? 'ООО СервисРемонт' : 'АО ТехПодряд',
engineHoursAtRepair: 1000 + i * 350,
description: `Заявка на ремонт ${equipment.inventoryNumber} (${equipment.name})`,
notes: i % 2 === 0 ? 'Тестовая заметка' : null,
},
});
}
console.log('Seed data created successfully');
}

View File

@@ -9,8 +9,7 @@ import { EquipmentModule } from './modules/equipment/equipment.module';
import { RepairOrderModule } from './modules/repair-order/repair-order.module';
@Module({
imports: [
ConfigModule.forRoot({
imports: [ConfigModule.forRoot({
isGlobal: true,
validate: validateEnvironment,
}),

View File

@@ -1,6 +1,6 @@
export class CreateEquipmentTypeDto {
code: string;
name: string;
code?: string;
name!: string;
manufacturer?: string;
maintenanceIntervalHours?: number;
overhaulIntervalHours?: number;

View File

@@ -1,4 +1,6 @@
export class UpdateEquipmentTypeDto {
id?: string;
code?: string;
name?: string;
manufacturer?: string;
maintenanceIntervalHours?: number;

View File

@@ -8,12 +8,12 @@ import { UpdateEquipmentTypeDto } from './dto/update-equipment-type.dto';
@Controller('equipment-types')
export class EquipmentTypeController {
constructor(private readonly equipmentTypeService: EquipmentTypeService) {}
constructor(private readonly service: EquipmentTypeService) {}
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
@Get()
async findAll(@Query() query: any, @Res() res: Response) {
const result = await this.equipmentTypeService.findAll(query);
const result = await this.service.findAll(query);
res.set('Content-Range', `equipment-types ${query._start || 0}-${query._end || result.total}/${result.total}`);
res.set('Access-Control-Expose-Headers', 'Content-Range');
return res.json(result.data);
@@ -21,25 +21,25 @@ export class EquipmentTypeController {
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
@Get(':code')
findOne(@Param('code') code: string) {
return this.equipmentTypeService.findOne(code);
findOne(@Param('code') id: string) {
return this.service.findOne(id);
}
@Roles(RealmRole.Editor, RealmRole.Admin)
@Post()
create(@Body() dto: CreateEquipmentTypeDto) {
return this.equipmentTypeService.create(dto);
return this.service.create(dto);
}
@Roles(RealmRole.Editor, RealmRole.Admin)
@Patch(':code')
update(@Param('code') code: string, @Body() dto: UpdateEquipmentTypeDto) {
return this.equipmentTypeService.update(code, dto);
update(@Param('code') id: string, @Body() dto: UpdateEquipmentTypeDto) {
return this.service.update(id, dto);
}
@Roles(RealmRole.Admin)
@Delete(':code')
remove(@Param('code') code: string) {
return this.equipmentTypeService.remove(code);
remove(@Param('code') id: string) {
return this.service.remove(id);
}
}

View File

@@ -1,78 +1,90 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateEquipmentTypeDto } from './dto/create-equipment-type.dto';
import { UpdateEquipmentTypeDto } from './dto/update-equipment-type.dto';
function serializeRecord(record: any) {
return {
...record,
};
}
@Injectable()
export class EquipmentTypeService {
constructor(private readonly prisma: PrismaService) {}
async findAll(query: {
_start?: string;
_end?: string;
_sort?: string;
_order?: string;
[key: string]: any;
}) {
async findAll(query: { _start?: string; _end?: string; _sort?: string; _order?: string; [key: string]: any }) {
const start = parseInt(query._start) || 0;
const end = parseInt(query._end) || 10;
const take = end - start;
const skip = start;
const sortField = 'code';
const sortField = query._sort || 'code';
const prismaSortField = sortField === 'id' ? 'code' : sortField;
const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';
const where: any = {};
if (query.q) {
const q = String(query.q);
const ors: any[] = [];
ors.push({ code: { contains: q, mode: 'insensitive' } });
ors.push({ name: { contains: q, mode: 'insensitive' } });
ors.push({ manufacturer: { contains: q, mode: 'insensitive' } });
if (ors.length) where.OR = ors;
}
if (query.code) where.code = { contains: query.code, mode: 'insensitive' };
if (query.name) where.name = { contains: query.name, mode: 'insensitive' };
if (query.manufacturer)
where.manufacturer = {
contains: query.manufacturer,
mode: 'insensitive',
};
if (query.manufacturer) where.manufacturer = { contains: query.manufacturer, mode: 'insensitive' };
// Enum multi-value support (e.g. status=A&status=B)
if (query.id) {
const ids = Array.isArray(query.id) ? query.id : [query.id];
where.code = { in: ids };
}
const [data, total] = await Promise.all([
this.prisma.equipmentType.findMany({
where,
skip,
take,
orderBy: { [sortField]: sortOrder },
}),
this.prisma.equipmentType.findMany({ where, skip, take, orderBy: { [prismaSortField]: sortOrder } }),
this.prisma.equipmentType.count({ where }),
]);
return {
data: data.map((item) => ({ id: item.code, ...item })),
total,
};
const mapped = data.map((item: any) => ({ id: item.code, ...serializeRecord(item) }));
return { data: mapped, total };
}
async findOne(code: string) {
const record = await this.prisma.equipmentType.findUniqueOrThrow({
where: { code },
});
return { id: record.code, ...record };
async findOne(id: string) {
const record = await this.prisma.equipmentType.findUniqueOrThrow({ where: { code: id } as any });
return { id: (record as any).code, ...serializeRecord(record) };
}
async create(dto: CreateEquipmentTypeDto) {
const record = await this.prisma.equipmentType.create({ data: dto });
return { id: record.code, ...record };
const data: any = { ...(dto as any) };
const record = await this.prisma.equipmentType.create({ data });
return { id: (record as any).code, ...serializeRecord(record) };
}
async update(code: string, dto: UpdateEquipmentTypeDto) {
const { id, code: _pk, ...data } = dto as any;
const record = await this.prisma.equipmentType.update({
where: { code },
data,
});
return { id: record.code, ...record };
async update(id: string, dto: UpdateEquipmentTypeDto) {
const { id: _pk, code, ...rest } = (dto as any);
const data: any = { ...rest };
const record = await this.prisma.equipmentType.update({ where: { code: id } as any, data });
return { id: (record as any).code, ...serializeRecord(record) };
}
async remove(code: string) {
const record = await this.prisma.equipmentType.delete({ where: { code } });
return { id: record.code, ...record };
async remove(id: string) {
const record = await this.prisma.equipmentType.delete({ where: { code: id } as any });
return { id: (record as any).code, ...serializeRecord(record) };
}
}

View File

@@ -1,9 +1,9 @@
export class CreateEquipmentDto {
inventoryNumber: string;
inventoryNumber!: string;
serialNumber?: string;
name: string;
equipmentTypeCode: string;
status?: string;
name!: string;
equipmentTypeCode!: string;
status!: string;
location?: string;
commissionedAt?: string;
totalEngineHours?: string;

View File

@@ -1,4 +1,5 @@
export class UpdateEquipmentDto {
id?: string;
inventoryNumber?: string;
serialNumber?: string;
name?: string;

View File

@@ -8,12 +8,12 @@ import { UpdateEquipmentDto } from './dto/update-equipment.dto';
@Controller('equipment')
export class EquipmentController {
constructor(private readonly equipmentService: EquipmentService) {}
constructor(private readonly service: EquipmentService) {}
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
@Get()
async findAll(@Query() query: any, @Res() res: Response) {
const result = await this.equipmentService.findAll(query);
const result = await this.service.findAll(query);
res.set('Content-Range', `equipment ${query._start || 0}-${query._end || result.total}/${result.total}`);
res.set('Access-Control-Expose-Headers', 'Content-Range');
return res.json(result.data);
@@ -22,24 +22,24 @@ export class EquipmentController {
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
@Get(':id')
findOne(@Param('id') id: string) {
return this.equipmentService.findOne(id);
return this.service.findOne(id);
}
@Roles(RealmRole.Editor, RealmRole.Admin)
@Post()
create(@Body() dto: CreateEquipmentDto) {
return this.equipmentService.create(dto);
return this.service.create(dto);
}
@Roles(RealmRole.Editor, RealmRole.Admin)
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateEquipmentDto) {
return this.equipmentService.update(id, dto);
return this.service.update(id, dto);
}
@Roles(RealmRole.Admin)
@Delete(':id')
remove(@Param('id') id: string) {
return this.equipmentService.remove(id);
return this.service.remove(id);
}
}

View File

@@ -23,66 +23,80 @@ export class EquipmentService {
const end = parseInt(query._end) || 10;
const take = end - start;
const skip = start;
const sortField = query._sort || 'id';
const sortField = query._sort || 'inventoryNumber';
const prismaSortField = sortField === 'id' ? 'id' : sortField;
const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';
const where: any = {};
if (query.q) {
const q = String(query.q);
const ors: any[] = [];
ors.push({ inventoryNumber: { contains: q, mode: 'insensitive' } });
ors.push({ serialNumber: { contains: q, mode: 'insensitive' } });
ors.push({ name: { contains: q, mode: 'insensitive' } });
ors.push({ equipmentTypeCode: { contains: q, mode: 'insensitive' } });
ors.push({ location: { contains: q, mode: 'insensitive' } });
ors.push({ notes: { contains: q, mode: 'insensitive' } });
if (ors.length) where.OR = ors;
}
if (query.inventoryNumber) where.inventoryNumber = { contains: query.inventoryNumber, mode: 'insensitive' };
if (query.serialNumber) where.serialNumber = { contains: query.serialNumber, mode: 'insensitive' };
if (query.name) where.name = { contains: query.name, mode: 'insensitive' };
if (query.equipmentTypeCode) where.equipmentTypeCode = query.equipmentTypeCode;
if (query.status) where.status = query.status;
if (query.location) where.location = { contains: query.location, mode: 'insensitive' };
if (query.notes) where.notes = { contains: query.notes, mode: 'insensitive' };
if (query.equipmentTypeCode) where.equipmentTypeCode = query.equipmentTypeCode;
// Enum multi-value support (e.g. status=A&status=B)
if (query.status) { const vals = Array.isArray(query.status) ? query.status : [query.status]; where.status = vals.length > 1 ? { in: vals } : vals[0]; }
if (query.id) {
const ids = Array.isArray(query.id) ? query.id : [query.id];
where.id = { in: ids };
}
const [data, total] = await Promise.all([
this.prisma.equipment.findMany({
where,
skip,
take,
orderBy: { [sortField]: sortOrder },
}),
this.prisma.equipment.findMany({ where, skip, take, orderBy: { [prismaSortField]: sortOrder } }),
this.prisma.equipment.count({ where }),
]);
return {
data: data.map(serializeRecord),
total,
};
const mapped = data.map(serializeRecord);
return { data: mapped, total };
}
async findOne(id: string) {
const record = await this.prisma.equipment.findUniqueOrThrow({ where: { id } });
const record = await this.prisma.equipment.findUniqueOrThrow({ where: { id: id } as any });
return serializeRecord(record);
}
async create(dto: CreateEquipmentDto) {
const data: any = { ...dto };
if (dto.commissionedAt) data.commissionedAt = new Date(dto.commissionedAt);
if (dto.lastRepairAt) data.lastRepairAt = new Date(dto.lastRepairAt);
if (dto.totalEngineHours) data.totalEngineHours = new Prisma.Decimal(dto.totalEngineHours);
if (dto.engineHoursSinceLastRepair) data.engineHoursSinceLastRepair = new Prisma.Decimal(dto.engineHoursSinceLastRepair);
const data: any = { ...(dto as any) };
if (data.commissionedAt) data.commissionedAt = new Date(data.commissionedAt);
if (data.lastRepairAt) data.lastRepairAt = new Date(data.lastRepairAt);
if (data.totalEngineHours) data.totalEngineHours = new Prisma.Decimal(data.totalEngineHours);
if (data.engineHoursSinceLastRepair) data.engineHoursSinceLastRepair = new Prisma.Decimal(data.engineHoursSinceLastRepair);
const record = await this.prisma.equipment.create({ data });
return serializeRecord(record);
}
async update(id: string, dto: UpdateEquipmentDto) {
const { id: _pk, ...rest } = dto as any;
const data: any = { ...rest };
const data: any = { ...(dto as any) };
delete data.id;
delete data.id;
if (data.commissionedAt) data.commissionedAt = new Date(data.commissionedAt);
if (data.lastRepairAt) data.lastRepairAt = new Date(data.lastRepairAt);
if (data.totalEngineHours !== undefined && data.totalEngineHours !== null) data.totalEngineHours = new Prisma.Decimal(data.totalEngineHours);
if (data.engineHoursSinceLastRepair !== undefined && data.engineHoursSinceLastRepair !== null) data.engineHoursSinceLastRepair = new Prisma.Decimal(data.engineHoursSinceLastRepair);
const record = await this.prisma.equipment.update({ where: { id }, data });
const record = await this.prisma.equipment.update({ where: { id: id } as any, data });
return serializeRecord(record);
}
async remove(id: string) {
const record = await this.prisma.equipment.delete({ where: { id } });
const record = await this.prisma.equipment.delete({ where: { id: id } as any });
return serializeRecord(record);
}
}

View File

@@ -1,9 +1,9 @@
export class CreateRepairOrderDto {
number: string;
equipmentId: string;
repairKind: string;
status?: string;
plannedAt: string;
number!: string;
equipmentId!: string;
repairKind!: string;
status!: string;
plannedAt!: string;
startedAt?: string;
completedAt?: string;
contractor?: string;

View File

@@ -1,4 +1,5 @@
export class UpdateRepairOrderDto {
id?: string;
number?: string;
equipmentId?: string;
repairKind?: string;

View File

@@ -8,12 +8,12 @@ import { UpdateRepairOrderDto } from './dto/update-repair-order.dto';
@Controller('repair-orders')
export class RepairOrderController {
constructor(private readonly repairOrderService: RepairOrderService) {}
constructor(private readonly service: RepairOrderService) {}
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
@Get()
async findAll(@Query() query: any, @Res() res: Response) {
const result = await this.repairOrderService.findAll(query);
const result = await this.service.findAll(query);
res.set('Content-Range', `repair-orders ${query._start || 0}-${query._end || result.total}/${result.total}`);
res.set('Access-Control-Expose-Headers', 'Content-Range');
return res.json(result.data);
@@ -22,24 +22,24 @@ export class RepairOrderController {
@Roles(RealmRole.Viewer, RealmRole.Editor, RealmRole.Admin)
@Get(':id')
findOne(@Param('id') id: string) {
return this.repairOrderService.findOne(id);
return this.service.findOne(id);
}
@Roles(RealmRole.Editor, RealmRole.Admin)
@Post()
create(@Body() dto: CreateRepairOrderDto) {
return this.repairOrderService.create(dto);
return this.service.create(dto);
}
@Roles(RealmRole.Editor, RealmRole.Admin)
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateRepairOrderDto) {
return this.repairOrderService.update(id, dto);
return this.service.update(id, dto);
}
@Roles(RealmRole.Admin)
@Delete(':id')
remove(@Param('id') id: string) {
return this.repairOrderService.remove(id);
return this.service.remove(id);
}
}

View File

@@ -23,66 +23,78 @@ export class RepairOrderService {
const end = parseInt(query._end) || 10;
const take = end - start;
const skip = start;
const sortField = query._sort || 'id';
const sortField = query._sort || 'number';
const prismaSortField = sortField === 'id' ? 'id' : sortField;
const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';
const where: any = {};
if (query.q) {
const q = String(query.q);
const ors: any[] = [];
ors.push({ number: { contains: q, mode: 'insensitive' } });
ors.push({ contractor: { contains: q, mode: 'insensitive' } });
ors.push({ description: { contains: q, mode: 'insensitive' } });
ors.push({ notes: { contains: q, mode: 'insensitive' } });
if (ors.length) where.OR = ors;
}
if (query.number) where.number = { contains: query.number, mode: 'insensitive' };
if (query.equipmentId) where.equipmentId = query.equipmentId;
if (query.repairKind) where.repairKind = query.repairKind;
if (query.status) where.status = query.status;
if (query.contractor) where.contractor = { contains: query.contractor, mode: 'insensitive' };
if (query.description) where.description = { contains: query.description, mode: 'insensitive' };
if (query.notes) where.notes = { contains: query.notes, mode: 'insensitive' };
if (query.equipmentId) where.equipmentId = query.equipmentId;
// Enum multi-value support (e.g. status=A&status=B)
if (query.repairKind) { const vals = Array.isArray(query.repairKind) ? query.repairKind : [query.repairKind]; where.repairKind = vals.length > 1 ? { in: vals } : vals[0]; }
if (query.status) { const vals = Array.isArray(query.status) ? query.status : [query.status]; where.status = vals.length > 1 ? { in: vals } : vals[0]; }
if (query.id) {
const ids = Array.isArray(query.id) ? query.id : [query.id];
where.id = { in: ids };
}
const [data, total] = await Promise.all([
this.prisma.repairOrder.findMany({
where,
skip,
take,
orderBy: { [sortField]: sortOrder },
}),
this.prisma.repairOrder.findMany({ where, skip, take, orderBy: { [prismaSortField]: sortOrder } }),
this.prisma.repairOrder.count({ where }),
]);
return {
data: data.map(serializeRecord),
total,
};
const mapped = data.map(serializeRecord);
return { data: mapped, total };
}
async findOne(id: string) {
const record = await this.prisma.repairOrder.findUniqueOrThrow({ where: { id } });
const record = await this.prisma.repairOrder.findUniqueOrThrow({ where: { id: id } as any });
return serializeRecord(record);
}
async create(dto: CreateRepairOrderDto) {
const data: any = { ...dto };
if (dto.plannedAt) data.plannedAt = new Date(dto.plannedAt);
if (dto.startedAt) data.startedAt = new Date(dto.startedAt);
if (dto.completedAt) data.completedAt = new Date(dto.completedAt);
if (dto.engineHoursAtRepair) data.engineHoursAtRepair = new Prisma.Decimal(dto.engineHoursAtRepair);
const data: any = { ...(dto as any) };
if (data.plannedAt) data.plannedAt = new Date(data.plannedAt);
if (data.startedAt) data.startedAt = new Date(data.startedAt);
if (data.completedAt) data.completedAt = new Date(data.completedAt);
if (data.engineHoursAtRepair) data.engineHoursAtRepair = new Prisma.Decimal(data.engineHoursAtRepair);
const record = await this.prisma.repairOrder.create({ data });
return serializeRecord(record);
}
async update(id: string, dto: UpdateRepairOrderDto) {
const { id: _pk, ...rest } = dto as any;
const data: any = { ...rest };
const data: any = { ...(dto as any) };
delete data.id;
delete data.id;
if (data.plannedAt) data.plannedAt = new Date(data.plannedAt);
if (data.startedAt) data.startedAt = new Date(data.startedAt);
if (data.completedAt) data.completedAt = new Date(data.completedAt);
if (data.engineHoursAtRepair !== undefined && data.engineHoursAtRepair !== null) data.engineHoursAtRepair = new Prisma.Decimal(data.engineHoursAtRepair);
const record = await this.prisma.repairOrder.update({ where: { id }, data });
const record = await this.prisma.repairOrder.update({ where: { id: id } as any, data });
return serializeRecord(record);
}
async remove(id: string) {
const record = await this.prisma.repairOrder.delete({ where: { id } });
const record = await this.prisma.repairOrder.delete({ where: { id: id } as any });
return serializeRecord(record);
}
}