Generate filtering/sorting and searchable dropdowns

Includes backend q search + generated list UX from DSL.
This commit is contained in:
time_
2026-03-18 19:49:07 +03:00
parent 33521016d3
commit 5b8d8a85c4
37 changed files with 1267 additions and 582 deletions

View File

@@ -1448,9 +1448,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1465,9 +1462,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1482,9 +1476,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1499,9 +1490,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1516,9 +1504,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1533,9 +1518,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1550,9 +1532,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1567,9 +1546,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1584,9 +1560,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1601,9 +1574,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1618,9 +1588,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1635,9 +1602,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1652,9 +1616,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [

View File

@@ -1,8 +1,24 @@
import { DataProvider, fetchUtils } from 'react-admin';
const apiUrl = 'http://localhost:3000';
const apiUrl = 'http://localhost:3001';
const httpClient = fetchUtils.fetchJson;
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!;
@@ -10,23 +26,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);
@@ -55,15 +63,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';
import { Create, SimpleForm, TextInput } from 'react-admin';
export const EquipmentTypeCreate = () => (
<Create>
<SimpleForm>
<TextInput source="code" label="Код" isRequired />
<TextInput source="name" label="Наименование" isRequired />
<TextInput source="manufacturer" label="Производитель" />
<NumberInput source="maintenanceIntervalHours" label="Периодичность ТО (ч)" />
<NumberInput source="overhaulIntervalHours" label="Периодичность КР (ч)" />
<TextInput source="code" label="code" isRequired />
<TextInput source="name" label="name" isRequired />
<TextInput source="manufacturer" label="manufacturer" />
<TextInput source="maintenanceIntervalHours" label="maintenanceIntervalHours" />
<TextInput source="overhaulIntervalHours" label="overhaulIntervalHours" />
</SimpleForm>
</Create>
);

View File

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

View File

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

View File

@@ -1,36 +1,28 @@
import {
Create,
SimpleForm,
TextInput,
NumberInput,
DateInput,
SelectInput,
ReferenceInput,
} from 'react-admin';
import { Create, SimpleForm, TextInput, SelectInput, ReferenceInput, AutocompleteInput } from 'react-admin';
const statusChoices = [
{ id: 'Active', name: 'В эксплуатации' },
{ id: 'Repair', name: 'В ремонте' },
{ id: 'Reserve', name: 'В резерве' },
{ id: 'WriteOff', name: 'Списано' },
{ id: 'Active', name: 'Active' },
{ id: 'Repair', name: 'Repair' },
{ id: 'Reserve', name: 'Reserve' },
{ id: 'WriteOff', name: 'WriteOff' },
];
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="inventoryNumber" label="inventoryNumber" isRequired />
<TextInput source="serialNumber" label="serialNumber" />
<TextInput source="name" label="name" isRequired />
<ReferenceInput source="equipmentTypeCode" reference="equipment-types" label="equipmentTypeCode">
<AutocompleteInput 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="Место эксплуатации" />
<DateInput source="commissionedAt" label="Дата ввода в эксплуатацию" />
<NumberInput source="totalEngineHours" label="Общая наработка (ч)" />
<NumberInput source="engineHoursSinceLastRepair" label="Наработка с последнего ремонта (ч)" />
<DateInput source="lastRepairAt" label="Дата последнего ремонта" />
<TextInput source="notes" label="Примечания" multiline />
<SelectInput source="status" label="status" choices={statusChoices} emptyText="Не выбрано" />
<TextInput source="location" label="location" />
<TextInput source="commissionedAt" label="commissionedAt" />
<TextInput source="totalEngineHours" label="totalEngineHours" />
<TextInput source="engineHoursSinceLastRepair" label="engineHoursSinceLastRepair" />
<TextInput source="lastRepairAt" label="lastRepairAt" />
<TextInput source="notes" label="notes" />
</SimpleForm>
</Create>
);

View File

@@ -1,36 +1,29 @@
import {
Edit,
SimpleForm,
TextInput,
NumberInput,
DateInput,
SelectInput,
ReferenceInput,
} from 'react-admin';
import { Edit, SimpleForm, TextInput, SelectInput, ReferenceInput, AutocompleteInput } from 'react-admin';
const statusChoices = [
{ id: 'Active', name: 'В эксплуатации' },
{ id: 'Repair', name: 'В ремонте' },
{ id: 'Reserve', name: 'В резерве' },
{ id: 'WriteOff', name: 'Списано' },
{ id: 'Active', name: 'Active' },
{ id: 'Repair', name: 'Repair' },
{ id: 'Reserve', name: 'Reserve' },
{ id: 'WriteOff', name: 'WriteOff' },
];
export const EquipmentEdit = () => (
<Edit>
<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="id" label="id" disabled />
<TextInput source="inventoryNumber" label="inventoryNumber" isRequired />
<TextInput source="serialNumber" label="serialNumber" />
<TextInput source="name" label="name" isRequired />
<ReferenceInput source="equipmentTypeCode" reference="equipment-types" label="equipmentTypeCode">
<AutocompleteInput 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="Место эксплуатации" />
<DateInput source="commissionedAt" label="Дата ввода в эксплуатацию" />
<NumberInput source="totalEngineHours" label="Общая наработка (ч)" />
<NumberInput source="engineHoursSinceLastRepair" label="Наработка с последнего ремонта (ч)" />
<DateInput source="lastRepairAt" label="Дата последнего ремонта" />
<TextInput source="notes" label="Примечания" multiline />
<SelectInput source="status" label="status" choices={statusChoices} emptyText="Не выбрано" />
<TextInput source="location" label="location" />
<TextInput source="commissionedAt" label="commissionedAt" />
<TextInput source="totalEngineHours" label="totalEngineHours" />
<TextInput source="engineHoursSinceLastRepair" label="engineHoursSinceLastRepair" />
<TextInput source="lastRepairAt" label="lastRepairAt" />
<TextInput source="notes" label="notes" />
</SimpleForm>
</Edit>
);

