From 5b8d8a85c4a80f697120a34e3f8f83ab7386fdc7 Mon Sep 17 00:00:00 2001 From: time_ Date: Wed, 18 Mar 2026 19:49:07 +0300 Subject: [PATCH] Generate filtering/sorting and searchable dropdowns Includes backend q search + generated list UX from DSL. --- client/package-lock.json | 39 -- client/src/dataProvider.ts | 47 +- .../equipment-type/EquipmentTypeCreate.tsx | 13 +- .../equipment-type/EquipmentTypeEdit.tsx | 13 +- .../equipment-type/EquipmentTypeList.tsx | 39 +- .../equipment-type/EquipmentTypeShow.tsx | 12 +- .../resources/equipment/EquipmentCreate.tsx | 42 +- .../src/resources/equipment/EquipmentEdit.tsx | 43 +- .../src/resources/equipment/EquipmentList.tsx | 58 +- .../src/resources/equipment/EquipmentShow.tsx | 42 +- .../repair-order/RepairOrderCreate.tsx | 56 +- .../repair-order/RepairOrderEdit.tsx | 57 +- .../repair-order/RepairOrderList.tsx | 73 ++- .../repair-order/RepairOrderShow.tsx | 52 +- docker-compose.yml | 2 +- examples/TOiR.domain.dsl | 2 +- frontend/react-admin-rules.md | 17 + generation/backend-generation.md | 21 + generation/dev-workflow.md | 17 + generation/frontend-generation.md | 20 + generation/generate.mjs | 562 ++++++++++++++++++ server/package.json | 1 + server/prisma/schema.prisma | 64 +- server/prisma/seed.ts | 206 ++++--- server/src/app.module.ts | 3 +- .../dto/create-equipment-type.dto.ts | 4 +- .../dto/update-equipment-type.dto.ts | 10 +- .../equipment-type.controller.ts | 18 +- .../equipment-type/equipment-type.service.ts | 75 ++- .../equipment/dto/create-equipment.dto.ts | 8 +- .../equipment/dto/update-equipment.dto.ts | 23 +- .../modules/equipment/equipment.controller.ts | 12 +- .../modules/equipment/equipment.service.ts | 77 ++- .../dto/create-repair-order.dto.ts | 10 +- .../dto/update-repair-order.dto.ts | 23 +- .../repair-order/repair-order.controller.ts | 12 +- .../repair-order/repair-order.service.ts | 76 +-- 37 files changed, 1267 insertions(+), 582 deletions(-) create mode 100644 generation/generate.mjs diff --git a/client/package-lock.json b/client/package-lock.json index 2aead9f..0bd6b54 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1448,9 +1448,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1465,9 +1462,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1482,9 +1476,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1499,9 +1490,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1516,9 +1504,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1533,9 +1518,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1550,9 +1532,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1567,9 +1546,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1584,9 +1560,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1601,9 +1574,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1618,9 +1588,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1635,9 +1602,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1652,9 +1616,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/client/src/dataProvider.ts b/client/src/dataProvider.ts index fd208f5..3ce2577 100644 --- a/client/src/dataProvider.ts +++ b/client/src/dataProvider.ts @@ -1,8 +1,24 @@ import { DataProvider, fetchUtils } from 'react-admin'; -const apiUrl = 'http://localhost:3000'; +const apiUrl = 'http://localhost:3001'; const httpClient = fetchUtils.fetchJson; +function buildQueryString(query: Record) { + const search = new URLSearchParams(); + Object.entries(query).forEach(([key, val]) => { + if (val === undefined || val === null || val === '') return; + if (Array.isArray(val)) { + val.forEach((v) => { + if (v === undefined || v === null || v === '') return; + search.append(key, String(v)); + }); + return; + } + search.set(key, String(val)); + }); + return search.toString(); +} + const dataProvider: DataProvider = { getList: async (resource, params) => { const { page, perPage } = params.pagination!; @@ -10,23 +26,15 @@ const dataProvider: DataProvider = { const start = (page - 1) * perPage; const end = page * perPage; - const query: Record = { - _start: String(start), - _end: String(end), + const query: Record = { + _start: start, + _end: end, _sort: field, _order: order, + ...(params.filter ?? {}), }; - if (params.filter) { - Object.keys(params.filter).forEach((key) => { - const val = params.filter[key]; - if (val !== undefined && val !== null && val !== '') { - query[key] = String(val); - } - }); - } - - const queryString = new URLSearchParams(query).toString(); + const queryString = buildQueryString(query); const url = `${apiUrl}/${resource}?${queryString}`; const { json, headers } = await httpClient(url); @@ -55,15 +63,16 @@ const dataProvider: DataProvider = { const start = (page - 1) * perPage; const end = page * perPage; - const query: Record = { - _start: String(start), - _end: String(end), + const query: Record = { + _start: start, + _end: end, _sort: field, _order: order, - [params.target]: String(params.id), + [params.target]: params.id, + ...(params.filter ?? {}), }; - const queryString = new URLSearchParams(query).toString(); + const queryString = buildQueryString(query); const url = `${apiUrl}/${resource}?${queryString}`; const { json, headers } = await httpClient(url); diff --git a/client/src/resources/equipment-type/EquipmentTypeCreate.tsx b/client/src/resources/equipment-type/EquipmentTypeCreate.tsx index d1f9e70..dd45ce2 100644 --- a/client/src/resources/equipment-type/EquipmentTypeCreate.tsx +++ b/client/src/resources/equipment-type/EquipmentTypeCreate.tsx @@ -1,13 +1,14 @@ -import { Create, SimpleForm, TextInput, NumberInput } from 'react-admin'; +import { Create, SimpleForm, TextInput } from 'react-admin'; + export const EquipmentTypeCreate = () => ( - - - - - + + + + + ); diff --git a/client/src/resources/equipment-type/EquipmentTypeEdit.tsx b/client/src/resources/equipment-type/EquipmentTypeEdit.tsx index 1325204..91ee269 100644 --- a/client/src/resources/equipment-type/EquipmentTypeEdit.tsx +++ b/client/src/resources/equipment-type/EquipmentTypeEdit.tsx @@ -1,13 +1,14 @@ -import { Edit, SimpleForm, TextInput, NumberInput } from 'react-admin'; +import { Edit, SimpleForm, TextInput } from 'react-admin'; + export const EquipmentTypeEdit = () => ( - - - - - + + + + + ); diff --git a/client/src/resources/equipment-type/EquipmentTypeList.tsx b/client/src/resources/equipment-type/EquipmentTypeList.tsx index d212b8f..2334bfd 100644 --- a/client/src/resources/equipment-type/EquipmentTypeList.tsx +++ b/client/src/resources/equipment-type/EquipmentTypeList.tsx @@ -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 = [ + , + , + +]; + +const EquipmentTypeListActions = () => ( + + + + + +); export const EquipmentTypeList = () => ( - + } filters={equipmentTypeFilters} sort={{ field: 'code', order: 'ASC' }}> - - - - - + + + + + ); diff --git a/client/src/resources/equipment-type/EquipmentTypeShow.tsx b/client/src/resources/equipment-type/EquipmentTypeShow.tsx index 65f0fb7..d81b0a3 100644 --- a/client/src/resources/equipment-type/EquipmentTypeShow.tsx +++ b/client/src/resources/equipment-type/EquipmentTypeShow.tsx @@ -1,13 +1,13 @@ -import { Show, SimpleShowLayout, TextField, NumberField } from 'react-admin'; +import { Show, SimpleShowLayout, TextField } from 'react-admin'; export const EquipmentTypeShow = () => ( - - - - - + + + + + ); diff --git a/client/src/resources/equipment/EquipmentCreate.tsx b/client/src/resources/equipment/EquipmentCreate.tsx index f10030e..705773a 100644 --- a/client/src/resources/equipment/EquipmentCreate.tsx +++ b/client/src/resources/equipment/EquipmentCreate.tsx @@ -1,36 +1,28 @@ -import { - Create, - SimpleForm, - TextInput, - NumberInput, - DateInput, - SelectInput, - ReferenceInput, -} from 'react-admin'; +import { Create, SimpleForm, TextInput, SelectInput, ReferenceInput, AutocompleteInput } from 'react-admin'; const statusChoices = [ - { id: 'Active', name: 'В эксплуатации' }, - { id: 'Repair', name: 'В ремонте' }, - { id: 'Reserve', name: 'В резерве' }, - { id: 'WriteOff', name: 'Списано' }, + { id: 'Active', name: 'Active' }, + { id: 'Repair', name: 'Repair' }, + { id: 'Reserve', name: 'Reserve' }, + { id: 'WriteOff', name: 'WriteOff' }, ]; export const EquipmentCreate = () => ( - - - - - + + + + + record.code ? `${record.code} — ${record.name ?? record.code}` : (record.name ?? record.id)} filterToQuery={(searchText) => ({ q: searchText })} /> - - - - - - - + + + + + + + ); diff --git a/client/src/resources/equipment/EquipmentEdit.tsx b/client/src/resources/equipment/EquipmentEdit.tsx index dca02ca..7044507 100644 --- a/client/src/resources/equipment/EquipmentEdit.tsx +++ b/client/src/resources/equipment/EquipmentEdit.tsx @@ -1,36 +1,29 @@ -import { - Edit, - SimpleForm, - TextInput, - NumberInput, - DateInput, - SelectInput, - ReferenceInput, -} from 'react-admin'; +import { Edit, SimpleForm, TextInput, SelectInput, ReferenceInput, AutocompleteInput } from 'react-admin'; const statusChoices = [ - { id: 'Active', name: 'В эксплуатации' }, - { id: 'Repair', name: 'В ремонте' }, - { id: 'Reserve', name: 'В резерве' }, - { id: 'WriteOff', name: 'Списано' }, + { id: 'Active', name: 'Active' }, + { id: 'Repair', name: 'Repair' }, + { id: 'Reserve', name: 'Reserve' }, + { id: 'WriteOff', name: 'WriteOff' }, ]; export const EquipmentEdit = () => ( - - - - - + + + + + + record.code ? `${record.code} — ${record.name ?? record.code}` : (record.name ?? record.id)} filterToQuery={(searchText) => ({ q: searchText })} /> - - - - - - - + + + + + + + ); diff --git a/client/src/resources/equipment/EquipmentList.tsx b/client/src/resources/equipment/EquipmentList.tsx index 978ee68..89e3361 100644 --- a/client/src/resources/equipment/EquipmentList.tsx +++ b/client/src/resources/equipment/EquipmentList.tsx @@ -2,29 +2,65 @@ import { List, Datagrid, TextField, + TextInput, + TopToolbar, + FilterButton, + CreateButton, + ExportButton, NumberField, + DateField, SelectField, ReferenceField, + SelectArrayInput, + ReferenceInput, + AutocompleteInput } from 'react-admin'; const statusChoices = [ - { id: 'Active', name: 'В эксплуатации' }, - { id: 'Repair', name: 'В ремонте' }, - { id: 'Reserve', name: 'В резерве' }, - { id: 'WriteOff', name: 'Списано' }, + { id: 'Active', name: 'Active' }, + { id: 'Repair', name: 'Repair' }, + { id: 'Reserve', name: 'Reserve' }, + { id: 'WriteOff', name: 'WriteOff' }, ]; +const equipmentFilters = [ + , + , + , + , + + record.code ? `${record.code} — ${record.name ?? record.code}` : (record.name ?? record.id)} filterToQuery={(searchText) => ({ q: searchText })} /> + , + , + , + +]; + +const EquipmentListActions = () => ( + + + + + +); + export const EquipmentList = () => ( - + } filters={equipmentFilters} sort={{ field: 'id', order: 'ASC' }}> - - - + + + + + - - - + + + + + + + ); diff --git a/client/src/resources/equipment/EquipmentShow.tsx b/client/src/resources/equipment/EquipmentShow.tsx index 9c8429c..10dfd4d 100644 --- a/client/src/resources/equipment/EquipmentShow.tsx +++ b/client/src/resources/equipment/EquipmentShow.tsx @@ -1,36 +1,20 @@ -import { - Show, - SimpleShowLayout, - TextField, - NumberField, - DateField, - SelectField, - ReferenceField, -} from 'react-admin'; - -const statusChoices = [ - { id: 'Active', name: 'В эксплуатации' }, - { id: 'Repair', name: 'В ремонте' }, - { id: 'Reserve', name: 'В резерве' }, - { id: 'WriteOff', name: 'Списано' }, -]; +import { Show, SimpleShowLayout, TextField } from 'react-admin'; export const EquipmentShow = () => ( - - - - - - - - - - - - - + + + + + + + + + + + + ); diff --git a/client/src/resources/repair-order/RepairOrderCreate.tsx b/client/src/resources/repair-order/RepairOrderCreate.tsx index a860444..26c67c2 100644 --- a/client/src/resources/repair-order/RepairOrderCreate.tsx +++ b/client/src/resources/repair-order/RepairOrderCreate.tsx @@ -1,46 +1,38 @@ -import { - Create, - SimpleForm, - TextInput, - NumberInput, - DateInput, - SelectInput, - ReferenceInput, -} from 'react-admin'; +import { Create, SimpleForm, TextInput, SelectInput, ReferenceInput, AutocompleteInput } from 'react-admin'; const repairKindChoices = [ - { id: 'TO', name: 'Техническое обслуживание' }, - { id: 'TR', name: 'Текущий ремонт' }, - { id: 'TRE', name: 'Текущий расширенный ремонт' }, - { id: 'KR', name: 'Капитальный ремонт' }, - { id: 'AR', name: 'Аварийный ремонт' }, - { id: 'MP', name: 'Метрологическая поверка' }, + { id: 'TO', name: 'TO' }, + { id: 'TR', name: 'TR' }, + { id: 'TRE', name: 'TRE' }, + { id: 'KR', name: 'KR' }, + { id: 'AR', name: 'AR' }, + { id: 'MP', name: 'MP' }, ]; const statusChoices = [ - { id: 'Draft', name: 'Черновик' }, - { id: 'Approved', name: 'Утверждена' }, - { id: 'InWork', name: 'В работе' }, - { id: 'Done', name: 'Выполнена' }, - { id: 'Cancelled', name: 'Отменена' }, + { id: 'Draft', name: 'Draft' }, + { id: 'Approved', name: 'Approved' }, + { id: 'InWork', name: 'InWork' }, + { id: 'Done', name: 'Done' }, + { id: 'Cancelled', name: 'Cancelled' }, ]; export const RepairOrderCreate = () => ( - - - + + + record.code ? `${record.code} — ${record.name ?? record.code}` : (record.name ?? record.id)} filterToQuery={(searchText) => ({ q: searchText })} /> - - - - - - - - - + + + + + + + + + ); diff --git a/client/src/resources/repair-order/RepairOrderEdit.tsx b/client/src/resources/repair-order/RepairOrderEdit.tsx index c60b159..677bb64 100644 --- a/client/src/resources/repair-order/RepairOrderEdit.tsx +++ b/client/src/resources/repair-order/RepairOrderEdit.tsx @@ -1,46 +1,39 @@ -import { - Edit, - SimpleForm, - TextInput, - NumberInput, - DateInput, - SelectInput, - ReferenceInput, -} from 'react-admin'; +import { Edit, SimpleForm, TextInput, SelectInput, ReferenceInput, AutocompleteInput } from 'react-admin'; const repairKindChoices = [ - { id: 'TO', name: 'Техническое обслуживание' }, - { id: 'TR', name: 'Текущий ремонт' }, - { id: 'TRE', name: 'Текущий расширенный ремонт' }, - { id: 'KR', name: 'Капитальный ремонт' }, - { id: 'AR', name: 'Аварийный ремонт' }, - { id: 'MP', name: 'Метрологическая поверка' }, + { id: 'TO', name: 'TO' }, + { id: 'TR', name: 'TR' }, + { id: 'TRE', name: 'TRE' }, + { id: 'KR', name: 'KR' }, + { id: 'AR', name: 'AR' }, + { id: 'MP', name: 'MP' }, ]; const statusChoices = [ - { id: 'Draft', name: 'Черновик' }, - { id: 'Approved', name: 'Утверждена' }, - { id: 'InWork', name: 'В работе' }, - { id: 'Done', name: 'Выполнена' }, - { id: 'Cancelled', name: 'Отменена' }, + { id: 'Draft', name: 'Draft' }, + { id: 'Approved', name: 'Approved' }, + { id: 'InWork', name: 'InWork' }, + { id: 'Done', name: 'Done' }, + { id: 'Cancelled', name: 'Cancelled' }, ]; export const RepairOrderEdit = () => ( - - - + + + + record.code ? `${record.code} — ${record.name ?? record.code}` : (record.name ?? record.id)} filterToQuery={(searchText) => ({ q: searchText })} /> - - - - - - - - - + + + + + + + + + ); diff --git a/client/src/resources/repair-order/RepairOrderList.tsx b/client/src/resources/repair-order/RepairOrderList.tsx index 486898c..3bf4717 100644 --- a/client/src/resources/repair-order/RepairOrderList.tsx +++ b/client/src/resources/repair-order/RepairOrderList.tsx @@ -2,39 +2,76 @@ import { List, Datagrid, TextField, + TextInput, + TopToolbar, + FilterButton, + CreateButton, + ExportButton, + NumberField, DateField, SelectField, ReferenceField, + SelectArrayInput, + SelectInput, + ReferenceInput, + AutocompleteInput } from 'react-admin'; const repairKindChoices = [ - { id: 'TO', name: 'Техническое обслуживание' }, - { id: 'TR', name: 'Текущий ремонт' }, - { id: 'TRE', name: 'Текущий расширенный ремонт' }, - { id: 'KR', name: 'Капитальный ремонт' }, - { id: 'AR', name: 'Аварийный ремонт' }, - { id: 'MP', name: 'Метрологическая поверка' }, + { id: 'TO', name: 'TO' }, + { id: 'TR', name: 'TR' }, + { id: 'TRE', name: 'TRE' }, + { id: 'KR', name: 'KR' }, + { id: 'AR', name: 'AR' }, + { id: 'MP', name: 'MP' }, ]; const statusChoices = [ - { id: 'Draft', name: 'Черновик' }, - { id: 'Approved', name: 'Утверждена' }, - { id: 'InWork', name: 'В работе' }, - { id: 'Done', name: 'Выполнена' }, - { id: 'Cancelled', name: 'Отменена' }, + { id: 'Draft', name: 'Draft' }, + { id: 'Approved', name: 'Approved' }, + { id: 'InWork', name: 'InWork' }, + { id: 'Done', name: 'Done' }, + { id: 'Cancelled', name: 'Cancelled' }, ]; +const repairOrderFilters = [ + , + , + + record.code ? `${record.code} — ${record.name ?? record.code}` : (record.name ?? record.id)} filterToQuery={(searchText) => ({ q: searchText })} /> + , + , + , + , + , + +]; + +const RepairOrderListActions = () => ( + + + + + +); + export const RepairOrderList = () => ( - + } filters={repairOrderFilters} sort={{ field: 'id', order: 'ASC' }}> - - + + + - - - - + + + + + + + + + ); diff --git a/client/src/resources/repair-order/RepairOrderShow.tsx b/client/src/resources/repair-order/RepairOrderShow.tsx index 4d1ebeb..10c6769 100644 --- a/client/src/resources/repair-order/RepairOrderShow.tsx +++ b/client/src/resources/repair-order/RepairOrderShow.tsx @@ -1,46 +1,20 @@ -import { - Show, - SimpleShowLayout, - TextField, - NumberField, - DateField, - SelectField, - ReferenceField, -} from 'react-admin'; - -const repairKindChoices = [ - { id: 'TO', name: 'Техническое обслуживание' }, - { id: 'TR', name: 'Текущий ремонт' }, - { id: 'TRE', name: 'Текущий расширенный ремонт' }, - { id: 'KR', name: 'Капитальный ремонт' }, - { id: 'AR', name: 'Аварийный ремонт' }, - { id: 'MP', name: 'Метрологическая поверка' }, -]; - -const statusChoices = [ - { id: 'Draft', name: 'Черновик' }, - { id: 'Approved', name: 'Утверждена' }, - { id: 'InWork', name: 'В работе' }, - { id: 'Done', name: 'Выполнена' }, - { id: 'Cancelled', name: 'Отменена' }, -]; +import { Show, SimpleShowLayout, TextField } from 'react-admin'; export const RepairOrderShow = () => ( - - - - - - - - - - - - - + + + + + + + + + + + + ); diff --git a/docker-compose.yml b/docker-compose.yml index c591e3b..b0a1a9e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: POSTGRES_PASSWORD: postgres POSTGRES_DB: toir ports: - - "5432:5432" + - "5433:5432" volumes: - postgres_data:/var/lib/postgresql/data diff --git a/examples/TOiR.domain.dsl b/examples/TOiR.domain.dsl index ebef7b2..aca3ecd 100644 --- a/examples/TOiR.domain.dsl +++ b/examples/TOiR.domain.dsl @@ -61,7 +61,6 @@ enum RepairOrderStatus { } } - // ───────────────────────────────────────────── // Справочник: Вид оборудования // ───────────────────────────────────────────── @@ -255,3 +254,4 @@ entity RepairOrder { type text; } } + diff --git a/frontend/react-admin-rules.md b/frontend/react-admin-rules.md index 66ebee4..e8de51f 100644 --- a/frontend/react-admin-rules.md +++ b/frontend/react-admin-rules.md @@ -43,6 +43,23 @@ React Admin +## 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 diff --git a/generation/backend-generation.md b/generation/backend-generation.md index a6b3bf6..15b5321 100644 --- a/generation/backend-generation.md +++ b/generation/backend-generation.md @@ -68,6 +68,27 @@ Use mapping rules from `backend/prisma-rules.md`: - DSL `decimal` -> DTO `string` - DSL `date` -> DTO `string` (ISO) +## Filtering & search contract (must be generated) + +React Admin uses query parameters for pagination, sorting, and filtering. + +- **Pagination**: `_start`, `_end` +- **Sorting**: `_sort`, `_order` +- **Filtering**: arbitrary field keys in query string + +Additionally, to support `AutocompleteInput` search for references, list endpoints must support: + +- `q`: a generic search term that can be applied as an `OR` over a few human-meaningful fields (e.g. code/name/manufacturer, inventoryNumber/name, etc.) + +### Multi-value filter support + +For enum-like fields (e.g. `status`) the backend must accept both: + +- `status=Active` (single value) +- `status=Active&status=Repair` (multiple values) + +Services must treat repeated query params as arrays and translate them to Prisma `in` filters. + --- # Step 4 — Runtime infrastructure diff --git a/generation/dev-workflow.md b/generation/dev-workflow.md index cd5559a..d65ff35 100644 --- a/generation/dev-workflow.md +++ b/generation/dev-workflow.md @@ -2,6 +2,23 @@ This document describes the **developer workflow** for running a generated fullstack application locally. The generator must produce a project that supports this workflow so the app is **fully runnable** after generation. +## Regenerating code from DSL + +If the domain DSL changes (e.g. a new entity is added), regenerate backend + frontend artifacts from `examples/TOiR.domain.dsl`: + +```bash +cd server +npm run generate:from-dsl +``` + +Then apply the updated schema and seed data: + +```bash +cd server +npx prisma db push +npx prisma db seed +``` + --- # Prerequisites diff --git a/generation/frontend-generation.md b/generation/frontend-generation.md index e141060..10f53ff 100644 --- a/generation/frontend-generation.md +++ b/generation/frontend-generation.md @@ -19,6 +19,26 @@ EntityCreate.tsx EntityEdit.tsx EntityShow.tsx +## List UX requirements (must be generated) + +- Lists must include **filtering UI** via `filters` prop on `List` and an explicit actions toolbar with: + - `FilterButton` (so non-`alwaysOn` filters are discoverable) + - `CreateButton` + - `ExportButton` +- Lists must include a **default sort** (`sort={{ field: "...", order: "ASC|DESC" }}`) appropriate for the entity. + +## Reference selection UX (must be generated) + +- For foreign keys (`ReferenceInput`) in Create/Edit forms, prefer `AutocompleteInput` over `SelectInput` to support search. +- Autocomplete must send search text to backend using `filterToQuery={(searchText) => ({ q: searchText })}`. +- Option text must include a **code** (or business identifier) and a name when available, e.g. `CODE — NAME`. + +## Enum filters (must be generated) + +- For enum fields in **list filters**, use: + - `SelectInput` for single-select filters + - `SelectArrayInput` for multi-select filters when users need to filter by multiple enum values (e.g. Status). + --- # Step 3 — Map Fields diff --git a/generation/generate.mjs b/generation/generate.mjs new file mode 100644 index 0000000..622eb80 --- /dev/null +++ b/generation/generate.mjs @@ -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 +// /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(``); + } + for (const a of entity.attributes) { + if (a.name === pk) continue; + if (a.foreign) { + filterInputs.push( + `\n record.code ? \`\${record.code} — \${record.name ?? record.code}\` : (record.name ?? record.id)} filterToQuery={(searchText) => ({ q: searchText })} />\n ` + ); + continue; + } + if (['string', 'text', 'uuid'].includes(a.type)) { + filterInputs.push(``); + continue; + } + if (['integer', 'decimal'].includes(a.type)) continue; + if (a.type === 'date') continue; + // enum + if (a.name === 'status') { + filterInputs.push(``); + } else { + filterInputs.push(``); + } + } + + const listFields = []; + for (const a of entity.attributes) { + if (a.foreign) { + listFields.push( + `\n \n ` + ); + continue; + } + if (a.type === 'date') { + listFields.push(``); + } else if (['integer', 'decimal'].includes(a.type)) { + listFields.push(``); + } else if (!['string', 'text', 'uuid', 'integer', 'decimal', 'date'].includes(a.type)) { + listFields.push(``); + } else { + listFields.push(``); + } + } + + 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 \n \n \n \n \n);\n\nexport const ${className}List = () => (\n } filters={${filtersIdent}} sort={{ field: '${pk}', order: 'ASC' }}>\n \n ${listFields.join('\n ')}\n \n \n);\n`; + + const formField = (a, mode) => { + if (a.isPrimary && mode === 'create' && a.type === 'uuid') return null; + if (a.isPrimary && mode === 'edit') { + return ``; + } + if (a.foreign) { + return `\n record.code ? \`\${record.code} — \${record.name ?? record.code}\` : (record.name ?? record.id)} filterToQuery={(searchText) => ({ q: searchText })} />\n `; + } + if (a.type === 'date') return ``; + if (['integer', 'decimal'].includes(a.type)) return ``; + if (!['string', 'text', 'uuid', 'integer', 'decimal', 'date'].includes(a.type)) { + if (a.name === 'status' && statusEnumAttr) return ``; + return ``; + } + return ``; + }; + + 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 \n \n ${entity.attributes.map((a) => formField(a, 'create')).filter(Boolean).join('\n ')}\n \n \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 \n \n ${entity.attributes.map((a) => formField(a, 'edit')).filter(Boolean).join('\n ')}\n \n \n);\n`; + + const show = `import { Show, SimpleShowLayout, TextField } from 'react-admin';\n\nexport const ${className}Show = () => (\n \n \n ${entity.attributes.map((a) => ``).join('\n ')}\n \n \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, + ` \n ` + ); + } + } + 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(); + diff --git a/server/package.json b/server/package.json index b799dbd..9678d3b 100644 --- a/server/package.json +++ b/server/package.json @@ -18,6 +18,7 @@ "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", + "generate:from-dsl": "node ../generation/generate.mjs --apply --dsl examples/TOiR.domain.dsl", "postinstall": "prisma generate" }, "prisma": { diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index f9dd153..e0556d8 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -32,43 +32,43 @@ enum RepairOrderStatus { } model EquipmentType { - code String @id - name String - manufacturer String? + code String @id + name String + manufacturer String? maintenanceIntervalHours Int? - overhaulIntervalHours Int? - equipment Equipment[] + overhaulIntervalHours Int? + equipment Equipment[] } model Equipment { - id String @id @default(uuid()) - inventoryNumber String @unique - serialNumber String? - name String - equipmentTypeCode String - equipmentType EquipmentType @relation(fields: [equipmentTypeCode], references: [code]) - status EquipmentStatus @default(Active) - location String? - commissionedAt DateTime? - totalEngineHours Decimal? - engineHoursSinceLastRepair Decimal? - lastRepairAt DateTime? - notes String? - repairOrders RepairOrder[] + id String @id @default(uuid()) + inventoryNumber String @unique + serialNumber String? + name String + equipmentTypeCode String + status EquipmentStatus @default(Active) + location String? + commissionedAt DateTime? + totalEngineHours Decimal? + engineHoursSinceLastRepair Decimal? + lastRepairAt DateTime? + notes String? + equipmentType EquipmentType @relation(fields: [equipmentTypeCode], references: [code]) + repairOrders RepairOrder[] } model RepairOrder { - id String @id @default(uuid()) - number String @unique - equipmentId String - equipment Equipment @relation(fields: [equipmentId], references: [id]) - repairKind RepairKind - status RepairOrderStatus @default(Draft) - plannedAt DateTime - startedAt DateTime? - completedAt DateTime? - contractor String? - engineHoursAtRepair Decimal? - description String? - notes String? + id String @id @default(uuid()) + number String @unique + equipmentId String + repairKind RepairKind + status RepairOrderStatus @default(Draft) + plannedAt DateTime + startedAt DateTime? + completedAt DateTime? + contractor String? + engineHoursAtRepair Decimal? + description String? + notes String? + equipment Equipment @relation(fields: [equipmentId], references: [id]) } diff --git a/server/prisma/seed.ts b/server/prisma/seed.ts index e74af49..5300f3d 100644 --- a/server/prisma/seed.ts +++ b/server/prisma/seed.ts @@ -3,89 +3,161 @@ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); async function main() { - const equipmentType = await prisma.equipmentType.upsert({ - where: { code: 'pump' }, - update: {}, - create: { + const equipmentTypes = [ + { code: 'pump', name: 'Насосный агрегат', manufacturer: 'АО НасосПром', maintenanceIntervalHours: 2000, overhaulIntervalHours: 16000, }, - }); - - const equipmentType2 = await prisma.equipmentType.upsert({ - where: { code: 'compressor' }, - update: {}, - create: { + { code: 'compressor', name: 'Компрессорная установка', manufacturer: 'ОАО Компрессормаш', maintenanceIntervalHours: 1500, overhaulIntervalHours: 12000, }, - }); - - const equipment = await prisma.equipment.upsert({ - where: { inventoryNumber: 'INV-001' }, - update: {}, - create: { - inventoryNumber: 'INV-001', - serialNumber: 'SN-2024-0001', - name: 'Насос ЦНС 180-212', - equipmentTypeCode: 'pump', - status: 'Active', - location: 'Куст №5, скважина 42', - commissionedAt: new Date('2023-06-15'), - totalEngineHours: 4500, - engineHoursSinceLastRepair: 1200, + { + code: 'generator', + name: 'Дизель-генератор', + manufacturer: 'АО ЭнергоМаш', + maintenanceIntervalHours: 500, + overhaulIntervalHours: 6000, }, - }); - - const equipment2 = await prisma.equipment.upsert({ - where: { inventoryNumber: 'INV-002' }, - update: {}, - create: { - inventoryNumber: 'INV-002', - serialNumber: 'SN-2024-0002', - name: 'Компрессор 4ВМ10-120/9', - equipmentTypeCode: 'compressor', - status: 'Active', - location: 'ГКС-3', - commissionedAt: new Date('2022-03-10'), - totalEngineHours: 8200, - engineHoursSinceLastRepair: 800, + { + code: 'valve', + name: 'Запорная арматура', + manufacturer: 'ЗАО АрматурПром', + maintenanceIntervalHours: 1000, + overhaulIntervalHours: 10000, }, - }); - - await prisma.repairOrder.upsert({ - where: { number: 'RO-2026-001' }, - update: {}, - create: { - number: 'RO-2026-001', - equipmentId: equipment.id, - repairKind: 'TO', - status: 'Approved', - plannedAt: new Date('2026-04-01'), - contractor: 'ООО СервисРемонт', - engineHoursAtRepair: 4500, - description: 'Плановое техническое обслуживание насосного агрегата', + { + code: 'sensor', + name: 'Датчик давления', + manufacturer: 'ООО ПриборСервис', + maintenanceIntervalHours: 800, + overhaulIntervalHours: 8000, }, - }); - - await prisma.repairOrder.upsert({ - where: { number: 'RO-2026-002' }, - update: {}, - create: { - number: 'RO-2026-002', - equipmentId: equipment2.id, - repairKind: 'TR', - status: 'Draft', - plannedAt: new Date('2026-05-15'), - description: 'Текущий ремонт компрессорной установки', + { + code: 'motor', + name: 'Электродвигатель', + manufacturer: 'ПАО ЭлектроМотор', + maintenanceIntervalHours: 1200, + overhaulIntervalHours: 14000, }, - }); + { + code: 'fan', + name: 'Вентилятор', + manufacturer: 'АО ВентПром', + maintenanceIntervalHours: 700, + overhaulIntervalHours: 9000, + }, + { + code: 'heat-exchanger', + name: 'Теплообменник', + manufacturer: 'ОАО ТеплоТех', + maintenanceIntervalHours: 1800, + overhaulIntervalHours: 15000, + }, + { + code: 'filter', + name: 'Фильтровальная установка', + manufacturer: 'ООО ФильтрТех', + maintenanceIntervalHours: 600, + overhaulIntervalHours: 7000, + }, + { + code: 'separator', + name: 'Сепаратор', + manufacturer: 'АО СепараторМаш', + maintenanceIntervalHours: 1600, + overhaulIntervalHours: 13000, + }, + { + code: 'transformer', + name: 'Трансформатор', + manufacturer: 'ПАО ТрансЭнерго', + maintenanceIntervalHours: 2500, + overhaulIntervalHours: 20000, + }, + ] as const; + + for (const type of equipmentTypes) { + await prisma.equipmentType.upsert({ + where: { code: type.code }, + update: { ...type }, + create: { ...type }, + }); + } + + const equipmentRecords: { id: string; inventoryNumber: string; name: string }[] = []; + for (let i = 1; i <= 11; i++) { + const type = equipmentTypes[(i - 1) % equipmentTypes.length]; + const inventoryNumber = `INV-${String(i).padStart(3, '0')}`; + const serialNumber = `SN-2026-${String(i).padStart(4, '0')}`; + const record = await prisma.equipment.upsert({ + where: { inventoryNumber }, + update: { + serialNumber, + name: `${type.name} #${i}`, + equipmentTypeCode: type.code, + status: i % 5 === 0 ? 'Repair' : 'Active', + location: i % 2 === 0 ? `Площадка ${Math.ceil(i / 2)}` : `Цех ${Math.ceil(i / 3)}`, + commissionedAt: new Date(2022, (i - 1) % 12, 1 + ((i - 1) % 28)), + totalEngineHours: 1000 + i * 350, + engineHoursSinceLastRepair: 200 + i * 25, + }, + create: { + inventoryNumber, + serialNumber, + name: `${type.name} #${i}`, + equipmentTypeCode: type.code, + status: i % 5 === 0 ? 'Repair' : 'Active', + location: i % 2 === 0 ? `Площадка ${Math.ceil(i / 2)}` : `Цех ${Math.ceil(i / 3)}`, + commissionedAt: new Date(2022, (i - 1) % 12, 1 + ((i - 1) % 28)), + totalEngineHours: 1000 + i * 350, + engineHoursSinceLastRepair: 200 + i * 25, + }, + }); + equipmentRecords.push({ id: record.id, inventoryNumber: record.inventoryNumber, name: record.name }); + } + + const repairKinds = ['TO', 'TR', 'TRE', 'KR', 'AR', 'MP'] as const; + const statuses = ['Draft', 'Approved', 'InWork', 'Done', 'Cancelled'] as const; + + for (let i = 1; i <= 11; i++) { + const number = `RO-2026-${String(i).padStart(3, '0')}`; + const equipment = equipmentRecords[(i - 1) % equipmentRecords.length]; + await prisma.repairOrder.upsert({ + where: { number }, + update: { + equipmentId: equipment.id, + repairKind: repairKinds[(i - 1) % repairKinds.length], + status: statuses[(i - 1) % statuses.length], + plannedAt: new Date(2026, ((i - 1) % 12), 1 + ((i - 1) % 28)), + startedAt: i % 4 === 0 ? new Date(2026, ((i - 1) % 12), 2 + ((i - 1) % 28)) : null, + completedAt: i % 5 === 0 ? new Date(2026, ((i - 1) % 12), 5 + ((i - 1) % 28)) : null, + contractor: i % 3 === 0 ? 'ООО СервисРемонт' : 'АО ТехПодряд', + engineHoursAtRepair: 1000 + i * 350, + description: `Заявка на ремонт ${equipment.inventoryNumber} (${equipment.name})`, + notes: i % 2 === 0 ? 'Тестовая заметка' : null, + }, + create: { + number, + equipmentId: equipment.id, + repairKind: repairKinds[(i - 1) % repairKinds.length], + status: statuses[(i - 1) % statuses.length], + plannedAt: new Date(2026, ((i - 1) % 12), 1 + ((i - 1) % 28)), + startedAt: i % 4 === 0 ? new Date(2026, ((i - 1) % 12), 2 + ((i - 1) % 28)) : null, + completedAt: i % 5 === 0 ? new Date(2026, ((i - 1) % 12), 5 + ((i - 1) % 28)) : null, + contractor: i % 3 === 0 ? 'ООО СервисРемонт' : 'АО ТехПодряд', + engineHoursAtRepair: 1000 + i * 350, + description: `Заявка на ремонт ${equipment.inventoryNumber} (${equipment.name})`, + notes: i % 2 === 0 ? 'Тестовая заметка' : null, + }, + }); + } console.log('Seed data created successfully'); } diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 3461820..a451c2b 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -7,8 +7,7 @@ import { EquipmentModule } from './modules/equipment/equipment.module'; import { RepairOrderModule } from './modules/repair-order/repair-order.module'; @Module({ - imports: [ - ConfigModule.forRoot({ isGlobal: true }), + imports: [ConfigModule.forRoot({ isGlobal: true }), PrismaModule, HealthModule, EquipmentTypeModule, diff --git a/server/src/modules/equipment-type/dto/create-equipment-type.dto.ts b/server/src/modules/equipment-type/dto/create-equipment-type.dto.ts index 07257b3..2f9d6ab 100644 --- a/server/src/modules/equipment-type/dto/create-equipment-type.dto.ts +++ b/server/src/modules/equipment-type/dto/create-equipment-type.dto.ts @@ -1,6 +1,6 @@ export class CreateEquipmentTypeDto { - code: string; - name: string; + code?: string; + name!: string; manufacturer?: string; maintenanceIntervalHours?: number; overhaulIntervalHours?: number; diff --git a/server/src/modules/equipment-type/dto/update-equipment-type.dto.ts b/server/src/modules/equipment-type/dto/update-equipment-type.dto.ts index 8c3d21c..441980e 100644 --- a/server/src/modules/equipment-type/dto/update-equipment-type.dto.ts +++ b/server/src/modules/equipment-type/dto/update-equipment-type.dto.ts @@ -1,6 +1,8 @@ export class UpdateEquipmentTypeDto { - name?: string; - manufacturer?: string; - maintenanceIntervalHours?: number; - overhaulIntervalHours?: number; + id?: string; + code?: string | null; + name?: string | null; + manufacturer?: string | null; + maintenanceIntervalHours?: number | null; + overhaulIntervalHours?: number | null; } diff --git a/server/src/modules/equipment-type/equipment-type.controller.ts b/server/src/modules/equipment-type/equipment-type.controller.ts index 14d21e5..a79b6ee 100644 --- a/server/src/modules/equipment-type/equipment-type.controller.ts +++ b/server/src/modules/equipment-type/equipment-type.controller.ts @@ -6,33 +6,33 @@ import { UpdateEquipmentTypeDto } from './dto/update-equipment-type.dto'; @Controller('equipment-types') export class EquipmentTypeController { - constructor(private readonly equipmentTypeService: EquipmentTypeService) {} + constructor(private readonly service: EquipmentTypeService) {} @Get() async findAll(@Query() query: any, @Res() res: Response) { - const result = await this.equipmentTypeService.findAll(query); + const result = await this.service.findAll(query); res.set('Content-Range', `equipment-types ${query._start || 0}-${query._end || result.total}/${result.total}`); res.set('Access-Control-Expose-Headers', 'Content-Range'); return res.json(result.data); } @Get(':code') - findOne(@Param('code') code: string) { - return this.equipmentTypeService.findOne(code); + findOne(@Param('code') id: string) { + return this.service.findOne(id); } @Post() create(@Body() dto: CreateEquipmentTypeDto) { - return this.equipmentTypeService.create(dto); + return this.service.create(dto); } @Patch(':code') - update(@Param('code') code: string, @Body() dto: UpdateEquipmentTypeDto) { - return this.equipmentTypeService.update(code, dto); + update(@Param('code') id: string, @Body() dto: UpdateEquipmentTypeDto) { + return this.service.update(id, dto); } @Delete(':code') - remove(@Param('code') code: string) { - return this.equipmentTypeService.remove(code); + remove(@Param('code') id: string) { + return this.service.remove(id); } } diff --git a/server/src/modules/equipment-type/equipment-type.service.ts b/server/src/modules/equipment-type/equipment-type.service.ts index 549ea83..f564c21 100644 --- a/server/src/modules/equipment-type/equipment-type.service.ts +++ b/server/src/modules/equipment-type/equipment-type.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; import { CreateEquipmentTypeDto } from './dto/create-equipment-type.dto'; import { UpdateEquipmentTypeDto } from './dto/update-equipment-type.dto'; @@ -7,72 +8,66 @@ import { UpdateEquipmentTypeDto } from './dto/update-equipment-type.dto'; export class EquipmentTypeService { constructor(private readonly prisma: PrismaService) {} - async findAll(query: { - _start?: string; - _end?: string; - _sort?: string; - _order?: string; - [key: string]: any; - }) { + async findAll(query: { _start?: string; _end?: string; _sort?: string; _order?: string; [key: string]: any }) { const start = parseInt(query._start) || 0; const end = parseInt(query._end) || 10; const take = end - start; const skip = start; - const sortField = 'code'; + const sortField = query._sort || 'code'; const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc'; const where: any = {}; + + if (query.q) { + const q = String(query.q); + const ors: any[] = []; + ors.push({ code: { contains: q, mode: 'insensitive' } }); + ors.push({ name: { contains: q, mode: 'insensitive' } }); + ors.push({ manufacturer: { contains: q, mode: 'insensitive' } }); + if (ors.length) where.OR = ors; + } + if (query.code) where.code = { contains: query.code, mode: 'insensitive' }; if (query.name) where.name = { contains: query.name, mode: 'insensitive' }; - if (query.manufacturer) - where.manufacturer = { - contains: query.manufacturer, - mode: 'insensitive', - }; + if (query.manufacturer) where.manufacturer = { contains: query.manufacturer, mode: 'insensitive' }; + + // Enum multi-value support (e.g. status=A&status=B) + + if (query.id) { const ids = Array.isArray(query.id) ? query.id : [query.id]; where.code = { in: ids }; } const [data, total] = await Promise.all([ - this.prisma.equipmentType.findMany({ - where, - skip, - take, - orderBy: { [sortField]: sortOrder }, - }), + this.prisma.equipmentType.findMany({ where, skip, take, orderBy: { [sortField]: sortOrder } }), this.prisma.equipmentType.count({ where }), ]); - return { - data: data.map((item) => ({ id: item.code, ...item })), - total, - }; + const mapped = data.map((r: any) => ({ id: r.code, ...r })); + return { data: mapped, total }; } - async findOne(code: string) { - const record = await this.prisma.equipmentType.findUniqueOrThrow({ - where: { code }, - }); - return { id: record.code, ...record }; + async findOne(id: string) { + const record = await this.prisma.equipmentType.findUniqueOrThrow({ where: { code: id } as any }); + return { id: (record as any).code, ...record }; } async create(dto: CreateEquipmentTypeDto) { - const record = await this.prisma.equipmentType.create({ data: dto }); - return { id: record.code, ...record }; + const record = await this.prisma.equipmentType.create({ data: dto as any }); + return { id: (record as any).code, ...record }; } - async update(code: string, dto: UpdateEquipmentTypeDto) { - const { id, code: _pk, ...data } = dto as any; - const record = await this.prisma.equipmentType.update({ - where: { code }, - data, - }); - return { id: record.code, ...record }; + async update(id: string, dto: UpdateEquipmentTypeDto) { + const data: any = { ...(dto as any) }; + delete data.id; + delete data.code; + const record = await this.prisma.equipmentType.update({ where: { code: id } as any, data }); + return { id: (record as any).code, ...record }; } - async remove(code: string) { - const record = await this.prisma.equipmentType.delete({ where: { code } }); - return { id: record.code, ...record }; + async remove(id: string) { + const record = await this.prisma.equipmentType.delete({ where: { code: id } as any }); + return { id: (record as any).code, ...record }; } } diff --git a/server/src/modules/equipment/dto/create-equipment.dto.ts b/server/src/modules/equipment/dto/create-equipment.dto.ts index 02d8905..a89cc8d 100644 --- a/server/src/modules/equipment/dto/create-equipment.dto.ts +++ b/server/src/modules/equipment/dto/create-equipment.dto.ts @@ -1,9 +1,9 @@ export class CreateEquipmentDto { - inventoryNumber: string; + inventoryNumber!: string; serialNumber?: string; - name: string; - equipmentTypeCode: string; - status?: string; + name!: string; + equipmentTypeCode!: string; + status!: string; location?: string; commissionedAt?: string; totalEngineHours?: string; diff --git a/server/src/modules/equipment/dto/update-equipment.dto.ts b/server/src/modules/equipment/dto/update-equipment.dto.ts index be3d8b5..83b599f 100644 --- a/server/src/modules/equipment/dto/update-equipment.dto.ts +++ b/server/src/modules/equipment/dto/update-equipment.dto.ts @@ -1,13 +1,14 @@ export class UpdateEquipmentDto { - inventoryNumber?: string; - serialNumber?: string; - name?: string; - equipmentTypeCode?: string; - status?: string; - location?: string; - commissionedAt?: string; - totalEngineHours?: string; - engineHoursSinceLastRepair?: string; - lastRepairAt?: string; - notes?: string; + id?: string | null; + inventoryNumber?: string | null; + serialNumber?: string | null; + name?: string | null; + equipmentTypeCode?: string | null; + status?: string | null; + location?: string | null; + commissionedAt?: string | null; + totalEngineHours?: string | null; + engineHoursSinceLastRepair?: string | null; + lastRepairAt?: string | null; + notes?: string | null; } diff --git a/server/src/modules/equipment/equipment.controller.ts b/server/src/modules/equipment/equipment.controller.ts index 3ab81e3..e8c13aa 100644 --- a/server/src/modules/equipment/equipment.controller.ts +++ b/server/src/modules/equipment/equipment.controller.ts @@ -6,11 +6,11 @@ import { UpdateEquipmentDto } from './dto/update-equipment.dto'; @Controller('equipment') export class EquipmentController { - constructor(private readonly equipmentService: EquipmentService) {} + constructor(private readonly service: EquipmentService) {} @Get() async findAll(@Query() query: any, @Res() res: Response) { - const result = await this.equipmentService.findAll(query); + const result = await this.service.findAll(query); res.set('Content-Range', `equipment ${query._start || 0}-${query._end || result.total}/${result.total}`); res.set('Access-Control-Expose-Headers', 'Content-Range'); return res.json(result.data); @@ -18,21 +18,21 @@ export class EquipmentController { @Get(':id') findOne(@Param('id') id: string) { - return this.equipmentService.findOne(id); + return this.service.findOne(id); } @Post() create(@Body() dto: CreateEquipmentDto) { - return this.equipmentService.create(dto); + return this.service.create(dto); } @Patch(':id') update(@Param('id') id: string, @Body() dto: UpdateEquipmentDto) { - return this.equipmentService.update(id, dto); + return this.service.update(id, dto); } @Delete(':id') remove(@Param('id') id: string) { - return this.equipmentService.remove(id); + return this.service.remove(id); } } diff --git a/server/src/modules/equipment/equipment.service.ts b/server/src/modules/equipment/equipment.service.ts index f1d104d..3d05add 100644 --- a/server/src/modules/equipment/equipment.service.ts +++ b/server/src/modules/equipment/equipment.service.ts @@ -4,16 +4,6 @@ import { PrismaService } from '../../prisma/prisma.service'; import { CreateEquipmentDto } from './dto/create-equipment.dto'; import { UpdateEquipmentDto } from './dto/update-equipment.dto'; -function serializeRecord(record: any) { - return { - ...record, - totalEngineHours: record.totalEngineHours?.toString() ?? null, - engineHoursSinceLastRepair: record.engineHoursSinceLastRepair?.toString() ?? null, - commissionedAt: record.commissionedAt?.toISOString() ?? null, - lastRepairAt: record.lastRepairAt?.toISOString() ?? null, - }; -} - @Injectable() export class EquipmentService { constructor(private readonly prisma: PrismaService) {} @@ -27,62 +17,63 @@ export class EquipmentService { const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc'; const where: any = {}; + + if (query.q) { + const q = String(query.q); + const ors: any[] = []; + ors.push({ inventoryNumber: { contains: q, mode: 'insensitive' } }); + ors.push({ serialNumber: { contains: q, mode: 'insensitive' } }); + ors.push({ name: { contains: q, mode: 'insensitive' } }); + ors.push({ equipmentTypeCode: { contains: q, mode: 'insensitive' } }); + ors.push({ location: { contains: q, mode: 'insensitive' } }); + ors.push({ notes: { contains: q, mode: 'insensitive' } }); + if (ors.length) where.OR = ors; + } + if (query.inventoryNumber) where.inventoryNumber = { contains: query.inventoryNumber, mode: 'insensitive' }; + if (query.serialNumber) where.serialNumber = { contains: query.serialNumber, mode: 'insensitive' }; if (query.name) where.name = { contains: query.name, mode: 'insensitive' }; - if (query.equipmentTypeCode) where.equipmentTypeCode = query.equipmentTypeCode; - if (query.status) where.status = query.status; + if (query.equipmentTypeCode) where.equipmentTypeCode = { contains: query.equipmentTypeCode, mode: 'insensitive' }; if (query.location) where.location = { contains: query.location, mode: 'insensitive' }; + if (query.notes) where.notes = { contains: query.notes, mode: 'insensitive' }; + + // Enum multi-value support (e.g. status=A&status=B) + if (query.status) { const vals = Array.isArray(query.status) ? query.status : [query.status]; where.status = vals.length > 1 ? { in: vals } : vals[0]; } + if (query.id) { const ids = Array.isArray(query.id) ? query.id : [query.id]; where.id = { in: ids }; } const [data, total] = await Promise.all([ - this.prisma.equipment.findMany({ - where, - skip, - take, - orderBy: { [sortField]: sortOrder }, - }), + this.prisma.equipment.findMany({ where, skip, take, orderBy: { [sortField]: sortOrder } }), this.prisma.equipment.count({ where }), ]); - return { - data: data.map(serializeRecord), - total, - }; + const mapped = data; + return { data: mapped, total }; } async findOne(id: string) { - const record = await this.prisma.equipment.findUniqueOrThrow({ where: { id } }); - return serializeRecord(record); + const record = await this.prisma.equipment.findUniqueOrThrow({ where: { id: id } as any }); + return record; } async create(dto: CreateEquipmentDto) { - const data: any = { ...dto }; - if (dto.commissionedAt) data.commissionedAt = new Date(dto.commissionedAt); - if (dto.lastRepairAt) data.lastRepairAt = new Date(dto.lastRepairAt); - if (dto.totalEngineHours) data.totalEngineHours = new Prisma.Decimal(dto.totalEngineHours); - if (dto.engineHoursSinceLastRepair) data.engineHoursSinceLastRepair = new Prisma.Decimal(dto.engineHoursSinceLastRepair); - - const record = await this.prisma.equipment.create({ data }); - return serializeRecord(record); + const record = await this.prisma.equipment.create({ data: dto as any }); + return record; } async update(id: string, dto: UpdateEquipmentDto) { - const { id: _pk, ...rest } = dto as any; - const data: any = { ...rest }; - if (data.commissionedAt) data.commissionedAt = new Date(data.commissionedAt); - if (data.lastRepairAt) data.lastRepairAt = new Date(data.lastRepairAt); - if (data.totalEngineHours !== undefined && data.totalEngineHours !== null) data.totalEngineHours = new Prisma.Decimal(data.totalEngineHours); - if (data.engineHoursSinceLastRepair !== undefined && data.engineHoursSinceLastRepair !== null) data.engineHoursSinceLastRepair = new Prisma.Decimal(data.engineHoursSinceLastRepair); - - const record = await this.prisma.equipment.update({ where: { id }, data }); - return serializeRecord(record); + const data: any = { ...(dto as any) }; + delete data.id; + delete data.id; + const record = await this.prisma.equipment.update({ where: { id: id } as any, data }); + return record; } async remove(id: string) { - const record = await this.prisma.equipment.delete({ where: { id } }); - return serializeRecord(record); + const record = await this.prisma.equipment.delete({ where: { id: id } as any }); + return record; } } diff --git a/server/src/modules/repair-order/dto/create-repair-order.dto.ts b/server/src/modules/repair-order/dto/create-repair-order.dto.ts index 8b3c9b5..229f25f 100644 --- a/server/src/modules/repair-order/dto/create-repair-order.dto.ts +++ b/server/src/modules/repair-order/dto/create-repair-order.dto.ts @@ -1,9 +1,9 @@ export class CreateRepairOrderDto { - number: string; - equipmentId: string; - repairKind: string; - status?: string; - plannedAt: string; + number!: string; + equipmentId!: string; + repairKind!: string; + status!: string; + plannedAt!: string; startedAt?: string; completedAt?: string; contractor?: string; diff --git a/server/src/modules/repair-order/dto/update-repair-order.dto.ts b/server/src/modules/repair-order/dto/update-repair-order.dto.ts index 25f0cb8..3609ea1 100644 --- a/server/src/modules/repair-order/dto/update-repair-order.dto.ts +++ b/server/src/modules/repair-order/dto/update-repair-order.dto.ts @@ -1,13 +1,14 @@ export class UpdateRepairOrderDto { - number?: string; - equipmentId?: string; - repairKind?: string; - status?: string; - plannedAt?: string; - startedAt?: string; - completedAt?: string; - contractor?: string; - engineHoursAtRepair?: string; - description?: string; - notes?: string; + id?: string | null; + number?: string | null; + equipmentId?: string | null; + repairKind?: string | null; + status?: string | null; + plannedAt?: string | null; + startedAt?: string | null; + completedAt?: string | null; + contractor?: string | null; + engineHoursAtRepair?: string | null; + description?: string | null; + notes?: string | null; } diff --git a/server/src/modules/repair-order/repair-order.controller.ts b/server/src/modules/repair-order/repair-order.controller.ts index 8787894..30d282e 100644 --- a/server/src/modules/repair-order/repair-order.controller.ts +++ b/server/src/modules/repair-order/repair-order.controller.ts @@ -6,11 +6,11 @@ import { UpdateRepairOrderDto } from './dto/update-repair-order.dto'; @Controller('repair-orders') export class RepairOrderController { - constructor(private readonly repairOrderService: RepairOrderService) {} + constructor(private readonly service: RepairOrderService) {} @Get() async findAll(@Query() query: any, @Res() res: Response) { - const result = await this.repairOrderService.findAll(query); + const result = await this.service.findAll(query); res.set('Content-Range', `repair-orders ${query._start || 0}-${query._end || result.total}/${result.total}`); res.set('Access-Control-Expose-Headers', 'Content-Range'); return res.json(result.data); @@ -18,21 +18,21 @@ export class RepairOrderController { @Get(':id') findOne(@Param('id') id: string) { - return this.repairOrderService.findOne(id); + return this.service.findOne(id); } @Post() create(@Body() dto: CreateRepairOrderDto) { - return this.repairOrderService.create(dto); + return this.service.create(dto); } @Patch(':id') update(@Param('id') id: string, @Body() dto: UpdateRepairOrderDto) { - return this.repairOrderService.update(id, dto); + return this.service.update(id, dto); } @Delete(':id') remove(@Param('id') id: string) { - return this.repairOrderService.remove(id); + return this.service.remove(id); } } diff --git a/server/src/modules/repair-order/repair-order.service.ts b/server/src/modules/repair-order/repair-order.service.ts index d762254..68e4127 100644 --- a/server/src/modules/repair-order/repair-order.service.ts +++ b/server/src/modules/repair-order/repair-order.service.ts @@ -4,16 +4,6 @@ import { PrismaService } from '../../prisma/prisma.service'; import { CreateRepairOrderDto } from './dto/create-repair-order.dto'; import { UpdateRepairOrderDto } from './dto/update-repair-order.dto'; -function serializeRecord(record: any) { - return { - ...record, - engineHoursAtRepair: record.engineHoursAtRepair?.toString() ?? null, - plannedAt: record.plannedAt?.toISOString() ?? null, - startedAt: record.startedAt?.toISOString() ?? null, - completedAt: record.completedAt?.toISOString() ?? null, - }; -} - @Injectable() export class RepairOrderService { constructor(private readonly prisma: PrismaService) {} @@ -27,62 +17,60 @@ export class RepairOrderService { const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc'; const where: any = {}; + + if (query.q) { + const q = String(query.q); + const ors: any[] = []; + ors.push({ number: { contains: q, mode: 'insensitive' } }); + ors.push({ contractor: { contains: q, mode: 'insensitive' } }); + ors.push({ description: { contains: q, mode: 'insensitive' } }); + ors.push({ notes: { contains: q, mode: 'insensitive' } }); + if (ors.length) where.OR = ors; + } + if (query.number) where.number = { contains: query.number, mode: 'insensitive' }; - if (query.equipmentId) where.equipmentId = query.equipmentId; - if (query.repairKind) where.repairKind = query.repairKind; - if (query.status) where.status = query.status; if (query.contractor) where.contractor = { contains: query.contractor, mode: 'insensitive' }; + if (query.description) where.description = { contains: query.description, mode: 'insensitive' }; + if (query.notes) where.notes = { contains: query.notes, mode: 'insensitive' }; + + // Enum multi-value support (e.g. status=A&status=B) + if (query.repairKind) { const vals = Array.isArray(query.repairKind) ? query.repairKind : [query.repairKind]; where.repairKind = vals.length > 1 ? { in: vals } : vals[0]; } + if (query.status) { const vals = Array.isArray(query.status) ? query.status : [query.status]; where.status = vals.length > 1 ? { in: vals } : vals[0]; } + if (query.id) { const ids = Array.isArray(query.id) ? query.id : [query.id]; where.id = { in: ids }; } const [data, total] = await Promise.all([ - this.prisma.repairOrder.findMany({ - where, - skip, - take, - orderBy: { [sortField]: sortOrder }, - }), + this.prisma.repairOrder.findMany({ where, skip, take, orderBy: { [sortField]: sortOrder } }), this.prisma.repairOrder.count({ where }), ]); - return { - data: data.map(serializeRecord), - total, - }; + const mapped = data; + return { data: mapped, total }; } async findOne(id: string) { - const record = await this.prisma.repairOrder.findUniqueOrThrow({ where: { id } }); - return serializeRecord(record); + const record = await this.prisma.repairOrder.findUniqueOrThrow({ where: { id: id } as any }); + return record; } async create(dto: CreateRepairOrderDto) { - const data: any = { ...dto }; - if (dto.plannedAt) data.plannedAt = new Date(dto.plannedAt); - if (dto.startedAt) data.startedAt = new Date(dto.startedAt); - if (dto.completedAt) data.completedAt = new Date(dto.completedAt); - if (dto.engineHoursAtRepair) data.engineHoursAtRepair = new Prisma.Decimal(dto.engineHoursAtRepair); - - const record = await this.prisma.repairOrder.create({ data }); - return serializeRecord(record); + const record = await this.prisma.repairOrder.create({ data: dto as any }); + return record; } async update(id: string, dto: UpdateRepairOrderDto) { - const { id: _pk, ...rest } = dto as any; - const data: any = { ...rest }; - if (data.plannedAt) data.plannedAt = new Date(data.plannedAt); - if (data.startedAt) data.startedAt = new Date(data.startedAt); - if (data.completedAt) data.completedAt = new Date(data.completedAt); - if (data.engineHoursAtRepair !== undefined && data.engineHoursAtRepair !== null) data.engineHoursAtRepair = new Prisma.Decimal(data.engineHoursAtRepair); - - const record = await this.prisma.repairOrder.update({ where: { id }, data }); - return serializeRecord(record); + const data: any = { ...(dto as any) }; + delete data.id; + delete data.id; + const record = await this.prisma.repairOrder.update({ where: { id: id } as any, data }); + return record; } async remove(id: string) { - const record = await this.prisma.repairOrder.delete({ where: { id } }); - return serializeRecord(record); + const record = await this.prisma.repairOrder.delete({ where: { id: id } as any }); + return record; } }