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

View File

@@ -1,8 +1,24 @@
import { DataProvider, fetchUtils } from 'react-admin'; import { DataProvider, fetchUtils } from 'react-admin';
const apiUrl = 'http://localhost:3000'; const apiUrl = 'http://localhost:3001';
const httpClient = fetchUtils.fetchJson; 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 = { const dataProvider: DataProvider = {
getList: async (resource, params) => { getList: async (resource, params) => {
const { page, perPage } = params.pagination!; const { page, perPage } = params.pagination!;
@@ -10,23 +26,15 @@ const dataProvider: DataProvider = {
const start = (page - 1) * perPage; const start = (page - 1) * perPage;
const end = page * perPage; const end = page * perPage;
const query: Record<string, string> = { const query: Record<string, unknown> = {
_start: String(start), _start: start,
_end: String(end), _end: end,
_sort: field, _sort: field,
_order: order, _order: order,
...(params.filter ?? {}),
}; };
if (params.filter) { const queryString = buildQueryString(query);
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 url = `${apiUrl}/${resource}?${queryString}`; const url = `${apiUrl}/${resource}?${queryString}`;
const { json, headers } = await httpClient(url); const { json, headers } = await httpClient(url);
@@ -55,15 +63,16 @@ const dataProvider: DataProvider = {
const start = (page - 1) * perPage; const start = (page - 1) * perPage;
const end = page * perPage; const end = page * perPage;
const query: Record<string, string> = { const query: Record<string, unknown> = {
_start: String(start), _start: start,
_end: String(end), _end: end,
_sort: field, _sort: field,
_order: order, _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 url = `${apiUrl}/${resource}?${queryString}`;
const { json, headers } = await httpClient(url); 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 = () => ( export const EquipmentTypeCreate = () => (
<Create> <Create>
<SimpleForm> <SimpleForm>
<TextInput source="code" label="Код" isRequired /> <TextInput source="code" label="code" isRequired />
<TextInput source="name" label="Наименование" isRequired /> <TextInput source="name" label="name" isRequired />
<TextInput source="manufacturer" label="Производитель" /> <TextInput source="manufacturer" label="manufacturer" />
<NumberInput source="maintenanceIntervalHours" label="Периодичность ТО (ч)" /> <TextInput source="maintenanceIntervalHours" label="maintenanceIntervalHours" />
<NumberInput source="overhaulIntervalHours" label="Периодичность КР (ч)" /> <TextInput source="overhaulIntervalHours" label="overhaulIntervalHours" />
</SimpleForm> </SimpleForm>
</Create> </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 = () => ( export const EquipmentTypeEdit = () => (
<Edit> <Edit>
<SimpleForm> <SimpleForm>
<TextInput source="code" label="Код" disabled /> <TextInput source="code" label="code" disabled />
<TextInput source="name" label="Наименование" isRequired /> <TextInput source="name" label="name" isRequired />
<TextInput source="manufacturer" label="Производитель" /> <TextInput source="manufacturer" label="manufacturer" />
<NumberInput source="maintenanceIntervalHours" label="Периодичность ТО (ч)" /> <TextInput source="maintenanceIntervalHours" label="maintenanceIntervalHours" />
<NumberInput source="overhaulIntervalHours" label="Периодичность КР (ч)" /> <TextInput source="overhaulIntervalHours" label="overhaulIntervalHours" />
</SimpleForm> </SimpleForm>
</Edit> </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 = () => ( export const EquipmentTypeList = () => (
<List> <List actions={<EquipmentTypeListActions />} filters={equipmentTypeFilters} sort={{ field: 'code', order: 'ASC' }}>
<Datagrid rowClick="show"> <Datagrid rowClick="show">
<TextField source="code" label="Код" /> <TextField source="code" label="code" />
<TextField source="name" label="Наименование" /> <TextField source="name" label="name" />
<TextField source="manufacturer" label="Производитель" /> <TextField source="manufacturer" label="manufacturer" />
<NumberField source="maintenanceIntervalHours" label="Периодичность ТО (ч)" /> <NumberField source="maintenanceIntervalHours" label="maintenanceIntervalHours" />
<NumberField source="overhaulIntervalHours" label="Периодичность КР (ч)" /> <NumberField source="overhaulIntervalHours" label="overhaulIntervalHours" />
</Datagrid> </Datagrid>
</List> </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 = () => ( export const EquipmentTypeShow = () => (
<Show> <Show>
<SimpleShowLayout> <SimpleShowLayout>
<TextField source="code" label="Код" /> <TextField source="code" label="code" />
<TextField source="name" label="Наименование" /> <TextField source="name" label="name" />
<TextField source="manufacturer" label="Производитель" /> <TextField source="manufacturer" label="manufacturer" />
<NumberField source="maintenanceIntervalHours" label="Периодичность ТО (ч)" /> <TextField source="maintenanceIntervalHours" label="maintenanceIntervalHours" />
<NumberField source="overhaulIntervalHours" label="Периодичность КР (ч)" /> <TextField source="overhaulIntervalHours" label="overhaulIntervalHours" />
</SimpleShowLayout> </SimpleShowLayout>
</Show> </Show>
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,46 +1,20 @@
import { import { Show, SimpleShowLayout, TextField } from 'react-admin';
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: 'Отменена' },
];
export const RepairOrderShow = () => ( export const RepairOrderShow = () => (
<Show> <Show>
<SimpleShowLayout> <SimpleShowLayout>
<TextField source="number" label="Номер заявки" /> <TextField source="id" label="id" />
<ReferenceField source="equipmentId" reference="equipment" label="Оборудование" link="show"> <TextField source="number" label="number" />
<TextField source="name" /> <TextField source="equipmentId" label="equipmentId" />
</ReferenceField> <TextField source="repairKind" label="repairKind" />
<SelectField source="repairKind" label="Вид ремонта" choices={repairKindChoices} /> <TextField source="status" label="status" />
<SelectField source="status" label="Статус" choices={statusChoices} /> <TextField source="plannedAt" label="plannedAt" />
<DateField source="plannedAt" label="Плановая дата начала" /> <TextField source="startedAt" label="startedAt" />
<DateField source="startedAt" label="Фактическая дата начала" /> <TextField source="completedAt" label="completedAt" />
<DateField source="completedAt" label="Фактическая дата завершения" /> <TextField source="contractor" label="contractor" />
<TextField source="contractor" label="Подрядная организация" /> <TextField source="engineHoursAtRepair" label="engineHoursAtRepair" />
<NumberField source="engineHoursAtRepair" label="Наработка на момент ремонта (ч)" /> <TextField source="description" label="description" />
<TextField source="description" label="Описание работ / дефекта" /> <TextField source="notes" label="notes" />
<TextField source="notes" label="Примечания" />
</SimpleShowLayout> </SimpleShowLayout>
</Show> </Show>
); );

View File

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

View File

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

View File

@@ -43,6 +43,23 @@ React Admin
<SelectInput source="status" choices={statusChoices} /> <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 # Foreign Key Example

View File

@@ -68,6 +68,27 @@ Use mapping rules from `backend/prisma-rules.md`:
- DSL `decimal` -> DTO `string` - DSL `decimal` -> DTO `string`
- DSL `date` -> DTO `string` (ISO) - 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 # 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. 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 # Prerequisites

View File

@@ -19,6 +19,26 @@ EntityCreate.tsx
EntityEdit.tsx EntityEdit.tsx
EntityShow.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 # 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:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "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", "test:e2e": "jest --config ./test/jest-e2e.json",
"generate:from-dsl": "node ../generation/generate.mjs --apply --dsl examples/TOiR.domain.dsl",
"postinstall": "prisma generate" "postinstall": "prisma generate"
}, },
"prisma": { "prisma": {

View File

@@ -32,43 +32,43 @@ enum RepairOrderStatus {
} }
model EquipmentType { model EquipmentType {
code String @id code String @id
name String name String
manufacturer String? manufacturer String?
maintenanceIntervalHours Int? maintenanceIntervalHours Int?
overhaulIntervalHours Int? overhaulIntervalHours Int?
equipment Equipment[] equipment Equipment[]
} }
model Equipment { model Equipment {
id String @id @default(uuid()) id String @id @default(uuid())
inventoryNumber String @unique inventoryNumber String @unique
serialNumber String? serialNumber String?
name String name String
equipmentTypeCode String equipmentTypeCode String
equipmentType EquipmentType @relation(fields: [equipmentTypeCode], references: [code]) status EquipmentStatus @default(Active)
status EquipmentStatus @default(Active) location String?
location String? commissionedAt DateTime?
commissionedAt DateTime? totalEngineHours Decimal?
totalEngineHours Decimal? engineHoursSinceLastRepair Decimal?
engineHoursSinceLastRepair Decimal? lastRepairAt DateTime?
lastRepairAt DateTime? notes String?
notes String? equipmentType EquipmentType @relation(fields: [equipmentTypeCode], references: [code])
repairOrders RepairOrder[] repairOrders RepairOrder[]
} }
model RepairOrder { model RepairOrder {
id String @id @default(uuid()) id String @id @default(uuid())
number String @unique number String @unique
equipmentId String equipmentId String
equipment Equipment @relation(fields: [equipmentId], references: [id]) repairKind RepairKind
repairKind RepairKind status RepairOrderStatus @default(Draft)
status RepairOrderStatus @default(Draft) plannedAt DateTime
plannedAt DateTime startedAt DateTime?
startedAt DateTime? completedAt DateTime?
completedAt DateTime? contractor String?
contractor String? engineHoursAtRepair Decimal?
engineHoursAtRepair Decimal? description String?
description String? notes 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(); const prisma = new PrismaClient();
async function main() { async function main() {
const equipmentType = await prisma.equipmentType.upsert({ const equipmentTypes = [
where: { code: 'pump' }, {
update: {},
create: {
code: 'pump', code: 'pump',
name: 'Насосный агрегат', name: 'Насосный агрегат',
manufacturer: 'АО НасосПром', manufacturer: 'АО НасосПром',
maintenanceIntervalHours: 2000, maintenanceIntervalHours: 2000,
overhaulIntervalHours: 16000, overhaulIntervalHours: 16000,
}, },
}); {
const equipmentType2 = await prisma.equipmentType.upsert({
where: { code: 'compressor' },
update: {},
create: {
code: 'compressor', code: 'compressor',
name: 'Компрессорная установка', name: 'Компрессорная установка',
manufacturer: 'ОАО Компрессормаш', manufacturer: 'ОАО Компрессормаш',
maintenanceIntervalHours: 1500, maintenanceIntervalHours: 1500,
overhaulIntervalHours: 12000, overhaulIntervalHours: 12000,
}, },
}); {
code: 'generator',
const equipment = await prisma.equipment.upsert({ name: 'Дизель-генератор',
where: { inventoryNumber: 'INV-001' }, manufacturer: 'АО ЭнергоМаш',
update: {}, maintenanceIntervalHours: 500,
create: { overhaulIntervalHours: 6000,
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: 'valve',
const equipment2 = await prisma.equipment.upsert({ name: 'Запорная арматура',
where: { inventoryNumber: 'INV-002' }, manufacturer: 'ЗАО АрматурПром',
update: {}, maintenanceIntervalHours: 1000,
create: { overhaulIntervalHours: 10000,
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: 'sensor',
await prisma.repairOrder.upsert({ name: 'Датчик давления',
where: { number: 'RO-2026-001' }, manufacturer: 'ООО ПриборСервис',
update: {}, maintenanceIntervalHours: 800,
create: { overhaulIntervalHours: 8000,
number: 'RO-2026-001',
equipmentId: equipment.id,
repairKind: 'TO',
status: 'Approved',
plannedAt: new Date('2026-04-01'),
contractor: 'ООО СервисРемонт',
engineHoursAtRepair: 4500,
description: 'Плановое техническое обслуживание насосного агрегата',
}, },
}); {
code: 'motor',
await prisma.repairOrder.upsert({ name: 'Электродвигатель',
where: { number: 'RO-2026-002' }, manufacturer: 'ПАО ЭлектроМотор',
update: {}, maintenanceIntervalHours: 1200,
create: { overhaulIntervalHours: 14000,
number: 'RO-2026-002',
equipmentId: equipment2.id,
repairKind: 'TR',
status: 'Draft',
plannedAt: new Date('2026-05-15'),
description: 'Текущий ремонт компрессорной установки',
}, },
}); {
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'); 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'; import { RepairOrderModule } from './modules/repair-order/repair-order.module';
@Module({ @Module({
imports: [ imports: [ConfigModule.forRoot({ isGlobal: true }),
ConfigModule.forRoot({ isGlobal: true }),
PrismaModule, PrismaModule,
HealthModule, HealthModule,
EquipmentTypeModule, EquipmentTypeModule,

View File

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

View File

@@ -1,6 +1,8 @@
export class UpdateEquipmentTypeDto { export class UpdateEquipmentTypeDto {
name?: string; id?: string;
manufacturer?: string; code?: string | null;
maintenanceIntervalHours?: number; name?: string | null;
overhaulIntervalHours?: number; 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') @Controller('equipment-types')
export class EquipmentTypeController { export class EquipmentTypeController {
constructor(private readonly equipmentTypeService: EquipmentTypeService) {} constructor(private readonly service: EquipmentTypeService) {}
@Get() @Get()
async findAll(@Query() query: any, @Res() res: Response) { 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('Content-Range', `equipment-types ${query._start || 0}-${query._end || result.total}/${result.total}`);
res.set('Access-Control-Expose-Headers', 'Content-Range'); res.set('Access-Control-Expose-Headers', 'Content-Range');
return res.json(result.data); return res.json(result.data);
} }
@Get(':code') @Get(':code')
findOne(@Param('code') code: string) { findOne(@Param('code') id: string) {
return this.equipmentTypeService.findOne(code); return this.service.findOne(id);
} }
@Post() @Post()
create(@Body() dto: CreateEquipmentTypeDto) { create(@Body() dto: CreateEquipmentTypeDto) {
return this.equipmentTypeService.create(dto); return this.service.create(dto);
} }
@Patch(':code') @Patch(':code')
update(@Param('code') code: string, @Body() dto: UpdateEquipmentTypeDto) { update(@Param('code') id: string, @Body() dto: UpdateEquipmentTypeDto) {
return this.equipmentTypeService.update(code, dto); return this.service.update(id, dto);
} }
@Delete(':code') @Delete(':code')
remove(@Param('code') code: string) { remove(@Param('code') id: string) {
return this.equipmentTypeService.remove(code); return this.service.remove(id);
} }
} }

View File

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

View File

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

View File

@@ -1,13 +1,14 @@
export class UpdateEquipmentDto { export class UpdateEquipmentDto {
inventoryNumber?: string; id?: string | null;
serialNumber?: string; inventoryNumber?: string | null;
name?: string; serialNumber?: string | null;
equipmentTypeCode?: string; name?: string | null;
status?: string; equipmentTypeCode?: string | null;
location?: string; status?: string | null;
commissionedAt?: string; location?: string | null;
totalEngineHours?: string; commissionedAt?: string | null;
engineHoursSinceLastRepair?: string; totalEngineHours?: string | null;
lastRepairAt?: string; engineHoursSinceLastRepair?: string | null;
notes?: string; lastRepairAt?: string | null;
notes?: string | null;
} }

View File

@@ -6,11 +6,11 @@ import { UpdateEquipmentDto } from './dto/update-equipment.dto';
@Controller('equipment') @Controller('equipment')
export class EquipmentController { export class EquipmentController {
constructor(private readonly equipmentService: EquipmentService) {} constructor(private readonly service: EquipmentService) {}
@Get() @Get()
async findAll(@Query() query: any, @Res() res: Response) { 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('Content-Range', `equipment ${query._start || 0}-${query._end || result.total}/${result.total}`);
res.set('Access-Control-Expose-Headers', 'Content-Range'); res.set('Access-Control-Expose-Headers', 'Content-Range');
return res.json(result.data); return res.json(result.data);
@@ -18,21 +18,21 @@ export class EquipmentController {
@Get(':id') @Get(':id')
findOne(@Param('id') id: string) { findOne(@Param('id') id: string) {
return this.equipmentService.findOne(id); return this.service.findOne(id);
} }
@Post() @Post()
create(@Body() dto: CreateEquipmentDto) { create(@Body() dto: CreateEquipmentDto) {
return this.equipmentService.create(dto); return this.service.create(dto);
} }
@Patch(':id') @Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateEquipmentDto) { update(@Param('id') id: string, @Body() dto: UpdateEquipmentDto) {
return this.equipmentService.update(id, dto); return this.service.update(id, dto);
} }
@Delete(':id') @Delete(':id')
remove(@Param('id') id: string) { 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 { CreateEquipmentDto } from './dto/create-equipment.dto';
import { UpdateEquipmentDto } from './dto/update-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() @Injectable()
export class EquipmentService { export class EquipmentService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
@@ -27,62 +17,63 @@ export class EquipmentService {
const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc'; const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';
const where: any = {}; 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.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.name) where.name = { contains: query.name, mode: 'insensitive' };
if (query.equipmentTypeCode) where.equipmentTypeCode = query.equipmentTypeCode; if (query.equipmentTypeCode) where.equipmentTypeCode = { contains: query.equipmentTypeCode, mode: 'insensitive' };
if (query.status) where.status = query.status;
if (query.location) where.location = { contains: query.location, 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) { if (query.id) {
const ids = Array.isArray(query.id) ? query.id : [query.id]; const ids = Array.isArray(query.id) ? query.id : [query.id];
where.id = { in: ids }; where.id = { in: ids };
} }
const [data, total] = await Promise.all([ const [data, total] = await Promise.all([
this.prisma.equipment.findMany({ this.prisma.equipment.findMany({ where, skip, take, orderBy: { [sortField]: sortOrder } }),
where,
skip,
take,
orderBy: { [sortField]: sortOrder },
}),
this.prisma.equipment.count({ where }), this.prisma.equipment.count({ where }),
]); ]);
return { const mapped = data;
data: data.map(serializeRecord), return { data: mapped, total };
total,
};
} }
async findOne(id: string) { 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); return record;
} }
async create(dto: CreateEquipmentDto) { async create(dto: CreateEquipmentDto) {
const data: any = { ...dto }; const record = await this.prisma.equipment.create({ data: dto as any });
if (dto.commissionedAt) data.commissionedAt = new Date(dto.commissionedAt); return record;
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);
} }
async update(id: string, dto: UpdateEquipmentDto) { async update(id: string, dto: UpdateEquipmentDto) {
const { id: _pk, ...rest } = dto as any; const data: any = { ...(dto as any) };
const data: any = { ...rest }; delete data.id;
if (data.commissionedAt) data.commissionedAt = new Date(data.commissionedAt); delete data.id;
if (data.lastRepairAt) data.lastRepairAt = new Date(data.lastRepairAt); const record = await this.prisma.equipment.update({ where: { id: id } as any, data });
if (data.totalEngineHours !== undefined && data.totalEngineHours !== null) data.totalEngineHours = new Prisma.Decimal(data.totalEngineHours); return record;
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);
} }
async remove(id: string) { 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); return record;
} }
} }

View File

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

View File

@@ -1,13 +1,14 @@
export class UpdateRepairOrderDto { export class UpdateRepairOrderDto {
number?: string; id?: string | null;
equipmentId?: string; number?: string | null;
repairKind?: string; equipmentId?: string | null;
status?: string; repairKind?: string | null;
plannedAt?: string; status?: string | null;
startedAt?: string; plannedAt?: string | null;
completedAt?: string; startedAt?: string | null;
contractor?: string; completedAt?: string | null;
engineHoursAtRepair?: string; contractor?: string | null;
description?: string; engineHoursAtRepair?: string | null;
notes?: string; description?: string | null;
notes?: string | null;
} }

View File

@@ -6,11 +6,11 @@ import { UpdateRepairOrderDto } from './dto/update-repair-order.dto';
@Controller('repair-orders') @Controller('repair-orders')
export class RepairOrderController { export class RepairOrderController {
constructor(private readonly repairOrderService: RepairOrderService) {} constructor(private readonly service: RepairOrderService) {}
@Get() @Get()
async findAll(@Query() query: any, @Res() res: Response) { 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('Content-Range', `repair-orders ${query._start || 0}-${query._end || result.total}/${result.total}`);
res.set('Access-Control-Expose-Headers', 'Content-Range'); res.set('Access-Control-Expose-Headers', 'Content-Range');
return res.json(result.data); return res.json(result.data);
@@ -18,21 +18,21 @@ export class RepairOrderController {
@Get(':id') @Get(':id')
findOne(@Param('id') id: string) { findOne(@Param('id') id: string) {
return this.repairOrderService.findOne(id); return this.service.findOne(id);
} }
@Post() @Post()
create(@Body() dto: CreateRepairOrderDto) { create(@Body() dto: CreateRepairOrderDto) {
return this.repairOrderService.create(dto); return this.service.create(dto);
} }
@Patch(':id') @Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateRepairOrderDto) { update(@Param('id') id: string, @Body() dto: UpdateRepairOrderDto) {
return this.repairOrderService.update(id, dto); return this.service.update(id, dto);
} }
@Delete(':id') @Delete(':id')
remove(@Param('id') id: string) { 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 { CreateRepairOrderDto } from './dto/create-repair-order.dto';
import { UpdateRepairOrderDto } from './dto/update-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() @Injectable()
export class RepairOrderService { export class RepairOrderService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
@@ -27,62 +17,60 @@ export class RepairOrderService {
const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc'; const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';
const where: any = {}; 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.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.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) { if (query.id) {
const ids = Array.isArray(query.id) ? query.id : [query.id]; const ids = Array.isArray(query.id) ? query.id : [query.id];
where.id = { in: ids }; where.id = { in: ids };
} }
const [data, total] = await Promise.all([ const [data, total] = await Promise.all([
this.prisma.repairOrder.findMany({ this.prisma.repairOrder.findMany({ where, skip, take, orderBy: { [sortField]: sortOrder } }),
where,
skip,
take,
orderBy: { [sortField]: sortOrder },
}),
this.prisma.repairOrder.count({ where }), this.prisma.repairOrder.count({ where }),
]); ]);
return { const mapped = data;
data: data.map(serializeRecord), return { data: mapped, total };
total,
};
} }
async findOne(id: string) { 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); return record;
} }
async create(dto: CreateRepairOrderDto) { async create(dto: CreateRepairOrderDto) {
const data: any = { ...dto }; const record = await this.prisma.repairOrder.create({ data: dto as any });
if (dto.plannedAt) data.plannedAt = new Date(dto.plannedAt); return record;
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);
} }
async update(id: string, dto: UpdateRepairOrderDto) { async update(id: string, dto: UpdateRepairOrderDto) {
const { id: _pk, ...rest } = dto as any; const data: any = { ...(dto as any) };
const data: any = { ...rest }; delete data.id;
if (data.plannedAt) data.plannedAt = new Date(data.plannedAt); delete data.id;
if (data.startedAt) data.startedAt = new Date(data.startedAt); const record = await this.prisma.repairOrder.update({ where: { id: id } as any, data });
if (data.completedAt) data.completedAt = new Date(data.completedAt); return record;
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);
} }
async remove(id: string) { 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); return record;
} }
} }