View File

@@ -2,29 +2,65 @@ import {
List,
Datagrid,
TextField,
TextInput,
TopToolbar,
FilterButton,
CreateButton,
ExportButton,
NumberField,
DateField,
SelectField,
ReferenceField,
SelectArrayInput,
ReferenceInput,
AutocompleteInput
} from 'react-admin';
const statusChoices = [
{ id: 'Active', name: 'В эксплуатации' },
{ id: 'Repair', name: 'В ремонте' },
{ id: 'Reserve', name: 'В резерве' },
{ id: 'WriteOff', name: 'Списано' },
{ id: 'Active', name: 'Active' },
{ id: 'Repair', name: 'Repair' },
{ id: 'Reserve', name: 'Reserve' },
{ id: 'WriteOff', name: 'WriteOff' },
];
const equipmentFilters = [
<TextInput key="q" source="q" label="Поиск" alwaysOn />,
<TextInput key="inventoryNumber" source="inventoryNumber" label="inventoryNumber" />,
<TextInput key="serialNumber" source="serialNumber" label="serialNumber" />,
<TextInput key="name" source="name" label="name" />,
<ReferenceInput key="equipmentTypeCode" source="equipmentTypeCode" reference="equipment-types" label="equipmentTypeCode">
<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="status" choices={statusChoices} />,
<TextInput key="location" source="location" label="location" />,
<TextInput key="notes" source="notes" label="notes" />
];
const EquipmentListActions = () => (
<TopToolbar>
<FilterButton filters={equipmentFilters} />
<CreateButton />
<ExportButton />
</TopToolbar>
);
export const EquipmentList = () => (
<List>
<List actions={<EquipmentListActions />} filters={equipmentFilters} sort={{ field: 'id', order: 'ASC' }}>
<Datagrid rowClick="show">
<TextField source="inventoryNumber" label="Инвентарный номер" />
<TextField source="name" label="Наименование" />
<ReferenceField source="equipmentTypeCode" reference="equipment-types" label="Вид оборудования" link="show">
<TextField source="id" label="id" />
<TextField source="inventoryNumber" label="inventoryNumber" />
<TextField source="serialNumber" label="serialNumber" />
<TextField source="name" label="name" />
<ReferenceField source="equipmentTypeCode" reference="equipment-types" label="equipmentTypeCode" link="show">
<TextField source="name" />
</ReferenceField>
<SelectField source="status" label="Статус" choices={statusChoices} />
<TextField source="location" label="Место эксплуатации" />
<NumberField source="totalEngineHours" label="Наработка (ч)" />
<SelectField source="status" label="status" choices={statusChoices} />
<TextField source="location" label="location" />
<DateField source="commissionedAt" label="commissionedAt" />
<NumberField source="totalEngineHours" label="totalEngineHours" />
<NumberField source="engineHoursSinceLastRepair" label="engineHoursSinceLastRepair" />
<DateField source="lastRepairAt" label="lastRepairAt" />
<TextField source="notes" label="notes" />
</Datagrid>
</List>
);

View File

@@ -1,36 +1,20 @@
import {
Show,
SimpleShowLayout,
TextField,
NumberField,
DateField,
SelectField,
ReferenceField,
} from 'react-admin';
const statusChoices = [
{ id: 'Active', name: 'В эксплуатации' },
{ id: 'Repair', name: 'В ремонте' },
{ id: 'Reserve', name: 'В резерве' },
{ id: 'WriteOff', name: 'Списано' },
];
import { Show, SimpleShowLayout, TextField } from 'react-admin';
export const EquipmentShow = () => (
<Show>
<SimpleShowLayout>
<TextField source="inventoryNumber" label="Инвентарный номер" />
<TextField source="serialNumber" label="Заводской номер" />
<TextField source="name" label="Наименование" />
<ReferenceField source="equipmentTypeCode" reference="equipment-types" label="Вид оборудования" link="show">
<TextField source="name" />
</ReferenceField>
<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="Примечания" />
<TextField source="id" label="id" />
<TextField source="inventoryNumber" label="inventoryNumber" />
<TextField source="serialNumber" label="serialNumber" />
<TextField source="name" label="name" />
<TextField source="equipmentTypeCode" label="equipmentTypeCode" />
<TextField source="status" label="status" />
<TextField source="location" label="location" />
<TextField source="commissionedAt" label="commissionedAt" />
<TextField source="totalEngineHours" label="totalEngineHours" />
<TextField source="engineHoursSinceLastRepair" label="engineHoursSinceLastRepair" />
<TextField source="lastRepairAt" label="lastRepairAt" />
<TextField source="notes" label="notes" />
</SimpleShowLayout>
</Show>
);

View File

@@ -1,46 +1,38 @@
import {
Create,
SimpleForm,
TextInput,
NumberInput,
DateInput,
SelectInput,
ReferenceInput,
} from 'react-admin';
import { Create, SimpleForm, TextInput, SelectInput, ReferenceInput, AutocompleteInput } from 'react-admin';
const repairKindChoices = [
{ id: 'TO', name: 'Техническое обслуживание' },
{ id: 'TR', name: 'Текущий ремонт' },
{ id: 'TRE', name: 'Текущий расширенный ремонт' },
{ id: 'KR', name: 'Капитальный ремонт' },
{ id: 'AR', name: 'Аварийный ремонт' },
{ id: 'MP', name: 'Метрологическая поверка' },
{ id: 'TO', name: 'TO' },
{ id: 'TR', name: 'TR' },
{ id: 'TRE', name: 'TRE' },
{ id: 'KR', name: 'KR' },
{ id: 'AR', name: 'AR' },
{ id: 'MP', name: 'MP' },
];
const statusChoices = [
{ id: 'Draft', name: 'Черновик' },
{ id: 'Approved', name: 'Утверждена' },
{ id: 'InWork', name: 'В работе' },
{ id: 'Done', name: 'Выполнена' },
{ id: 'Cancelled', name: 'Отменена' },
{ id: 'Draft', name: 'Draft' },
{ id: 'Approved', name: 'Approved' },
{ id: 'InWork', name: 'InWork' },
{ id: 'Done', name: 'Done' },
{ id: 'Cancelled', name: 'Cancelled' },
];
export const RepairOrderCreate = () => (
<Create>
<SimpleForm>
<TextInput source="number" label="Номер заявки" isRequired />
<ReferenceInput source="equipmentId" reference="equipment" label="Оборудование">
<SelectInput optionText="name" isRequired />
<TextInput source="number" label="number" isRequired />
<ReferenceInput source="equipmentId" reference="equipment" label="equipmentId">
<AutocompleteInput optionText={(record) => record.code ? `${record.code}${record.name ?? record.code}` : (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 />
<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 />
<SelectInput source="repairKind" label="repairKind" choices={repairKindChoices} emptyText="Не выбрано" />
<SelectInput source="status" label="status" choices={statusChoices} emptyText="Не выбрано" />
<TextInput source="plannedAt" label="plannedAt" />
<TextInput source="startedAt" label="startedAt" />
<TextInput source="completedAt" label="completedAt" />
<TextInput source="contractor" label="contractor" />
<TextInput source="engineHoursAtRepair" label="engineHoursAtRepair" />
<TextInput source="description" label="description" />
<TextInput source="notes" label="notes" />
</SimpleForm>
</Create>
);

View File

@@ -1,46 +1,39 @@
import {
Edit,
SimpleForm,
TextInput,
NumberInput,
DateInput,
SelectInput,
ReferenceInput,
} from 'react-admin';
import { Edit, SimpleForm, TextInput, SelectInput, ReferenceInput, AutocompleteInput } from 'react-admin';
const repairKindChoices = [
{ id: 'TO', name: 'Техническое обслуживание' },
{ id: 'TR', name: 'Текущий ремонт' },
{ id: 'TRE', name: 'Текущий расширенный ремонт' },
{ id: 'KR', name: 'Капитальный ремонт' },
{ id: 'AR', name: 'Аварийный ремонт' },
{ id: 'MP', name: 'Метрологическая поверка' },
{ id: 'TO', name: 'TO' },
{ id: 'TR', name: 'TR' },
{ id: 'TRE', name: 'TRE' },
{ id: 'KR', name: 'KR' },
{ id: 'AR', name: 'AR' },
{ id: 'MP', name: 'MP' },
];
const statusChoices = [
{ id: 'Draft', name: 'Черновик' },
{ id: 'Approved', name: 'Утверждена' },
{ id: 'InWork', name: 'В работе' },
{ id: 'Done', name: 'Выполнена' },
{ id: 'Cancelled', name: 'Отменена' },
{ id: 'Draft', name: 'Draft' },
{ id: 'Approved', name: 'Approved' },
{ id: 'InWork', name: 'InWork' },
{ id: 'Done', name: 'Done' },
{ id: 'Cancelled', name: 'Cancelled' },
];
export const RepairOrderEdit = () => (
<Edit>
<SimpleForm>
<TextInput source="number" label="Номер заявки" isRequired />
<ReferenceInput source="equipmentId" reference="equipment" label="Оборудование">
<SelectInput optionText="name" isRequired />
<TextInput source="id" label="id" disabled />
<TextInput source="number" label="number" isRequired />
<ReferenceInput source="equipmentId" reference="equipment" label="equipmentId">
<AutocompleteInput optionText={(record) => record.code ? `${record.code}${record.name ?? record.code}` : (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 />
<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 />
<SelectInput source="repairKind" label="repairKind" choices={repairKindChoices} emptyText="Не выбрано" />
<SelectInput source="status" label="status" choices={statusChoices} emptyText="Не выбрано" />
<TextInput source="plannedAt" label="plannedAt" />
<TextInput source="startedAt" label="startedAt" />
<TextInput source="completedAt" label="completedAt" />
<TextInput source="contractor" label="contractor" />
<TextInput source="engineHoursAtRepair" label="engineHoursAtRepair" />
<TextInput source="description" label="description" />
<TextInput source="notes" label="notes" />
</SimpleForm>
</Edit>
);

View File

@@ -2,39 +2,76 @@ import {
List,
Datagrid,
TextField,
TextInput,
TopToolbar,
FilterButton,
CreateButton,
ExportButton,
NumberField,
DateField,
SelectField,
ReferenceField,
SelectArrayInput,
SelectInput,
ReferenceInput,
AutocompleteInput
} from 'react-admin';
const repairKindChoices = [
{ id: 'TO', name: 'Техническое обслуживание' },
{ id: 'TR', name: 'Текущий ремонт' },
{ id: 'TRE', name: 'Текущий расширенный ремонт' },
{ id: 'KR', name: 'Капитальный ремонт' },
{ id: 'AR', name: 'Аварийный ремонт' },
{ id: 'MP', name: 'Метрологическая поверка' },
{ id: 'TO', name: 'TO' },
{ id: 'TR', name: 'TR' },
{ id: 'TRE', name: 'TRE' },
{ id: 'KR', name: 'KR' },
{ id: 'AR', name: 'AR' },
{ id: 'MP', name: 'MP' },
];
const statusChoices = [
{ id: 'Draft', name: 'Черновик' },
{ id: 'Approved', name: 'Утверждена' },
{ id: 'InWork', name: 'В работе' },
{ id: 'Done', name: 'Выполнена' },
{ id: 'Cancelled', name: 'Отменена' },
{ id: 'Draft', name: 'Draft' },
{ id: 'Approved', name: 'Approved' },
{ id: 'InWork', name: 'InWork' },
{ id: 'Done', name: 'Done' },
{ id: 'Cancelled', name: 'Cancelled' },
];
const repairOrderFilters = [
<TextInput key="q" source="q" label="Поиск" alwaysOn />,
<TextInput key="number" source="number" label="number" />,
<ReferenceInput key="equipmentId" source="equipmentId" reference="equipment" label="equipmentId">
<AutocompleteInput optionText={(record) => record.code ? `${record.code}${record.name ?? record.code}` : (record.name ?? record.id)} filterToQuery={(searchText) => ({ q: searchText })} />
</ReferenceInput>,
<SelectInput key="repairKind" source="repairKind" label="repairKind" choices={repairKindChoices} emptyText="Все" />,
<SelectArrayInput key="status" source="status" label="status" choices={statusChoices} />,
<TextInput key="contractor" source="contractor" label="contractor" />,
<TextInput key="description" source="description" label="description" />,
<TextInput key="notes" source="notes" label="notes" />
];
const RepairOrderListActions = () => (
<TopToolbar>
<FilterButton filters={repairOrderFilters} />
<CreateButton />
<ExportButton />
</TopToolbar>
);
export const RepairOrderList = () => (
<List>
<List actions={<RepairOrderListActions />} filters={repairOrderFilters} sort={{ field: 'id', order: 'ASC' }}>
<Datagrid rowClick="show">
<TextField source="number" label="Номер" />
<ReferenceField source="equipmentId" reference="equipment" label="Оборудование" link="show">
<TextField source="id" label="id" />
<TextField source="number" label="number" />
<ReferenceField source="equipmentId" reference="equipment" label="equipmentId" link="show">
<TextField source="name" />
</ReferenceField>
<SelectField source="repairKind" label="Вид ремонта" choices={repairKindChoices} />
<SelectField source="status" label="Статус" choices={statusChoices} />
<DateField source="plannedAt" label="Плановая дата" />
<TextField source="contractor" label="Подрядчик" />
<SelectField source="repairKind" label="repairKind" choices={repairKindChoices} />
<SelectField source="status" label="status" choices={statusChoices} />
<DateField source="plannedAt" label="plannedAt" />
<DateField source="startedAt" label="startedAt" />
<DateField source="completedAt" label="completedAt" />
<TextField source="contractor" label="contractor" />
<NumberField source="engineHoursAtRepair" label="engineHoursAtRepair" />
<TextField source="description" label="description" />
<TextField source="notes" label="notes" />
</Datagrid>
</List>
);

View File

@@ -1,46 +1,20 @@
import {
Show,
SimpleShowLayout,
TextField,
NumberField,
DateField,
SelectField,
ReferenceField,
} from 'react-admin';
const repairKindChoices = [
{ id: 'TO', name: 'Техническое обслуживание' },
{ id: 'TR', name: 'Текущий ремонт' },
{ id: 'TRE', name: 'Текущий расширенный ремонт' },
{ id: 'KR', name: 'Капитальный ремонт' },
{ id: 'AR', name: 'Аварийный ремонт' },
{ id: 'MP', name: 'Метрологическая поверка' },
];
const statusChoices = [
{ id: 'Draft', name: 'Черновик' },
{ id: 'Approved', name: 'Утверждена' },
{ id: 'InWork', name: 'В работе' },
{ id: 'Done', name: 'Выполнена' },
{ id: 'Cancelled', name: 'Отменена' },
];
import { Show, SimpleShowLayout, TextField } from 'react-admin';
export const RepairOrderShow = () => (
<Show>
<SimpleShowLayout>
<TextField source="number" label="Номер заявки" />
<ReferenceField source="equipmentId" reference="equipment" label="Оборудование" link="show">
<TextField source="name" />
</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="description" label="Описание работ / дефекта" />
<TextField source="notes" label="Примечания" />
<TextField source="id" label="id" />
<TextField source="number" label="number" />
<TextField source="equipmentId" label="equipmentId" />
<TextField source="repairKind" label="repairKind" />
<TextField source="status" label="status" />
<TextField source="plannedAt" label="plannedAt" />
<TextField source="startedAt" label="startedAt" />
<TextField source="completedAt" label="completedAt" />
<TextField source="contractor" label="contractor" />
<TextField source="engineHoursAtRepair" label="engineHoursAtRepair" />
<TextField source="description" label="description" />
<TextField source="notes" label="notes" />
</SimpleShowLayout>
</Show>
);

View File

@@ -8,7 +8,7 @@ services:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: toir
ports:
- "5432:5432"
- "5433:5432"
volumes:
- postgres_data:/var/lib/postgresql/data

View File

@@ -61,7 +61,6 @@ enum RepairOrderStatus {
}
}
// ─────────────────────────────────────────────
// Справочник: Вид оборудования
// ─────────────────────────────────────────────
@@ -255,3 +254,4 @@ entity RepairOrder {
type text;
}
}

View File

@@ -43,6 +43,23 @@ React Admin
<SelectInput source="status" choices={statusChoices} />
## List filtering UX rules
- Lists should provide a visible filter UX:
- Use `List` `filters` prop to define filter inputs.
- Use an actions toolbar that includes `FilterButton` so users can add/remove non-`alwaysOn` filters.
## Multi-select enum filters
When filtering by enum values where users often need multiple selections (e.g. `status`), use:
`SelectArrayInput` in list filters, and ensure the backend supports repeated query params (e.g. `status=A&status=B`).
## Reference selection UX rules
For foreign keys, prefer `ReferenceInput` + `AutocompleteInput` over `SelectInput`.
Autocomplete search should send `q` to backend using `filterToQuery={(searchText) => ({ q: searchText })}`.
---
# Foreign Key Example

View File

@@ -68,6 +68,27 @@ Use mapping rules from `backend/prisma-rules.md`:
- DSL `decimal` -> DTO `string`
- DSL `date` -> DTO `string` (ISO)
## Filtering & search contract (must be generated)
React Admin uses query parameters for pagination, sorting, and filtering.
- **Pagination**: `_start`, `_end`
- **Sorting**: `_sort`, `_order`
- **Filtering**: arbitrary field keys in query string
Additionally, to support `AutocompleteInput` search for references, list endpoints must support:
- `q`: a generic search term that can be applied as an `OR` over a few human-meaningful fields (e.g. code/name/manufacturer, inventoryNumber/name, etc.)
### Multi-value filter support
For enum-like fields (e.g. `status`) the backend must accept both:
- `status=Active` (single value)
- `status=Active&status=Repair` (multiple values)
Services must treat repeated query params as arrays and translate them to Prisma `in` filters.
---
# Step 4 — Runtime infrastructure

View File

@@ -2,6 +2,23 @@
This document describes the **developer workflow** for running a generated fullstack application locally. The generator must produce a project that supports this workflow so the app is **fully runnable** after generation.
## Regenerating code from DSL
If the domain DSL changes (e.g. a new entity is added), regenerate backend + frontend artifacts from `examples/TOiR.domain.dsl`:
```bash
cd server
npm run generate:from-dsl
```
Then apply the updated schema and seed data:
```bash
cd server
npx prisma db push
npx prisma db seed
```
---
# Prerequisites

View File

@@ -19,6 +19,26 @@ EntityCreate.tsx
EntityEdit.tsx
EntityShow.tsx
## List UX requirements (must be generated)
- Lists must include **filtering UI** via `filters` prop on `List` and an explicit actions toolbar with:
- `FilterButton` (so non-`alwaysOn` filters are discoverable)
- `CreateButton`
- `ExportButton`
- Lists must include a **default sort** (`sort={{ field: "...", order: "ASC|DESC" }}`) appropriate for the entity.
## Reference selection UX (must be generated)
- For foreign keys (`ReferenceInput`) in Create/Edit forms, prefer `AutocompleteInput` over `SelectInput` to support search.
- Autocomplete must send search text to backend using `filterToQuery={(searchText) => ({ q: searchText })}`.
- Option text must include a **code** (or business identifier) and a name when available, e.g. `CODE — NAME`.
## Enum filters (must be generated)
- For enum fields in **list filters**, use:
- `SelectInput` for single-select filters
- `SelectArrayInput` for multi-select filters when users need to filter by multiple enum values (e.g. Status).
---
# Step 3 — Map Fields

562
generation/generate.mjs Normal file
View File

@@ -0,0 +1,562 @@
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 re = /^\s*value\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{/gm;
let m;
while ((m = re.exec(body))) values.push(m[1]);
return { values };
}
function parseEntity(body) {
const attrs = [];
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);
attrs.push({
name,
type,
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 };
}
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 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)} | null;`);
}
updateDtoLines.push('}');
const controller = `import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common';\nimport { Response } from 'express';\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 @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 @Get(':${pk}')\n findOne(@Param('${pk}') id: string) {\n return this.service.findOne(id);\n }\n\n @Post()\n create(@Body() dto: Create${className}Dto) {\n return this.service.create(dto);\n }\n\n @Patch(':${pk}')\n update(@Param('${pk}') id: string, @Body() dto: Update${className}Dto) {\n return this.service.update(id, dto);\n }\n\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\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 || '${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))
.map((a) => `if (query.${a.name}) where.${a.name} = { contains: query.${a.name}, mode: 'insensitive' };`)
.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' : `data.map((r: any) => ({ id: r.${pk}, ...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' ? 'record' : `{ id: (record as any).${pk}, ...record }`};\n }\n\n async create(dto: Create${className}Dto) {\n const record = await this.prisma.${lowerFirst(className)}.create({ data: dto as any });\n return ${pk === 'id' ? 'record' : `{ id: (record as any).${pk}, ...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 const record = await this.prisma.${lowerFirst(className)}.update({ where: { ${pk}: id } as any, data });\n return ${pk === 'id' ? 'record' : `{ id: (record as any).${pk}, ...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' ? 'record' : `{ id: (record as any).${pk}, ...record }`};\n }\n}\n`;
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`]: service,
[`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) {
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 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 constName = `${a.name}Choices`;
if (a.name === 'status') {
choiceConsts.push(
`const statusChoices = [\n${values.map((v) => ` { id: '${v}', name: '${v}' },`).join('\n')}\n];\n`
);
} else {
choiceConsts.push(
`const ${constName} = [\n${values.map((v) => ` { id: '${v}', name: '${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) {
if (a.name === pk) continue;
if (a.foreign) {
filterInputs.push(
`<ReferenceInput key="${a.name}" source="${a.name}" reference="${pluralize(toKebab(a.foreign.entity))}" label="${a.name}">\n <AutocompleteInput optionText={(record) => record.code ? \`\${record.code} — \${record.name ?? record.code}\` : (record.name ?? record.id)} filterToQuery={(searchText) => ({ q: searchText })} />\n </ReferenceInput>`
);
continue;
}
if (['string', 'text', 'uuid'].includes(a.type)) {
filterInputs.push(`<TextInput key="${a.name}" source="${a.name}" label="${a.name}" />`);
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="${a.name}" choices={statusChoices} />`);
} else {
filterInputs.push(`<SelectInput key="${a.name}" source="${a.name}" label="${a.name}" choices={${a.name}Choices} emptyText="Все" />`);
}
}
const listFields = [];
for (const a of entity.attributes) {
if (a.foreign) {
listFields.push(
`<ReferenceField source="${a.name}" reference="${pluralize(toKebab(a.foreign.entity))}" label="${a.name}" link="show">\n <TextField source="name" />\n </ReferenceField>`
);
continue;
}
if (a.type === 'date') {
listFields.push(`<DateField source="${a.name}" label="${a.name}" />`);
} else if (['integer', 'decimal'].includes(a.type)) {
listFields.push(`<NumberField source="${a.name}" label="${a.name}" />`);
} else if (!['string', 'text', 'uuid', 'integer', 'decimal', 'date'].includes(a.type)) {
listFields.push(`<SelectField source="${a.name}" label="${a.name}" choices={${a.name === 'status' ? 'statusChoices' : `${a.name}Choices`}} />`);
} else {
listFields.push(`<TextField source="${a.name}" label="${a.name}" />`);
}
}
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: '${pk}', order: 'ASC' }}>\n <Datagrid rowClick=\"show\">\n ${listFields.join('\n ')}\n </Datagrid>\n </List>\n);\n`;
const formField = (a, mode) => {
if (a.isPrimary && mode === 'create' && a.type === 'uuid') return null;
if (a.isPrimary && mode === 'edit') {
return `<TextInput source="${a.name}" label="${a.name}" disabled />`;
}
if (a.foreign) {
return `<ReferenceInput source="${a.name}" reference="${pluralize(toKebab(a.foreign.entity))}" label="${a.name}">\n <AutocompleteInput optionText={(record) => record.code ? \`\${record.code} — \${record.name ?? record.code}\` : (record.name ?? record.id)} filterToQuery={(searchText) => ({ q: searchText })} />\n </ReferenceInput>`;
}
if (a.type === 'date') return `<TextInput source="${a.name}" label="${a.name}" />`;
if (['integer', 'decimal'].includes(a.type)) return `<TextInput source="${a.name}" label="${a.name}" />`;
if (!['string', 'text', 'uuid', 'integer', 'decimal', 'date'].includes(a.type)) {
if (a.name === 'status' && statusEnumAttr) return `<SelectInput source="${a.name}" label="${a.name}" choices={statusChoices} emptyText="Не выбрано" />`;
return `<SelectInput source="${a.name}" label="${a.name}" choices={${a.name}Choices} emptyText="Не выбрано" />`;
}
return `<TextInput source="${a.name}" label="${a.name}" ${a.isRequired ? 'isRequired' : ''} />`;
};
const formImportSet = new Set(['SimpleForm', 'TextInput']);
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 show = `import { Show, SimpleShowLayout, TextField } from 'react-admin';\n\nexport const ${className}Show = () => (\n <Show>\n <SimpleShowLayout>\n ${entity.attributes.map((a) => `<TextField source="${a.name}" label="${a.name}" />`).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} }`)) {
out = out.replace(
/import\s+\{\s*RepairOrderModule\s*\}[^;]*;\s*/m,
(x) => `${x}import { ${m.moduleName} } from '${m.importPath}';\n`
);
}
}
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)) {
out = out.replace(/import\s+\{\s*RepairOrderShow\s*\}[^;]*;\s*/m, (x) => `${x}\n${imports.join('\n')}\n`);
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] : 'examples/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);
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

@@ -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 examples/TOiR.domain.dsl",
"postinstall": "prisma generate"
},
"prisma": {

View File

@@ -32,43 +32,43 @@ enum RepairOrderStatus {
}
model EquipmentType {
code String @id
name String
manufacturer String?
code String @id
name String
manufacturer String?
maintenanceIntervalHours Int?
overhaulIntervalHours Int?
equipment Equipment[]
overhaulIntervalHours Int?
equipment Equipment[]
}
model Equipment {
id String @id @default(uuid())
inventoryNumber String @unique
serialNumber String?
name String
equipmentTypeCode String
equipmentType EquipmentType @relation(fields: [equipmentTypeCode], references: [code])
status EquipmentStatus @default(Active)
location String?
commissionedAt DateTime?
totalEngineHours Decimal?
engineHoursSinceLastRepair Decimal?
lastRepairAt DateTime?
notes String?
repairOrders RepairOrder[]
id String @id @default(uuid())
inventoryNumber String @unique
serialNumber String?
name String
equipmentTypeCode String
status EquipmentStatus @default(Active)
location String?
commissionedAt DateTime?
totalEngineHours Decimal?
engineHoursSinceLastRepair Decimal?
lastRepairAt DateTime?
notes String?
equipmentType EquipmentType @relation(fields: [equipmentTypeCode], references: [code])
repairOrders RepairOrder[]
}
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
startedAt DateTime?
completedAt DateTime?
contractor String?
engineHoursAtRepair Decimal?
description String?
notes String?
id String @id @default(uuid())
number String @unique
equipmentId String
repairKind RepairKind
status RepairOrderStatus @default(Draft)
plannedAt DateTime
startedAt DateTime?
completedAt DateTime?
contractor String?
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,
},
});
const equipment = await prisma.equipment.upsert({
where: { inventoryNumber: 'INV-001' },
update: {},
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,
{
code: 'generator',
name: 'Дизель-генератор',
manufacturer: 'АО ЭнергоМаш',
maintenanceIntervalHours: 500,
overhaulIntervalHours: 6000,
},
});
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,
{
code: 'valve',
name: 'Запорная арматура',
manufacturer: 'ЗАО АрматурПром',
maintenanceIntervalHours: 1000,
overhaulIntervalHours: 10000,
},
});
await prisma.repairOrder.upsert({
where: { number: 'RO-2026-001' },
update: {},
create: {
number: 'RO-2026-001',
equipmentId: equipment.id,
repairKind: 'TO',
status: 'Approved',
plannedAt: new Date('2026-04-01'),
contractor: 'ООО СервисРемонт',
engineHoursAtRepair: 4500,
description: 'Плановое техническое обслуживание насосного агрегата',
{
code: 'sensor',
name: 'Датчик давления',
manufacturer: 'ООО ПриборСервис',
maintenanceIntervalHours: 800,
overhaulIntervalHours: 8000,
},
});
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: 'Текущий ремонт компрессорной установки',
{
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;
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,
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 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 },
update: {
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,
},
create: {
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

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

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,6 +1,8 @@
export class UpdateEquipmentTypeDto {
name?: string;
manufacturer?: string;
maintenanceIntervalHours?: number;
overhaulIntervalHours?: number;
id?: string;
code?: string | null;
name?: string | null;
manufacturer?: string | null;
maintenanceIntervalHours?: number | null;
overhaulIntervalHours?: number | null;
}

View File

@@ -6,33 +6,33 @@ import { UpdateEquipmentTypeDto } from './dto/update-equipment-type.dto';
@Controller('equipment-types')
export class EquipmentTypeController {
constructor(private readonly equipmentTypeService: EquipmentTypeService) {}
constructor(private readonly service: EquipmentTypeService) {}
@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);
}
@Get(':code')
findOne(@Param('code') code: string) {
return this.equipmentTypeService.findOne(code);
findOne(@Param('code') id: string) {
return this.service.findOne(id);
}
@Post()
create(@Body() dto: CreateEquipmentTypeDto) {
return this.equipmentTypeService.create(dto);
return this.service.create(dto);
}
@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);
}
@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,4 +1,5 @@
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';
@@ -7,72 +8,66 @@ import { UpdateEquipmentTypeDto } from './dto/update-equipment-type.dto';
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 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: { [sortField]: sortOrder } }),
this.prisma.equipmentType.count({ where }),
]);
return {
data: data.map((item) => ({ id: item.code, ...item })),
total,
};
const mapped = data.map((r: any) => ({ id: r.code, ...r }));
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, ...record };
}
async create(dto: CreateEquipmentTypeDto) {
const record = await this.prisma.equipmentType.create({ data: dto });
return { id: record.code, ...record };
const record = await this.prisma.equipmentType.create({ data: dto as any });
return { id: (record as any).code, ...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 data: any = { ...(dto as any) };
delete data.id;
delete data.code;
const record = await this.prisma.equipmentType.update({ where: { code: id } as any, data });
return { id: (record as any).code, ...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, ...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,13 +1,14 @@
export class UpdateEquipmentDto {
inventoryNumber?: string;
serialNumber?: string;
name?: string;
equipmentTypeCode?: string;
status?: string;
location?: string;
commissionedAt?: string;
totalEngineHours?: string;
engineHoursSinceLastRepair?: string;
lastRepairAt?: string;
notes?: string;
id?: string | null;
inventoryNumber?: string | null;
serialNumber?: string | null;
name?: string | null;
equipmentTypeCode?: string | null;
status?: string | null;
location?: string | null;
commissionedAt?: string | null;
totalEngineHours?: string | null;
engineHoursSinceLastRepair?: string | null;
lastRepairAt?: string | null;
notes?: string | null;
}

View File

@@ -6,11 +6,11 @@ import { UpdateEquipmentDto } from './dto/update-equipment.dto';
@Controller('equipment')
export class EquipmentController {
constructor(private readonly equipmentService: EquipmentService) {}
constructor(private readonly service: EquipmentService) {}
@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);
@@ -18,21 +18,21 @@ export class EquipmentController {
@Get(':id')
findOne(@Param('id') id: string) {
return this.equipmentService.findOne(id);
return this.service.findOne(id);
}
@Post()
create(@Body() dto: CreateEquipmentDto) {
return this.equipmentService.create(dto);
return this.service.create(dto);
}
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateEquipmentDto) {
return this.equipmentService.update(id, dto);
return this.service.update(id, dto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.equipmentService.remove(id);
return this.service.remove(id);
}
}

View File

@@ -4,16 +4,6 @@ import { PrismaService } from '../../prisma/prisma.service';
import { CreateEquipmentDto } from './dto/create-equipment.dto';
import { UpdateEquipmentDto } from './dto/update-equipment.dto';
function serializeRecord(record: any) {
return {
...record,
totalEngineHours: record.totalEngineHours?.toString() ?? null,
engineHoursSinceLastRepair: record.engineHoursSinceLastRepair?.toString() ?? null,
commissionedAt: record.commissionedAt?.toISOString() ?? null,
lastRepairAt: record.lastRepairAt?.toISOString() ?? null,
};
}
@Injectable()
export class EquipmentService {
constructor(private readonly prisma: PrismaService) {}
@@ -27,62 +17,63 @@ export class EquipmentService {
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.equipmentTypeCode) where.equipmentTypeCode = { contains: query.equipmentTypeCode, mode: 'insensitive' };
if (query.location) where.location = { contains: query.location, mode: 'insensitive' };
if (query.notes) where.notes = { contains: query.notes, mode: 'insensitive' };
// 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: { [sortField]: sortOrder } }),
this.prisma.equipment.count({ where }),
]);
return {
data: data.map(serializeRecord),
total,
};
const mapped = data;
return { data: mapped, total };
}
async findOne(id: string) {
const record = await this.prisma.equipment.findUniqueOrThrow({ where: { id } });
return serializeRecord(record);
const record = await this.prisma.equipment.findUniqueOrThrow({ where: { id: id } as any });
return 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 record = await this.prisma.equipment.create({ data });
return serializeRecord(record);
const record = await this.prisma.equipment.create({ data: dto as any });
return record;
}
async update(id: string, dto: UpdateEquipmentDto) {
const { id: _pk, ...rest } = dto as any;
const data: any = { ...rest };
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 });
return serializeRecord(record);
const data: any = { ...(dto as any) };
delete data.id;
delete data.id;
const record = await this.prisma.equipment.update({ where: { id: id } as any, data });
return record;
}
async remove(id: string) {
const record = await this.prisma.equipment.delete({ where: { id } });
return serializeRecord(record);
const record = await this.prisma.equipment.delete({ where: { id: id } as any });
return 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,13 +1,14 @@
export class UpdateRepairOrderDto {
number?: string;
equipmentId?: string;
repairKind?: string;
status?: string;
plannedAt?: string;
startedAt?: string;
completedAt?: string;
contractor?: string;
engineHoursAtRepair?: string;
description?: string;
notes?: string;
id?: string | null;
number?: string | null;
equipmentId?: string | null;
repairKind?: string | null;
status?: string | null;
plannedAt?: string | null;
startedAt?: string | null;
completedAt?: string | null;
contractor?: string | null;
engineHoursAtRepair?: string | null;
description?: string | null;
notes?: string | null;
}

View File

@@ -6,11 +6,11 @@ import { UpdateRepairOrderDto } from './dto/update-repair-order.dto';
@Controller('repair-orders')
export class RepairOrderController {
constructor(private readonly repairOrderService: RepairOrderService) {}
constructor(private readonly service: RepairOrderService) {}
@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);
@@ -18,21 +18,21 @@ export class RepairOrderController {
@Get(':id')
findOne(@Param('id') id: string) {
return this.repairOrderService.findOne(id);
return this.service.findOne(id);
}
@Post()
create(@Body() dto: CreateRepairOrderDto) {
return this.repairOrderService.create(dto);
return this.service.create(dto);
}
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateRepairOrderDto) {
return this.repairOrderService.update(id, dto);
return this.service.update(id, dto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.repairOrderService.remove(id);
return this.service.remove(id);
}
}

View File

@@ -4,16 +4,6 @@ import { PrismaService } from '../../prisma/prisma.service';
import { CreateRepairOrderDto } from './dto/create-repair-order.dto';
import { UpdateRepairOrderDto } from './dto/update-repair-order.dto';
function serializeRecord(record: any) {
return {
...record,
engineHoursAtRepair: record.engineHoursAtRepair?.toString() ?? null,
plannedAt: record.plannedAt?.toISOString() ?? null,
startedAt: record.startedAt?.toISOString() ?? null,
completedAt: record.completedAt?.toISOString() ?? null,
};
}
@Injectable()
export class RepairOrderService {
constructor(private readonly prisma: PrismaService) {}
@@ -27,62 +17,60 @@ export class RepairOrderService {
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' };
// 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: { [sortField]: sortOrder } }),
this.prisma.repairOrder.count({ where }),
]);
return {
data: data.map(serializeRecord),
total,
};
const mapped = data;
return { data: mapped, total };
}
async findOne(id: string) {
const record = await this.prisma.repairOrder.findUniqueOrThrow({ where: { id } });
return serializeRecord(record);
const record = await this.prisma.repairOrder.findUniqueOrThrow({ where: { id: id } as any });
return 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 record = await this.prisma.repairOrder.create({ data });
return serializeRecord(record);
const record = await this.prisma.repairOrder.create({ data: dto as any });
return record;
}
async update(id: string, dto: UpdateRepairOrderDto) {
const { id: _pk, ...rest } = dto as any;
const data: any = { ...rest };
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 });
return serializeRecord(record);
const data: any = { ...(dto as any) };
delete data.id;
delete data.id;
const record = await this.prisma.repairOrder.update({ where: { id: id } as any, data });
return record;
}
async remove(id: string) {
const record = await this.prisma.repairOrder.delete({ where: { id } });
return serializeRecord(record);
const record = await this.prisma.repairOrder.delete({ where: { id: id } as any });
return record;
}
}