Compare commits
1 Commits
67f7d617be
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05ae32c030 |
@@ -147,14 +147,14 @@
|
|||||||
"label": null
|
"label": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "attachment",
|
"name": "attachments",
|
||||||
"type": "DTO.FileAttachment",
|
"type": "DTO.FileAttachment[]",
|
||||||
"required": false,
|
"required": false,
|
||||||
"nullable": true,
|
"nullable": true,
|
||||||
"unique": false,
|
"unique": false,
|
||||||
"primary": false,
|
"primary": false,
|
||||||
"description": "Вложение (файл в MinIO/S3)",
|
"description": "Вложение (файл в MinIO/S3)",
|
||||||
"map": "Equipment.attachment",
|
"map": "Equipment.attachments",
|
||||||
"sync": false,
|
"sync": false,
|
||||||
"label": null
|
"label": null
|
||||||
}
|
}
|
||||||
@@ -763,9 +763,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "uploadEquipmentAttachment",
|
"name": "uploadEquipmentAttachment",
|
||||||
"label": "POST /equipment/{id}/attachment",
|
"label": "POST /equipment/{id}/attachments",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/equipment/{id}/attachment",
|
"path": "/equipment/{id}/attachments",
|
||||||
"description": "Загрузить файл-вложение (multipart/form-data, поле file)",
|
"description": "Загрузить файл-вложение (multipart/form-data, поле file)",
|
||||||
"attributes": [
|
"attributes": [
|
||||||
{
|
{
|
||||||
@@ -777,29 +777,39 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "deleteEquipmentAttachment",
|
"name": "deleteEquipmentAttachment",
|
||||||
"label": "DELETE /equipment/{id}/attachment",
|
"label": "DELETE /equipment/{id}/attachments/{attachmentId}",
|
||||||
"method": "DELETE",
|
"method": "DELETE",
|
||||||
"path": "/equipment/{id}/attachment",
|
"path": "/equipment/{id}/attachments/{attachmentId}",
|
||||||
"description": "Удалить файл-вложение из хранилища и из записи",
|
"description": "Удалить файл-вложение из хранилища и из записи",
|
||||||
"attributes": [
|
"attributes": [
|
||||||
{
|
{
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"type": "uuid",
|
"type": "uuid",
|
||||||
"description": null
|
"description": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "attachmentId",
|
||||||
|
"type": "uuid",
|
||||||
|
"description": null
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "downloadEquipmentAttachment",
|
"name": "downloadEquipmentAttachment",
|
||||||
"label": "GET /equipment/{id}/attachment/download",
|
"label": "GET /equipment/{id}/attachments/{attachmentId}/download",
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/equipment/{id}/attachment/download",
|
"path": "/equipment/{id}/attachments/{attachmentId}/download",
|
||||||
"description": "Скачать файл-вложение (Content-Disposition: attachment, поток из MinIO)",
|
"description": "Скачать файл-вложение (Content-Disposition: attachment, поток из MinIO)",
|
||||||
"attributes": [
|
"attributes": [
|
||||||
{
|
{
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"type": "uuid",
|
"type": "uuid",
|
||||||
"description": null
|
"description": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "attachmentId",
|
||||||
|
"type": "uuid",
|
||||||
|
"description": null
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
VITE_API_URL=http://localhost:3000
|
VITE_API_URL=/api
|
||||||
VITE_KEYCLOAK_URL=https://sso.greact.ru
|
VITE_KEYCLOAK_URL=https://sso.greact.ru
|
||||||
VITE_KEYCLOAK_REALM=toir
|
VITE_KEYCLOAK_REALM=toir
|
||||||
VITE_KEYCLOAK_CLIENT_ID=toir-frontend
|
VITE_KEYCLOAK_CLIENT_ID=toir-frontend
|
||||||
|
|||||||
644
client/package-lock.json
generated
644
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
export const env = {
|
export const env = {
|
||||||
apiUrl: import.meta.env.VITE_API_URL ?? 'http://localhost:3000',
|
apiUrl: import.meta.env.VITE_API_URL ?? '/api',
|
||||||
keycloakUrl: import.meta.env.VITE_KEYCLOAK_URL ?? 'https://sso.greact.ru',
|
keycloakUrl: import.meta.env.VITE_KEYCLOAK_URL ?? 'https://sso.greact.ru',
|
||||||
keycloakRealm: import.meta.env.VITE_KEYCLOAK_REALM ?? 'toir',
|
keycloakRealm: import.meta.env.VITE_KEYCLOAK_REALM ?? 'toir',
|
||||||
keycloakClientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID ?? 'toir-frontend',
|
keycloakClientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID ?? 'toir-frontend',
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const customRu = {
|
|||||||
serialNumber: 'Серийный номер',
|
serialNumber: 'Серийный номер',
|
||||||
dateOfInspection: 'Дата поверки',
|
dateOfInspection: 'Дата поверки',
|
||||||
commissionedAt: 'Дата изготовления',
|
commissionedAt: 'Дата изготовления',
|
||||||
|
installationDate: 'Дата установки',
|
||||||
|
writeOffDate: 'Дата списания',
|
||||||
status: 'Статус',
|
status: 'Статус',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -28,7 +30,7 @@ const customRu = {
|
|||||||
},
|
},
|
||||||
toir: {
|
toir: {
|
||||||
actions: {
|
actions: {
|
||||||
addFiles: 'Добавить файлы…',
|
addFiles: 'Добавить файлы...',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -41,4 +43,3 @@ export const messagesRu = {
|
|||||||
...customRu.resources,
|
...customRu.resources,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
131
client/src/layout/ToirMenu.css
Normal file
131
client/src/layout/ToirMenu.css
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
.toir-menu-item.MuiMenuItem-root,
|
||||||
|
.toir-menu-group.MuiListItemButton-root {
|
||||||
|
min-height: var(--toir-menu-item-height);
|
||||||
|
gap: var(--toir-menu-item-gap);
|
||||||
|
padding: var(--toir-menu-item-padding-y) var(--toir-menu-item-padding-x);
|
||||||
|
color: var(--toir-menu-text);
|
||||||
|
border-radius: 0 var(--toir-menu-radius) var(--toir-menu-radius) 0;
|
||||||
|
transition:
|
||||||
|
background-color var(--toir-menu-transition),
|
||||||
|
color var(--toir-menu-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toir-menu-item.MuiMenuItem-root:hover,
|
||||||
|
.toir-menu-item.RaMenuItemLink-active,
|
||||||
|
.toir-menu-group.MuiListItemButton-root:hover,
|
||||||
|
.toir-menu-group.Mui-selected {
|
||||||
|
background-color: var(--toir-menu-active-bg);
|
||||||
|
color: var(--toir-menu-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toir-menu-item.MuiMenuItem-root:hover,
|
||||||
|
.toir-menu-group.MuiListItemButton-root:hover {
|
||||||
|
background-color: var(--toir-menu-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toir-menu-item.MuiMenuItem-root .MuiListItemIcon-root {
|
||||||
|
min-width: var(--toir-menu-icon-slot);
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toir-menu-item.MuiMenuItem-root > .MuiTypography-root,
|
||||||
|
.toir-menu-group .MuiListItemText-primary {
|
||||||
|
font-size: var(--toir-menu-font-size);
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toir-menu-group__chevron {
|
||||||
|
display: inline-flex;
|
||||||
|
width: var(--toir-menu-chevron-size);
|
||||||
|
height: var(--toir-menu-chevron-size);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toir-menu-group__chevron span {
|
||||||
|
width: var(--toir-menu-chevron-stroke-size);
|
||||||
|
height: var(--toir-menu-chevron-stroke-size);
|
||||||
|
border-right: var(--toir-menu-guide-width) solid currentColor;
|
||||||
|
border-bottom: var(--toir-menu-guide-width) solid currentColor;
|
||||||
|
transform: rotate(45deg) translate(-2px, -2px);
|
||||||
|
transition: transform var(--toir-menu-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toir-menu-group__chevron.is-open span {
|
||||||
|
transform: rotate(225deg) translate(-2px, -2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toir-menu-subtree {
|
||||||
|
position: relative;
|
||||||
|
margin-left: var(--toir-menu-item-padding-x);
|
||||||
|
padding: 4px 0 var(--toir-menu-item-padding-y) var(--toir-menu-subtree-offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toir-menu-subtree::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: calc(var(--toir-menu-subitem-height) / 2 + 6px);
|
||||||
|
left: 0;
|
||||||
|
border-left: var(--toir-menu-guide-width) dashed var(--toir-menu-guide);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toir-menu-subitem.MuiMenuItem-root {
|
||||||
|
position: relative;
|
||||||
|
min-height: var(--toir-menu-subitem-height);
|
||||||
|
padding: 8px var(--toir-menu-subitem-padding-x) 8px var(--toir-menu-subitem-label-offset);
|
||||||
|
color: var(--toir-menu-text-muted);
|
||||||
|
border-radius: 0 var(--toir-menu-radius) var(--toir-menu-radius) 0;
|
||||||
|
white-space: normal;
|
||||||
|
transition:
|
||||||
|
background-color var(--toir-menu-transition),
|
||||||
|
color var(--toir-menu-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toir-menu-subitem.MuiMenuItem-root::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: calc(var(--toir-menu-subtree-offset) * -1);
|
||||||
|
top: 50%;
|
||||||
|
width: var(--toir-menu-subtree-offset);
|
||||||
|
border-top: var(--toir-menu-guide-width) dashed var(--toir-menu-guide);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toir-menu-subitem.MuiMenuItem-root::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: calc(var(--toir-menu-marker-size) / -2);
|
||||||
|
top: calc(50% - var(--toir-menu-marker-size) / 2);
|
||||||
|
width: var(--toir-menu-marker-size);
|
||||||
|
height: var(--toir-menu-marker-size);
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--toir-menu-marker-bg);
|
||||||
|
border: var(--toir-menu-guide-width) solid var(--toir-menu-guide-strong);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toir-menu-subitem.MuiMenuItem-root .MuiListItemIcon-root {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toir-menu-subitem.MuiMenuItem-root .MuiListItemText-primary {
|
||||||
|
font-size: var(--toir-menu-subitem-font-size);
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toir-menu-subitem.MuiMenuItem-root > .MuiTypography-root {
|
||||||
|
min-width: 0;
|
||||||
|
white-space: normal;
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toir-menu-subitem.MuiMenuItem-root:hover,
|
||||||
|
.toir-menu-subitem.RaMenuItemLink-active {
|
||||||
|
background-color: var(--toir-menu-hover-bg);
|
||||||
|
color: var(--toir-menu-text);
|
||||||
|
}
|
||||||
@@ -1,22 +1,66 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Box, Collapse, ListItemButton, ListItemText } from '@mui/material';
|
||||||
import { Menu, MenuItemLink } from 'react-admin';
|
import { Menu, MenuItemLink } from 'react-admin';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
EQUIPMENT_SIDEBAR_ARCHIVE_FILTER,
|
EQUIPMENT_SIDEBAR_ARCHIVE_FILTER,
|
||||||
EQUIPMENT_SIDEBAR_IN_WORK_FILTER,
|
EQUIPMENT_SIDEBAR_IN_WORK_FILTER,
|
||||||
|
STATUS_CHANGES_CLOSING_FILTER,
|
||||||
|
STATUS_CHANGES_DOWNTIME_FILTER,
|
||||||
equipmentListSearch,
|
equipmentListSearch,
|
||||||
|
statusChangesListSearch,
|
||||||
} from './toirMenuLinks';
|
} from './toirMenuLinks';
|
||||||
|
import './ToirMenu.css';
|
||||||
|
|
||||||
export function ToirMenu() {
|
export function ToirMenu() {
|
||||||
|
const location = useLocation();
|
||||||
|
const isStatusChangesRoute = location.pathname.startsWith('/status-changes');
|
||||||
|
const [statusChangesOpen, setStatusChangesOpen] = useState(false);
|
||||||
|
|
||||||
|
const statusChangesLinks = useMemo(
|
||||||
|
() => ({
|
||||||
|
downtime: `/status-changes?${statusChangesListSearch(STATUS_CHANGES_DOWNTIME_FILTER)}`,
|
||||||
|
closing: `/status-changes?${statusChangesListSearch(STATUS_CHANGES_CLOSING_FILTER)}`,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu>
|
<Menu>
|
||||||
<MenuItemLink
|
<MenuItemLink
|
||||||
|
className="toir-menu-item"
|
||||||
to={`/equipment?${equipmentListSearch(EQUIPMENT_SIDEBAR_IN_WORK_FILTER)}`}
|
to={`/equipment?${equipmentListSearch(EQUIPMENT_SIDEBAR_IN_WORK_FILTER)}`}
|
||||||
primaryText="В работе"
|
primaryText="В работе"
|
||||||
/>
|
/>
|
||||||
<MenuItemLink
|
<MenuItemLink
|
||||||
|
className="toir-menu-item"
|
||||||
to={`/equipment?${equipmentListSearch(EQUIPMENT_SIDEBAR_ARCHIVE_FILTER)}`}
|
to={`/equipment?${equipmentListSearch(EQUIPMENT_SIDEBAR_ARCHIVE_FILTER)}`}
|
||||||
primaryText="Архив"
|
primaryText="Архив"
|
||||||
/>
|
/>
|
||||||
<MenuItemLink to="/status-changes" primaryText="Журнал актов" />
|
<ListItemButton
|
||||||
|
className="toir-menu-group"
|
||||||
|
selected={isStatusChangesRoute}
|
||||||
|
onClick={() => setStatusChangesOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
<ListItemText primary="Журнал актов" />
|
||||||
|
<span className={`toir-menu-group__chevron${statusChangesOpen ? ' is-open' : ''}`} aria-hidden>
|
||||||
|
<span />
|
||||||
|
</span>
|
||||||
|
</ListItemButton>
|
||||||
|
<Collapse in={statusChangesOpen} timeout="auto" unmountOnExit>
|
||||||
|
<Box className="toir-menu-subtree">
|
||||||
|
<MenuItemLink
|
||||||
|
className="toir-menu-subitem"
|
||||||
|
to={statusChangesLinks.downtime}
|
||||||
|
primaryText="Простои"
|
||||||
|
/>
|
||||||
|
<MenuItemLink
|
||||||
|
className="toir-menu-subitem"
|
||||||
|
to={statusChangesLinks.closing}
|
||||||
|
primaryText="Закрывающие документы"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,3 +11,16 @@ export const EQUIPMENT_SIDEBAR_IN_WORK_FILTER = { status: ['Active'] };
|
|||||||
|
|
||||||
/** «Архив» → списанное оборудование (WriteOff). */
|
/** «Архив» → списанное оборудование (WriteOff). */
|
||||||
export const EQUIPMENT_SIDEBAR_ARCHIVE_FILTER = { status: ['WriteOff'] };
|
export const EQUIPMENT_SIDEBAR_ARCHIVE_FILTER = { status: ['WriteOff'] };
|
||||||
|
|
||||||
|
/** Параметры списка журнала актов в формате react-admin. */
|
||||||
|
export function statusChangesListSearch(filter: Record<string, unknown>): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('filter', JSON.stringify(filter));
|
||||||
|
return params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Простои» → акты, переводящие оборудование в ремонт. */
|
||||||
|
export const STATUS_CHANGES_DOWNTIME_FILTER = { newStatus: ['Repair'] };
|
||||||
|
|
||||||
|
/** «Закрывающие документы» → акты списания оборудования. */
|
||||||
|
export const STATUS_CHANGES_CLOSING_FILTER = { newStatus: ['WriteOff'] };
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type EquipmentRecord = {
|
|||||||
serialNumber: string;
|
serialNumber: string;
|
||||||
dateOfInspection: string | null;
|
dateOfInspection: string | null;
|
||||||
commissionedAt: string | null;
|
commissionedAt: string | null;
|
||||||
|
installationDate: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDate(value: string | null) {
|
function formatDate(value: string | null) {
|
||||||
@@ -171,7 +172,7 @@ export function EmbeddedActiveEquipmentPage() {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell sx={{ fontWeight: 700 }}>Наименование</TableCell>
|
<TableCell sx={{ fontWeight: 700 }}>Наименование</TableCell>
|
||||||
<TableCell sx={{ fontWeight: 700 }}>Заводской номер</TableCell>
|
<TableCell sx={{ fontWeight: 700 }}>Заводской номер</TableCell>
|
||||||
<TableCell sx={{ fontWeight: 700 }}>Дата изготовления</TableCell>
|
<TableCell sx={{ fontWeight: 700 }}>Дата установки</TableCell>
|
||||||
<TableCell sx={{ fontWeight: 700 }}>Дата поверки</TableCell>
|
<TableCell sx={{ fontWeight: 700 }}>Дата поверки</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
@@ -180,7 +181,7 @@ export function EmbeddedActiveEquipmentPage() {
|
|||||||
<TableRow key={item.id} hover>
|
<TableRow key={item.id} hover>
|
||||||
<TableCell>{item.name}</TableCell>
|
<TableCell>{item.name}</TableCell>
|
||||||
<TableCell>{item.serialNumber}</TableCell>
|
<TableCell>{item.serialNumber}</TableCell>
|
||||||
<TableCell>{formatDate(item.commissionedAt)}</TableCell>
|
<TableCell>{formatDate(item.installationDate)}</TableCell>
|
||||||
<TableCell>{formatDate(item.dateOfInspection)}</TableCell>
|
<TableCell>{formatDate(item.dateOfInspection)}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
CreateButton,
|
CreateButton,
|
||||||
Datagrid,
|
Datagrid,
|
||||||
@@ -6,7 +8,6 @@ import {
|
|||||||
FunctionField,
|
FunctionField,
|
||||||
List,
|
List,
|
||||||
ReferenceField,
|
ReferenceField,
|
||||||
SelectArrayInput,
|
|
||||||
SelectField,
|
SelectField,
|
||||||
TextField,
|
TextField,
|
||||||
TextInput,
|
TextInput,
|
||||||
@@ -15,10 +16,24 @@ import {
|
|||||||
import { equipmentStatusChoices } from '../equipment/shared';
|
import { equipmentStatusChoices } from '../equipment/shared';
|
||||||
import { StatusChangeAttachmentLink } from './StatusChangeAttachmentLink';
|
import { StatusChangeAttachmentLink } from './StatusChangeAttachmentLink';
|
||||||
|
|
||||||
|
function parseStatusChangesFilterFromSearch(search: string): Record<string, unknown> | undefined {
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
const raw = params.get('filter');
|
||||||
|
if (!raw) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const statusFilters = [
|
const statusFilters = [
|
||||||
<TextInput key="q" source="q" label="Поиск" alwaysOn />,
|
<TextInput key="q" source="q" label="Поиск" alwaysOn />,
|
||||||
<TextInput key="equipmentId" source="equipmentId" label="Оборудование" />,
|
<TextInput key="equipmentId" source="equipmentId" label="Оборудование" />,
|
||||||
<SelectArrayInput key="newStatus" source="newStatus" label="Новый статус" choices={equipmentStatusChoices} />,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const ListActions = () => (
|
const ListActions = () => (
|
||||||
@@ -29,16 +44,29 @@ const ListActions = () => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
export function ChangeEquipmentStatusList() {
|
export function ChangeEquipmentStatusList() {
|
||||||
|
const location = useLocation();
|
||||||
|
const filterDefaultValues = useMemo(
|
||||||
|
() => parseStatusChangesFilterFromSearch(location.search),
|
||||||
|
[location.search],
|
||||||
|
);
|
||||||
|
const listKey = `${location.pathname}${location.search}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List filters={statusFilters} actions={<ListActions />} sort={{ field: 'date', order: 'DESC' }}>
|
<List
|
||||||
|
key={listKey}
|
||||||
|
filters={statusFilters}
|
||||||
|
actions={<ListActions />}
|
||||||
|
sort={{ field: 'date', order: 'DESC' }}
|
||||||
|
filterDefaultValues={filterDefaultValues}
|
||||||
|
>
|
||||||
<Datagrid rowClick="show">
|
<Datagrid rowClick="show">
|
||||||
<TextField source="number" />
|
<TextField source="number" label="Номер" />
|
||||||
<DateField source="date" />
|
<DateField source="date" label="Дата" />
|
||||||
<ReferenceField source="equipmentId" reference="equipment" link="show">
|
<ReferenceField source="equipmentId" reference="equipment" link="show">
|
||||||
<TextField source="name" />
|
<TextField source="name" label="Оборудование" />
|
||||||
</ReferenceField>
|
</ReferenceField>
|
||||||
<SelectField source="newStatus" choices={equipmentStatusChoices} />
|
<SelectField source="newStatus" label="Новый статус" choices={equipmentStatusChoices} />
|
||||||
<TextField source="responsible" />
|
<TextField source="responsible" label="Ответственный" />
|
||||||
<FunctionField
|
<FunctionField
|
||||||
label="Файлы"
|
label="Файлы"
|
||||||
render={(record: {
|
render={(record: {
|
||||||
|
|||||||
31
client/src/resources/equipment/EquipmentAttachmentLink.tsx
Normal file
31
client/src/resources/equipment/EquipmentAttachmentLink.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Link } from '@mui/material';
|
||||||
|
import { useNotify } from 'react-admin';
|
||||||
|
import { downloadEquipmentAttachmentFile } from './attachmentDownload';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
equipmentId: string;
|
||||||
|
attachmentId: string;
|
||||||
|
fileName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EquipmentAttachmentLink({ equipmentId, attachmentId, fileName }: Props) {
|
||||||
|
const notify = useNotify();
|
||||||
|
const label = fileName.trim() || 'Скачать';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
void downloadEquipmentAttachmentFile(equipmentId, attachmentId, label).catch(() =>
|
||||||
|
notify('Не удалось скачать файл', { type: 'warning' }),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
sx={{ cursor: 'pointer', textAlign: 'left', verticalAlign: 'inherit' }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
client/src/resources/equipment/EquipmentAttachmentsInput.tsx
Normal file
172
client/src/resources/equipment/EquipmentAttachmentsInput.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { Button, Stack, Typography } from '@mui/material';
|
||||||
|
import { useNotify, useRecordContext, useRefresh } from 'react-admin';
|
||||||
|
import { useCallback, useMemo, useState, type ChangeEvent } from 'react';
|
||||||
|
import { ensureFreshToken, getAccessToken } from '../../auth/keycloak';
|
||||||
|
import { resolveApiBaseUrl } from '../../lib/apiBase';
|
||||||
|
import { EquipmentAttachmentLink } from './EquipmentAttachmentLink';
|
||||||
|
|
||||||
|
export type EquipmentAttachmentValue = {
|
||||||
|
id: string;
|
||||||
|
originalFileName?: string | null;
|
||||||
|
sizeBytes?: number | null;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
function formatBytes(bytes: number | null | undefined) {
|
||||||
|
if (!bytes || bytes <= 0) return null;
|
||||||
|
const kb = bytes / 1024;
|
||||||
|
if (kb < 1024) return `${Math.round(kb)} КБ`;
|
||||||
|
return `${(kb / 1024).toFixed(1)} МБ`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EquipmentAttachmentsInput() {
|
||||||
|
const record = useRecordContext();
|
||||||
|
const notify = useNotify();
|
||||||
|
const refresh = useRefresh();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const equipmentId = record?.id ? String(record.id) : null;
|
||||||
|
const attachments = useMemo(() => {
|
||||||
|
const raw = (record as { attachments?: EquipmentAttachmentValue | null })?.attachments;
|
||||||
|
return Array.isArray(raw) ? raw : [];
|
||||||
|
}, [record]);
|
||||||
|
|
||||||
|
const upload = useCallback(
|
||||||
|
async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(event.target.files ?? []);
|
||||||
|
event.target.value = '';
|
||||||
|
if (!files.length || !equipmentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await ensureFreshToken();
|
||||||
|
const token = getAccessToken();
|
||||||
|
const url = `${resolveApiBaseUrl()}/equipment/${equipmentId}/attachments`;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notify(files.length === 1 ? 'Файл добавлен' : `Файлы добавлены: ${files.length}`, {
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
refresh();
|
||||||
|
} catch {
|
||||||
|
notify('Не удалось загрузить файл', { type: 'warning' });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[equipmentId, notify, refresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
const remove = useCallback(
|
||||||
|
async (attachmentId: string) => {
|
||||||
|
if (!equipmentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await ensureFreshToken();
|
||||||
|
const token = getAccessToken();
|
||||||
|
const url = `${resolveApiBaseUrl()}/equipment/${equipmentId}/attachments/${attachmentId}`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: token
|
||||||
|
? { Authorization: `Bearer ${token}`, Accept: 'application/json' }
|
||||||
|
: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
notify('Вложение удалено', { type: 'success' });
|
||||||
|
refresh();
|
||||||
|
} catch {
|
||||||
|
notify('Не удалось удалить файл', { type: 'warning' });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[equipmentId, notify, refresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={1.25} sx={{ maxWidth: 680 }}>
|
||||||
|
<Typography component="div" variant="body2" sx={{ fontWeight: 600 }}>
|
||||||
|
Вложения (файлы)
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{!equipmentId ? (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Сначала сохраните запись, затем можно будет прикреплять файлы.
|
||||||
|
</Typography>
|
||||||
|
) : attachments.length ? (
|
||||||
|
<Stack spacing={0.75}>
|
||||||
|
{attachments.map((att) => (
|
||||||
|
<Stack
|
||||||
|
key={att.id}
|
||||||
|
direction={{ xs: 'column', sm: 'row' }}
|
||||||
|
spacing={1}
|
||||||
|
alignItems={{ xs: 'flex-start', sm: 'center' }}
|
||||||
|
justifyContent="space-between"
|
||||||
|
sx={{
|
||||||
|
py: 0.75,
|
||||||
|
px: 1,
|
||||||
|
borderRadius: 2,
|
||||||
|
border: (theme) => `1px solid ${theme.palette.divider}`,
|
||||||
|
background:
|
||||||
|
'linear-gradient(155deg, rgba(255,255,255,0.06), rgba(0,0,0,0.08))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={0.25} sx={{ minWidth: 0 }}>
|
||||||
|
<Typography variant="body2" component="div" sx={{ fontWeight: 500 }}>
|
||||||
|
<EquipmentAttachmentLink
|
||||||
|
equipmentId={equipmentId}
|
||||||
|
attachmentId={att.id}
|
||||||
|
fileName={att.originalFileName ?? 'файл'}
|
||||||
|
/>
|
||||||
|
</Typography>
|
||||||
|
{formatBytes(att.sizeBytes) ? (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{formatBytes(att.sizeBytes)}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => remove(att.id)}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Файлы не прикреплены
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
|
<Button variant="outlined" component="label" disabled={busy || !equipmentId}>
|
||||||
|
Добавить файлы...
|
||||||
|
<input type="file" hidden multiple onChange={upload} />
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,23 +1,25 @@
|
|||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
import { Create, DateInput, SelectInput, SimpleForm, TextInput as RaTextInput } from 'react-admin';
|
import { Create, DateInput, SelectInput, SimpleForm, TextInput as RaTextInput } from 'react-admin';
|
||||||
import { equipmentStatusChoices } from './shared';
|
import {
|
||||||
|
equipmentLabels,
|
||||||
const equipmentLabels = {
|
equipmentStatusChoices,
|
||||||
dateOfInspection: 'Дата поверки',
|
getEquipmentCreateDefaultValues,
|
||||||
commissionedAt: 'Дата изготовления',
|
getEquipmentPageContextFromSearch,
|
||||||
name: 'Название',
|
} from './shared';
|
||||||
serialNumber: 'Серийный номер',
|
|
||||||
status: 'Статус',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function EquipmentCreate() {
|
export function EquipmentCreate() {
|
||||||
|
const location = useLocation();
|
||||||
|
const pageContext = getEquipmentPageContextFromSearch(location.search);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Create>
|
<Create>
|
||||||
<SimpleForm>
|
<SimpleForm defaultValues={getEquipmentCreateDefaultValues(pageContext)}>
|
||||||
<DateInput source="dateOfInspection" label={equipmentLabels.dateOfInspection} />
|
|
||||||
<DateInput source="commissionedAt" label={equipmentLabels.commissionedAt} />
|
|
||||||
<RaTextInput source="name" label={equipmentLabels.name} required />
|
<RaTextInput source="name" label={equipmentLabels.name} required />
|
||||||
<RaTextInput source="serialNumber" label={equipmentLabels.serialNumber} required />
|
<RaTextInput source="serialNumber" label={equipmentLabels.serialNumber} required />
|
||||||
<SelectInput source="status" label={equipmentLabels.status} choices={equipmentStatusChoices} required />
|
<SelectInput source="status" label={equipmentLabels.status} choices={equipmentStatusChoices} required />
|
||||||
|
<DateInput source="dateOfInspection" label={equipmentLabels.dateOfInspection} />
|
||||||
|
<DateInput source="installationDate" label={equipmentLabels.installationDate} />
|
||||||
|
<DateInput source="writeOffDate" label={equipmentLabels.writeOffDate} />
|
||||||
</SimpleForm>
|
</SimpleForm>
|
||||||
</Create>
|
</Create>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import { DateInput, Edit, SelectInput, SimpleForm, TextInput as RaTextInput } from 'react-admin';
|
import { DateInput, Edit, SelectInput, SimpleForm, TextInput as RaTextInput } from 'react-admin';
|
||||||
import { equipmentStatusChoices } from './shared';
|
import { EquipmentAttachmentsInput } from './EquipmentAttachmentsInput';
|
||||||
|
import { equipmentLabels, equipmentStatusChoices } from './shared';
|
||||||
const equipmentLabels = {
|
|
||||||
dateOfInspection: 'Дата поверки',
|
|
||||||
commissionedAt: 'Дата изготовления',
|
|
||||||
name: 'Название',
|
|
||||||
serialNumber: 'Серийный номер',
|
|
||||||
status: 'Статус',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function EquipmentEdit() {
|
export function EquipmentEdit() {
|
||||||
return (
|
return (
|
||||||
@@ -27,8 +20,11 @@ export function EquipmentEdit() {
|
|||||||
fullWidth
|
fullWidth
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<DateInput source="commissionedAt" label={equipmentLabels.commissionedAt} fullWidth />
|
|
||||||
<DateInput source="dateOfInspection" label={equipmentLabels.dateOfInspection} fullWidth />
|
<DateInput source="dateOfInspection" label={equipmentLabels.dateOfInspection} fullWidth />
|
||||||
|
<DateInput source="commissionedAt" label={equipmentLabels.commissionedAt} fullWidth />
|
||||||
|
<DateInput source="installationDate" label={equipmentLabels.installationDate} fullWidth />
|
||||||
|
<DateInput source="writeOffDate" label={equipmentLabels.writeOffDate} fullWidth />
|
||||||
|
<EquipmentAttachmentsInput />
|
||||||
</SimpleForm>
|
</SimpleForm>
|
||||||
</Edit>
|
</Edit>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,60 +1,171 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo, type ReactNode } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
CreateButton,
|
CreateButton,
|
||||||
Datagrid,
|
Datagrid,
|
||||||
DateField,
|
DateField,
|
||||||
FilterButton,
|
FilterButton,
|
||||||
|
FunctionField,
|
||||||
List,
|
List,
|
||||||
TextField,
|
TextField,
|
||||||
TextInput,
|
TextInput,
|
||||||
TopToolbar,
|
TopToolbar,
|
||||||
} from 'react-admin';
|
} from 'react-admin';
|
||||||
|
import { EquipmentAttachmentLink } from './EquipmentAttachmentLink';
|
||||||
function parseListFilterFromSearch(search: string): Record<string, unknown> | undefined {
|
import {
|
||||||
const params = new URLSearchParams(search);
|
equipmentLabels,
|
||||||
const raw = params.get('filter');
|
getEquipmentCreatePath,
|
||||||
if (!raw) {
|
getEquipmentPageContextFromFilter,
|
||||||
return undefined;
|
parseEquipmentFilterFromSearch,
|
||||||
}
|
type EquipmentPageContext,
|
||||||
try {
|
} from './shared';
|
||||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
||||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : undefined;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const equipmentFilters = [
|
const equipmentFilters = [
|
||||||
<TextInput key="q" source="q" label="Поиск" alwaysOn />,
|
<TextInput key="q" source="q" label="Поиск" alwaysOn />,
|
||||||
];
|
];
|
||||||
|
|
||||||
const ListActions = () => (
|
type ListActionsProps = {
|
||||||
|
pageContext: EquipmentPageContext;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EquipmentAttachmentListItem = {
|
||||||
|
id: string;
|
||||||
|
originalFileName?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EquipmentWithAttachments = {
|
||||||
|
id: string;
|
||||||
|
attachments?: EquipmentAttachmentListItem[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EquipmentFilesFieldProps = {
|
||||||
|
source: string;
|
||||||
|
label: string;
|
||||||
|
sortable?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EquipmentFilesField = (props: EquipmentFilesFieldProps) => (
|
||||||
|
<FunctionField
|
||||||
|
{...props}
|
||||||
|
source="attachments"
|
||||||
|
label="Файлы"
|
||||||
|
sortable={false}
|
||||||
|
render={(record: EquipmentWithAttachments) => {
|
||||||
|
const attachments = Array.isArray(record.attachments) ? record.attachments : [];
|
||||||
|
if (!attachments.length) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
const [firstAttachment, ...restAttachments] = attachments;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
<EquipmentAttachmentLink
|
||||||
|
equipmentId={record.id}
|
||||||
|
attachmentId={firstAttachment.id}
|
||||||
|
fileName={firstAttachment.originalFileName ?? 'файл'}
|
||||||
|
/>
|
||||||
|
{restAttachments.length ? ` +${restAttachments.length}` : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ListActions = ({ pageContext }: ListActionsProps) => (
|
||||||
<TopToolbar>
|
<TopToolbar>
|
||||||
<CreateButton />
|
<CreateButton to={getEquipmentCreatePath(pageContext)} />
|
||||||
<FilterButton />
|
<FilterButton />
|
||||||
</TopToolbar>
|
</TopToolbar>
|
||||||
);
|
);
|
||||||
|
|
||||||
export function EquipmentList() {
|
type EquipmentListLayoutProps = {
|
||||||
const location = useLocation();
|
children: ReactNode;
|
||||||
const filterDefaultValues = useMemo(() => parseListFilterFromSearch(location.search), [location.search]);
|
filterDefaultValues?: Record<string, unknown>;
|
||||||
const listKey = `${location.pathname}${location.search}`;
|
listKey: string;
|
||||||
|
pageContext: EquipmentPageContext;
|
||||||
|
};
|
||||||
|
|
||||||
|
function EquipmentListLayout({
|
||||||
|
children,
|
||||||
|
filterDefaultValues,
|
||||||
|
listKey,
|
||||||
|
pageContext,
|
||||||
|
}: EquipmentListLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<List
|
<List
|
||||||
key={listKey}
|
key={listKey}
|
||||||
filters={equipmentFilters}
|
filters={equipmentFilters}
|
||||||
actions={<ListActions />}
|
actions={<ListActions pageContext={pageContext} />}
|
||||||
sort={{ field: 'name', order: 'ASC' }}
|
sort={{ field: 'name', order: 'ASC' }}
|
||||||
filterDefaultValues={filterDefaultValues}
|
filterDefaultValues={filterDefaultValues}
|
||||||
>
|
>
|
||||||
<Datagrid rowClick="show">
|
{children}
|
||||||
<TextField source="name" />
|
|
||||||
<TextField source="serialNumber" />
|
|
||||||
<DateField source="dateOfInspection" />
|
|
||||||
<DateField source="commissionedAt" />
|
|
||||||
</Datagrid>
|
|
||||||
</List>
|
</List>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EquipmentStatusListProps = {
|
||||||
|
filterDefaultValues?: Record<string, unknown>;
|
||||||
|
listKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EquipmentActiveList({ filterDefaultValues, listKey }: EquipmentStatusListProps) {
|
||||||
|
return (
|
||||||
|
<EquipmentListLayout filterDefaultValues={filterDefaultValues} listKey={listKey} pageContext="active">
|
||||||
|
<Datagrid rowClick="show">
|
||||||
|
<TextField source="name" label={equipmentLabels.name} />
|
||||||
|
<TextField source="serialNumber" label={equipmentLabels.serialNumber} />
|
||||||
|
<DateField source="dateOfInspection" label={equipmentLabels.dateOfInspection} />
|
||||||
|
<DateField source="installationDate" label={equipmentLabels.installationDate} />
|
||||||
|
<EquipmentFilesField source="attachments" label="Файлы" sortable={false} />
|
||||||
|
</Datagrid>
|
||||||
|
</EquipmentListLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EquipmentArchiveList({ filterDefaultValues, listKey }: EquipmentStatusListProps) {
|
||||||
|
return (
|
||||||
|
<EquipmentListLayout filterDefaultValues={filterDefaultValues} listKey={listKey} pageContext="archive">
|
||||||
|
<Datagrid rowClick="show">
|
||||||
|
<TextField source="name" label={equipmentLabels.name} />
|
||||||
|
<TextField source="serialNumber" label={equipmentLabels.serialNumber} />
|
||||||
|
<DateField source="installationDate" label={equipmentLabels.installationDate} />
|
||||||
|
<DateField source="writeOffDate" label={equipmentLabels.writeOffDate} />
|
||||||
|
<EquipmentFilesField source="attachments" label="Файлы" sortable={false} />
|
||||||
|
</Datagrid>
|
||||||
|
</EquipmentListLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EquipmentDefaultList({ filterDefaultValues, listKey }: EquipmentStatusListProps) {
|
||||||
|
return (
|
||||||
|
<EquipmentListLayout filterDefaultValues={filterDefaultValues} listKey={listKey} pageContext="default">
|
||||||
|
<Datagrid rowClick="show">
|
||||||
|
<TextField source="name" label={equipmentLabels.name} />
|
||||||
|
<TextField source="serialNumber" label={equipmentLabels.serialNumber} />
|
||||||
|
<DateField source="dateOfInspection" label={equipmentLabels.dateOfInspection} />
|
||||||
|
<DateField source="commissionedAt" label={equipmentLabels.commissionedAt} />
|
||||||
|
<DateField source="installationDate" label={equipmentLabels.installationDate} />
|
||||||
|
<DateField source="writeOffDate" label={equipmentLabels.writeOffDate} />
|
||||||
|
</Datagrid>
|
||||||
|
</EquipmentListLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EquipmentList() {
|
||||||
|
const location = useLocation();
|
||||||
|
const filterDefaultValues = useMemo(() => parseEquipmentFilterFromSearch(location.search), [location.search]);
|
||||||
|
const pageContext = getEquipmentPageContextFromFilter(filterDefaultValues);
|
||||||
|
const listKey = `${location.pathname}${location.search}`;
|
||||||
|
|
||||||
|
if (pageContext === 'archive') {
|
||||||
|
return <EquipmentArchiveList filterDefaultValues={filterDefaultValues} listKey={listKey} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageContext === 'active') {
|
||||||
|
return <EquipmentActiveList filterDefaultValues={filterDefaultValues} listKey={listKey} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <EquipmentDefaultList filterDefaultValues={filterDefaultValues} listKey={listKey} />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,44 @@
|
|||||||
import { DateField, SelectField, Show, SimpleShowLayout, TextField } from 'react-admin';
|
import { DateField, FunctionField, SelectField, Show, SimpleShowLayout, TextField } from 'react-admin';
|
||||||
import { equipmentStatusChoices } from './shared';
|
import { EquipmentAttachmentLink } from './EquipmentAttachmentLink';
|
||||||
|
import { equipmentLabels, equipmentStatusChoices } from './shared';
|
||||||
|
|
||||||
export function EquipmentShow() {
|
export function EquipmentShow() {
|
||||||
return (
|
return (
|
||||||
<Show>
|
<Show>
|
||||||
<SimpleShowLayout>
|
<SimpleShowLayout>
|
||||||
<TextField source="id" />
|
<TextField source="id" />
|
||||||
<TextField source="name" />
|
<TextField source="name" label={equipmentLabels.name} />
|
||||||
<TextField source="serialNumber" />
|
<TextField source="serialNumber" label={equipmentLabels.serialNumber} />
|
||||||
<DateField source="dateOfInspection" />
|
<DateField source="dateOfInspection" label={equipmentLabels.dateOfInspection} />
|
||||||
<DateField source="commissionedAt" />
|
<DateField source="commissionedAt" label={equipmentLabels.commissionedAt} />
|
||||||
<SelectField source="status" choices={equipmentStatusChoices} />
|
<DateField source="installationDate" label={equipmentLabels.installationDate} />
|
||||||
|
<DateField source="writeOffDate" label={equipmentLabels.writeOffDate} />
|
||||||
|
<SelectField source="status" label={equipmentLabels.status} choices={equipmentStatusChoices} />
|
||||||
|
<FunctionField
|
||||||
|
label="Вложения"
|
||||||
|
render={(record: {
|
||||||
|
id: string;
|
||||||
|
attachments?: { id: string; originalFileName?: string | null }[] | null;
|
||||||
|
}) => {
|
||||||
|
const items = Array.isArray(record.attachments) ? record.attachments : [];
|
||||||
|
if (!items.length) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{items.map((att) => (
|
||||||
|
<div key={att.id}>
|
||||||
|
<EquipmentAttachmentLink
|
||||||
|
equipmentId={record.id}
|
||||||
|
attachmentId={att.id}
|
||||||
|
fileName={att.originalFileName ?? 'файл'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</SimpleShowLayout>
|
</SimpleShowLayout>
|
||||||
</Show>
|
</Show>
|
||||||
);
|
);
|
||||||
|
|||||||
29
client/src/resources/equipment/attachmentDownload.ts
Normal file
29
client/src/resources/equipment/attachmentDownload.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { ensureFreshToken, getAccessToken } from '../../auth/keycloak';
|
||||||
|
import { resolveApiBaseUrl } from '../../lib/apiBase';
|
||||||
|
|
||||||
|
export async function downloadEquipmentAttachmentFile(
|
||||||
|
equipmentId: string,
|
||||||
|
attachmentId: string,
|
||||||
|
suggestedFileName: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await ensureFreshToken();
|
||||||
|
const token = getAccessToken();
|
||||||
|
const url = `${resolveApiBaseUrl()}/equipment/${equipmentId}/attachments/${attachmentId}/download`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}`, Accept: '*/*' } : { Accept: '*/*' },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Download failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = objectUrl;
|
||||||
|
a.download = suggestedFileName || 'download';
|
||||||
|
a.rel = 'noopener';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
@@ -5,6 +5,20 @@ export const equipmentStatusChoices = [
|
|||||||
{ id: 'WriteOff', name: 'Списано' },
|
{ id: 'WriteOff', name: 'Списано' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export type EquipmentPageContext = 'active' | 'archive' | 'default';
|
||||||
|
|
||||||
|
export const EQUIPMENT_PAGE_CONTEXT_PARAM = 'equipmentPageContext';
|
||||||
|
|
||||||
|
export const equipmentLabels = {
|
||||||
|
dateOfInspection: 'Дата поверки',
|
||||||
|
commissionedAt: 'Дата изготовления',
|
||||||
|
installationDate: 'Дата установки',
|
||||||
|
name: 'Название',
|
||||||
|
serialNumber: 'Серийный номер',
|
||||||
|
status: 'Статус',
|
||||||
|
writeOffDate: 'Дата списания',
|
||||||
|
};
|
||||||
|
|
||||||
export const equipmentOptionText = (record?: {
|
export const equipmentOptionText = (record?: {
|
||||||
name?: string;
|
name?: string;
|
||||||
serialNumber?: string;
|
serialNumber?: string;
|
||||||
@@ -16,3 +30,70 @@ export const equipmentOptionText = (record?: {
|
|||||||
|
|
||||||
return record.name ?? record.serialNumber ?? record.id ?? '';
|
return record.name ?? record.serialNumber ?? record.id ?? '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function parseEquipmentFilterFromSearch(search: string): Record<string, unknown> | undefined {
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
const raw = params.get('filter');
|
||||||
|
if (!raw) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function equipmentFilterHasStatus(filter: Record<string, unknown> | undefined, status: string) {
|
||||||
|
const value = filter?.status;
|
||||||
|
return Array.isArray(value) ? value.includes(status) : value === status;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEquipmentPageContextFromFilter(
|
||||||
|
filter: Record<string, unknown> | undefined,
|
||||||
|
): EquipmentPageContext {
|
||||||
|
if (equipmentFilterHasStatus(filter, 'WriteOff')) {
|
||||||
|
return 'archive';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (equipmentFilterHasStatus(filter, 'Active')) {
|
||||||
|
return 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEquipmentPageContextFromSearch(search: string): EquipmentPageContext {
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
const context = params.get(EQUIPMENT_PAGE_CONTEXT_PARAM);
|
||||||
|
|
||||||
|
if (context === 'active' || context === 'archive') {
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getEquipmentPageContextFromFilter(parseEquipmentFilterFromSearch(search));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEquipmentCreatePath(context: EquipmentPageContext): string {
|
||||||
|
if (context === 'default') {
|
||||||
|
return '/equipment/create';
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set(EQUIPMENT_PAGE_CONTEXT_PARAM, context);
|
||||||
|
return `/equipment/create?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEquipmentCreateDefaultValues(context: EquipmentPageContext) {
|
||||||
|
if (context === 'active') {
|
||||||
|
return { status: 'Active' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context === 'archive') {
|
||||||
|
return { status: 'WriteOff' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ function buildTokens(mode: Mode) {
|
|||||||
accent: '#bd8d64',
|
accent: '#bd8d64',
|
||||||
accentSoft: 'rgba(182, 130, 81, 0.22)',
|
accentSoft: 'rgba(182, 130, 81, 0.22)',
|
||||||
shadow: '0 20px 48px rgba(42, 29, 20, 0.10)',
|
shadow: '0 20px 48px rgba(42, 29, 20, 0.10)',
|
||||||
|
menuText: '#342820',
|
||||||
|
menuTextMuted: 'rgba(52, 40, 32, 0.72)',
|
||||||
|
menuHover: 'rgba(189, 141, 100, 0.10)',
|
||||||
|
menuActive: 'rgba(189, 141, 100, 0.16)',
|
||||||
|
menuGuide: 'rgba(52, 40, 32, 0.24)',
|
||||||
|
menuGuideStrong: 'rgba(52, 40, 32, 0.40)',
|
||||||
|
menuMarkerBg: '#fffaf4',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +37,13 @@ function buildTokens(mode: Mode) {
|
|||||||
accent: '#d4a574',
|
accent: '#d4a574',
|
||||||
accentSoft: 'rgba(201, 122, 61, 0.24)',
|
accentSoft: 'rgba(201, 122, 61, 0.24)',
|
||||||
shadow: '0 34px 86px rgba(0, 0, 0, 0.56)',
|
shadow: '0 34px 86px rgba(0, 0, 0, 0.56)',
|
||||||
|
menuText: '#f8fafc',
|
||||||
|
menuTextMuted: 'rgba(248, 250, 252, 0.72)',
|
||||||
|
menuHover: 'rgba(212, 165, 116, 0.12)',
|
||||||
|
menuActive: 'rgba(212, 165, 116, 0.18)',
|
||||||
|
menuGuide: 'rgba(248, 250, 252, 0.22)',
|
||||||
|
menuGuideStrong: 'rgba(248, 250, 252, 0.38)',
|
||||||
|
menuMarkerBg: '#18120d',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +51,10 @@ export function buildToirMuiTheme(mode: Mode): Theme {
|
|||||||
const t = buildTokens(mode);
|
const t = buildTokens(mode);
|
||||||
|
|
||||||
return createTheme({
|
return createTheme({
|
||||||
|
sidebar: {
|
||||||
|
width: 268,
|
||||||
|
closedWidth: 55,
|
||||||
|
},
|
||||||
palette: {
|
palette: {
|
||||||
mode,
|
mode,
|
||||||
primary: { main: t.accent },
|
primary: { main: t.accent },
|
||||||
@@ -63,6 +81,30 @@ export function buildToirMuiTheme(mode: Mode): Theme {
|
|||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
body: {
|
body: {
|
||||||
background: t.bgDefault,
|
background: t.bgDefault,
|
||||||
|
'--toir-menu-text': t.menuText,
|
||||||
|
'--toir-menu-text-muted': t.menuTextMuted,
|
||||||
|
'--toir-menu-hover-bg': t.menuHover,
|
||||||
|
'--toir-menu-active-bg': t.menuActive,
|
||||||
|
'--toir-menu-guide': t.menuGuide,
|
||||||
|
'--toir-menu-guide-strong': t.menuGuideStrong,
|
||||||
|
'--toir-menu-marker-bg': t.menuMarkerBg,
|
||||||
|
'--toir-menu-radius': '24px',
|
||||||
|
'--toir-menu-item-height': '56px',
|
||||||
|
'--toir-menu-subitem-height': '52px',
|
||||||
|
'--toir-menu-item-padding-y': '10px',
|
||||||
|
'--toir-menu-item-padding-x': '24px',
|
||||||
|
'--toir-menu-item-gap': '12px',
|
||||||
|
'--toir-menu-icon-slot': '36px',
|
||||||
|
'--toir-menu-chevron-size': '24px',
|
||||||
|
'--toir-menu-chevron-stroke-size': '11px',
|
||||||
|
'--toir-menu-font-size': '1.12rem',
|
||||||
|
'--toir-menu-subitem-font-size': '0.98rem',
|
||||||
|
'--toir-menu-subtree-offset': '30px',
|
||||||
|
'--toir-menu-subitem-padding-x': '14px',
|
||||||
|
'--toir-menu-subitem-label-offset': '24px',
|
||||||
|
'--toir-menu-marker-size': '10px',
|
||||||
|
'--toir-menu-guide-width': '2px',
|
||||||
|
'--toir-menu-transition': '160ms ease',
|
||||||
'& .RaList-content > .MuiCardContent-root:only-child:has(button[aria-label="Очистить фильтры"])': {
|
'& .RaList-content > .MuiCardContent-root:only-child:has(button[aria-label="Очистить фильтры"])': {
|
||||||
display: 'none',
|
display: 'none',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,4 +4,12 @@ import react from '@vitejs/plugin-react'
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -59,11 +59,11 @@ dto DTO.Equipment {
|
|||||||
description "Текущий статус";
|
description "Текущий статус";
|
||||||
map Equipment.status;
|
map Equipment.status;
|
||||||
}
|
}
|
||||||
attribute attachment {
|
attribute attachments {
|
||||||
type DTO.FileAttachment;
|
type DTO.FileAttachment[];
|
||||||
is nullable;
|
is nullable;
|
||||||
description "Вложение (файл в MinIO/S3)";
|
description "Вложение (файл в MinIO/S3)";
|
||||||
map Equipment.attachment;
|
map Equipment.attachments;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,7 +347,7 @@ api API.Equipment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
endpoint uploadEquipmentAttachment {
|
endpoint uploadEquipmentAttachment {
|
||||||
label "POST /equipment/{id}/attachment";
|
label "POST /equipment/{id}/attachments";
|
||||||
description "Загрузить файл-вложение (multipart/form-data, поле file)";
|
description "Загрузить файл-вложение (multipart/form-data, поле file)";
|
||||||
attribute id {
|
attribute id {
|
||||||
type uuid;
|
type uuid;
|
||||||
@@ -355,19 +355,25 @@ api API.Equipment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
endpoint deleteEquipmentAttachment {
|
endpoint deleteEquipmentAttachment {
|
||||||
label "DELETE /equipment/{id}/attachment";
|
label "DELETE /equipment/{id}/attachments/{attachmentId}";
|
||||||
description "Удалить файл-вложение из хранилища и из записи";
|
description "Удалить файл-вложение из хранилища и из записи";
|
||||||
attribute id {
|
attribute id {
|
||||||
type uuid;
|
type uuid;
|
||||||
}
|
}
|
||||||
|
attribute attachmentId {
|
||||||
|
type uuid;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint downloadEquipmentAttachment {
|
endpoint downloadEquipmentAttachment {
|
||||||
label "GET /equipment/{id}/attachment/download";
|
label "GET /equipment/{id}/attachments/{attachmentId}/download";
|
||||||
description "Скачать файл-вложение (Content-Disposition: attachment, поток из MinIO)";
|
description "Скачать файл-вложение (Content-Disposition: attachment, поток из MinIO)";
|
||||||
attribute id {
|
attribute id {
|
||||||
type uuid;
|
type uuid;
|
||||||
}
|
}
|
||||||
|
attribute attachmentId {
|
||||||
|
type uuid;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
735
openapi.json
735
openapi.json
@@ -20,86 +20,58 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"schemas": {
|
"schemas": {
|
||||||
"Equipment": {
|
"FileAttachment": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {
|
"objectKey": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "uuid"
|
"description": "Ключ объекта в бакете"
|
||||||
},
|
},
|
||||||
"inventoryNumber": {
|
"originalFileName": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Инвентарный номер"
|
"description": "Исходное имя файла"
|
||||||
},
|
},
|
||||||
"serialNumber": {
|
"contentType": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Заводской (серийный) номер"
|
"description": "MIME-тип"
|
||||||
},
|
},
|
||||||
"name": {
|
"sizeBytes": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Размер в байтах"
|
||||||
|
},
|
||||||
|
"downloadUrl": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Наименование единицы оборудования"
|
"description": "Ссылка на скачивание (публичная или presigned)"
|
||||||
},
|
|
||||||
"equipmentTypeCode": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Код вида оборудования"
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/EquipmentStatus"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Текущий статус"
|
|
||||||
},
|
|
||||||
"location": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Место эксплуатации / скважина / куст"
|
|
||||||
},
|
|
||||||
"commissionedAt": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "date-time",
|
|
||||||
"description": "Дата ввода в эксплуатацию"
|
|
||||||
},
|
|
||||||
"totalEngineHours": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "decimal",
|
|
||||||
"description": "Общая наработка, моточасов"
|
|
||||||
},
|
|
||||||
"engineHoursSinceLastRepair": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "decimal",
|
|
||||||
"description": "Наработка с последнего ремонта, моточасов"
|
|
||||||
},
|
|
||||||
"lastRepairAt": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "date-time",
|
|
||||||
"description": "Дата последнего ремонта"
|
|
||||||
},
|
|
||||||
"notes": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Примечания"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Оборудование — полный объект ответа"
|
"description": "Метаданные файла в объектном хранилище (MinIO/S3); бинарные данные в БД не хранятся"
|
||||||
|
},
|
||||||
|
"Equipment": {
|
||||||
|
"type": "string",
|
||||||
|
"x-dsl-enum": "Equipment",
|
||||||
|
"description": "Enum: Equipment (values defined in domain/*.api.dsl)"
|
||||||
},
|
},
|
||||||
"EquipmentCreate": {
|
"EquipmentCreate": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"inventoryNumber": {
|
"name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Инвентарный номер"
|
"description": "Название оборудования"
|
||||||
},
|
},
|
||||||
"serialNumber": {
|
"serialNumber": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Заводской (серийный) номер"
|
"description": "Заводской (серийный) номер"
|
||||||
},
|
},
|
||||||
"name": {
|
"dateOfInspection": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Наименование единицы оборудования"
|
"format": "date-time",
|
||||||
|
"description": "Дата поверки"
|
||||||
},
|
},
|
||||||
"equipmentTypeCode": {
|
"commissionedAt": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Код вида оборудования"
|
"format": "date-time",
|
||||||
|
"description": "Дата изготовления"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"allOf": [
|
"allOf": [
|
||||||
@@ -108,61 +80,35 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "Текущий статус"
|
"description": "Текущий статус"
|
||||||
},
|
|
||||||
"location": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Место эксплуатации / скважина / куст"
|
|
||||||
},
|
|
||||||
"commissionedAt": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "date-time",
|
|
||||||
"description": "Дата ввода в эксплуатацию"
|
|
||||||
},
|
|
||||||
"totalEngineHours": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "decimal",
|
|
||||||
"description": "Общая наработка, моточасов"
|
|
||||||
},
|
|
||||||
"engineHoursSinceLastRepair": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "decimal",
|
|
||||||
"description": "Наработка с последнего ремонта, моточасов"
|
|
||||||
},
|
|
||||||
"lastRepairAt": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "date-time",
|
|
||||||
"description": "Дата последнего ремонта"
|
|
||||||
},
|
|
||||||
"notes": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Примечания"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Оборудование — тело запроса на создание",
|
"description": "Тело запроса на создание единицы оборудования",
|
||||||
"required": [
|
"required": [
|
||||||
"inventoryNumber",
|
|
||||||
"name",
|
"name",
|
||||||
"equipmentTypeCode"
|
"serialNumber",
|
||||||
|
"status"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"EquipmentUpdate": {
|
"EquipmentUpdate": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"inventoryNumber": {
|
"name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Инвентарный номер"
|
"description": "Название оборудования"
|
||||||
},
|
},
|
||||||
"serialNumber": {
|
"serialNumber": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Заводской (серийный) номер"
|
"description": "Заводской (серийный) номер"
|
||||||
},
|
},
|
||||||
"name": {
|
"dateOfInspection": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Наименование единицы оборудования"
|
"format": "date-time",
|
||||||
|
"description": "Дата поверки"
|
||||||
},
|
},
|
||||||
"equipmentTypeCode": {
|
"commissionedAt": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Код вида оборудования"
|
"format": "date-time",
|
||||||
|
"description": "Дата изготовления"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"allOf": [
|
"allOf": [
|
||||||
@@ -171,52 +117,27 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "Текущий статус"
|
"description": "Текущий статус"
|
||||||
},
|
|
||||||
"location": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Место эксплуатации / скважина / куст"
|
|
||||||
},
|
|
||||||
"commissionedAt": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "date-time",
|
|
||||||
"description": "Дата ввода в эксплуатацию"
|
|
||||||
},
|
|
||||||
"totalEngineHours": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "decimal",
|
|
||||||
"description": "Общая наработка, моточасов"
|
|
||||||
},
|
|
||||||
"engineHoursSinceLastRepair": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "decimal",
|
|
||||||
"description": "Наработка с последнего ремонта, моточасов"
|
|
||||||
},
|
|
||||||
"lastRepairAt": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "date-time",
|
|
||||||
"description": "Дата последнего ремонта"
|
|
||||||
},
|
|
||||||
"notes": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Примечания"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Оборудование — тело запроса на обновление (частичное)"
|
"description": "Тело запроса на обновление единицы оборудования"
|
||||||
},
|
},
|
||||||
"EquipmentListRequest": {
|
"EquipmentListRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"filters": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/DTO.Filter"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"page": {
|
"page": {
|
||||||
"$ref": "#/components/schemas/DTO.PageRequest"
|
"$ref": "#/components/schemas/DTO.PageRequest"
|
||||||
|
},
|
||||||
|
"filterName": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"filterSerialNumber": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"filterStatus": {
|
||||||
|
"$ref": "#/components/schemas/EquipmentStatus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Оборудование — запрос постраничного списка с фильтрацией"
|
"description": "Запрос для постраничного получения списка оборудования с фильтрацией"
|
||||||
},
|
},
|
||||||
"EquipmentListResponse": {
|
"EquipmentListResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -227,263 +148,149 @@
|
|||||||
"$ref": "#/components/schemas/Equipment"
|
"$ref": "#/components/schemas/Equipment"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"page": {
|
"pageInfo": {
|
||||||
"$ref": "#/components/schemas/DTO.PageInfo"
|
"$ref": "#/components/schemas/DTO.PageInfo"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Оборудование — постраничный результат"
|
"description": "Ответ с постраничным списком оборудования и метаданными"
|
||||||
},
|
},
|
||||||
"RepairOrder": {
|
"ChangeEquipmentStatus": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {
|
"equipmentId": {
|
||||||
|
"$ref": "#/components/schemas/Equipment"
|
||||||
|
},
|
||||||
|
"newStatus": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/EquipmentStatus"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Новый статус"
|
||||||
|
},
|
||||||
|
"number": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Номер"
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "Дата изменения статуса"
|
||||||
|
},
|
||||||
|
"responsible": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Ответственный"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Полный response-объект для документа изменения статуса"
|
||||||
|
},
|
||||||
|
"ChangeEquipmentStatusCreate": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"equipmentId": {
|
||||||
|
"$ref": "#/components/schemas/Equipment"
|
||||||
|
},
|
||||||
|
"newStatus": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/EquipmentStatus"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Новый статус"
|
||||||
|
},
|
||||||
|
"number": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Номер"
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "Дата изменения статуса"
|
||||||
|
},
|
||||||
|
"responsible": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Ответственный"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Тело запроса на создание документа изменения статуса",
|
||||||
|
"required": [
|
||||||
|
"newStatus",
|
||||||
|
"date"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ChangeEquipmentStatusUpdate": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"equipmentId": {
|
||||||
|
"$ref": "#/components/schemas/Equipment"
|
||||||
|
},
|
||||||
|
"newStatus": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/EquipmentStatus"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Новый статус"
|
||||||
|
},
|
||||||
|
"number": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Номер"
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "Дата изменения статуса"
|
||||||
|
},
|
||||||
|
"responsible": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Ответственный"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Тело запроса на обновление документа изменения статуса"
|
||||||
|
},
|
||||||
|
"ChangeEquipmentStatusListRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"page": {
|
||||||
|
"$ref": "#/components/schemas/DTO.PageRequest"
|
||||||
|
},
|
||||||
|
"filterEquipmentId": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "uuid"
|
"format": "uuid"
|
||||||
},
|
},
|
||||||
"number": {
|
"filterNumber": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"filterDate": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Номер заявки"
|
"format": "date-time"
|
||||||
},
|
},
|
||||||
"equipmentId": {
|
"filterResponsible": {
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"format": "uuid",
|
|
||||||
"description": "Идентификатор оборудования"
|
|
||||||
},
|
|
||||||
"repairKind": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/RepairKind"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Вид ремонта"
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/RepairOrderStatus"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Статус заявки"
|
|
||||||
},
|
|
||||||
"plannedAt": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "date-time",
|
|
||||||
"description": "Плановая дата начала"
|
|
||||||
},
|
|
||||||
"startedAt": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "date-time",
|
|
||||||
"description": "Фактическая дата начала"
|
|
||||||
},
|
|
||||||
"completedAt": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "date-time",
|
|
||||||
"description": "Фактическая дата завершения"
|
|
||||||
},
|
|
||||||
"contractor": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Подрядная организация (если внешний ремонт)"
|
|
||||||
},
|
|
||||||
"engineHoursAtRepair": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "decimal",
|
|
||||||
"description": "Наработка на момент ремонта, моточасов"
|
|
||||||
},
|
|
||||||
"description": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Описание работ / дефекта"
|
|
||||||
},
|
|
||||||
"notes": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Примечания"
|
|
||||||
},
|
|
||||||
"confirmed": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Согласовано/Не согласовано"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Заявка на ремонт — полный объект ответа"
|
"description": "Запрос для постраничного получения списка документов изменения статуса с фильтрацией"
|
||||||
},
|
},
|
||||||
"RepairOrderCreate": {
|
"ChangeEquipmentStatusListResponse": {
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"number": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Номер заявки"
|
|
||||||
},
|
|
||||||
"equipmentId": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "uuid",
|
|
||||||
"description": "Идентификатор оборудования"
|
|
||||||
},
|
|
||||||
"repairKind": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/RepairKind"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Вид ремонта"
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/RepairOrderStatus"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Статус заявки"
|
|
||||||
},
|
|
||||||
"plannedAt": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "date-time",
|
|
||||||
"description": "Плановая дата начала"
|
|
||||||
},
|
|
||||||
"startedAt": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "date-time",
|
|
||||||
"description": "Фактическая дата начала"
|
|
||||||
},
|
|
||||||
"completedAt": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "date-time",
|
|
||||||
"description": "Фактическая дата завершения"
|
|
||||||
},
|
|
||||||
"contractor": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Подрядная организация (если внешний ремонт)"
|
|
||||||
},
|
|
||||||
"engineHoursAtRepair": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "decimal",
|
|
||||||
"description": "Наработка на момент ремонта, моточасов"
|
|
||||||
},
|
|
||||||
"description": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Описание работ / дефекта"
|
|
||||||
},
|
|
||||||
"notes": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Примечания"
|
|
||||||
},
|
|
||||||
"confirmed": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Согласовано/Не согласовано"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Заявка на ремонт — тело запроса на создание",
|
|
||||||
"required": [
|
|
||||||
"number",
|
|
||||||
"equipmentId",
|
|
||||||
"repairKind",
|
|
||||||
"plannedAt"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"RepairOrderUpdate": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"number": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Номер заявки"
|
|
||||||
},
|
|
||||||
"equipmentId": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "uuid",
|
|
||||||
"description": "Идентификатор оборудования"
|
|
||||||
},
|
|
||||||
"repairKind": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/RepairKind"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Вид ремонта"
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/RepairOrderStatus"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Статус заявки"
|
|
||||||
},
|
|
||||||
"plannedAt": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "date-time",
|
|
||||||
"description": "Плановая дата начала"
|
|
||||||
},
|
|
||||||
"startedAt": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "date-time",
|
|
||||||
"description": "Фактическая дата начала"
|
|
||||||
},
|
|
||||||
"completedAt": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "date-time",
|
|
||||||
"description": "Фактическая дата завершения"
|
|
||||||
},
|
|
||||||
"contractor": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Подрядная организация (если внешний ремонт)"
|
|
||||||
},
|
|
||||||
"engineHoursAtRepair": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "decimal",
|
|
||||||
"description": "Наработка на момент ремонта, моточасов"
|
|
||||||
},
|
|
||||||
"description": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Описание работ / дефекта"
|
|
||||||
},
|
|
||||||
"notes": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Примечания"
|
|
||||||
},
|
|
||||||
"confirmed": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Согласовано/Не согласовано"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Заявка на ремонт — тело запроса на обновление (частичное)"
|
|
||||||
},
|
|
||||||
"RepairOrderListRequest": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"filters": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/DTO.Filter"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"page": {
|
|
||||||
"$ref": "#/components/schemas/DTO.PageRequest"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Заявка на ремонт — запрос постраничного списка с фильтрацией"
|
|
||||||
},
|
|
||||||
"RepairOrderListResponse": {
|
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"content": {
|
"content": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/RepairOrder"
|
"$ref": "#/components/schemas/ChangeEquipmentStatus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"page": {
|
"pageInfo": {
|
||||||
"$ref": "#/components/schemas/DTO.PageInfo"
|
"$ref": "#/components/schemas/DTO.PageInfo"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Заявка на ремонт — постраничный результат"
|
"description": "Ответ с постраничным списком документов изменения статуса и метаданными"
|
||||||
},
|
},
|
||||||
"EquipmentStatus": {
|
"EquipmentStatus": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"x-dsl-enum": "EquipmentStatus",
|
"x-dsl-enum": "EquipmentStatus",
|
||||||
"description": "Enum: EquipmentStatus (values defined in domain/*.api.dsl)"
|
"description": "Enum: EquipmentStatus (values defined in domain/*.api.dsl)"
|
||||||
},
|
},
|
||||||
"DTO.Filter": {
|
|
||||||
"type": "string",
|
|
||||||
"x-dsl-enum": "DTO.Filter",
|
|
||||||
"description": "Enum: DTO.Filter (values defined in domain/*.api.dsl)"
|
|
||||||
},
|
|
||||||
"DTO.PageRequest": {
|
"DTO.PageRequest": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"x-dsl-enum": "DTO.PageRequest",
|
"x-dsl-enum": "DTO.PageRequest",
|
||||||
@@ -493,16 +300,6 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"x-dsl-enum": "DTO.PageInfo",
|
"x-dsl-enum": "DTO.PageInfo",
|
||||||
"description": "Enum: DTO.PageInfo (values defined in domain/*.api.dsl)"
|
"description": "Enum: DTO.PageInfo (values defined in domain/*.api.dsl)"
|
||||||
},
|
|
||||||
"RepairKind": {
|
|
||||||
"type": "string",
|
|
||||||
"x-dsl-enum": "RepairKind",
|
|
||||||
"description": "Enum: RepairKind (values defined in domain/*.api.dsl)"
|
|
||||||
},
|
|
||||||
"RepairOrderStatus": {
|
|
||||||
"type": "string",
|
|
||||||
"x-dsl-enum": "RepairOrderStatus",
|
|
||||||
"description": "Enum: RepairOrderStatus (values defined in domain/*.api.dsl)"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -516,7 +313,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"оборудованием"
|
"справочником оборудования"
|
||||||
],
|
],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -550,14 +347,14 @@
|
|||||||
},
|
},
|
||||||
"/equipment/{id}": {
|
"/equipment/{id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"summary": "Получить оборудование по идентификатору",
|
"summary": "Получить единицу оборудования по идентификатору",
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"bearerAuth": []
|
"bearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"оборудованием"
|
"справочником оборудования"
|
||||||
],
|
],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -590,14 +387,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"put": {
|
"put": {
|
||||||
"summary": "Обновить единицу оборудования",
|
"summary": "Обновить данные единицы оборудования",
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"bearerAuth": []
|
"bearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"оборудованием"
|
"справочником оборудования"
|
||||||
],
|
],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -647,7 +444,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"оборудованием"
|
"справочником оборудования"
|
||||||
],
|
],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -678,14 +475,14 @@
|
|||||||
},
|
},
|
||||||
"/equipment": {
|
"/equipment": {
|
||||||
"post": {
|
"post": {
|
||||||
"summary": "Создать единицу оборудования",
|
"summary": "Создать новую единицу оборудования",
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"bearerAuth": []
|
"bearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"оборудованием"
|
"справочником оборудования"
|
||||||
],
|
],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -717,34 +514,35 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/repair-orders/page": {
|
"/equipment/{id}/attachments": {
|
||||||
"post": {
|
"post": {
|
||||||
"summary": "Постраничный список заявок на ремонт с фильтрацией",
|
"summary": "Загрузить файл-вложение (multipart/form-data, поле file)",
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"bearerAuth": []
|
"bearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"заявками на ремонт"
|
"справочником оборудования"
|
||||||
],
|
],
|
||||||
"requestBody": {
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
"required": true,
|
"required": true,
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/RepairOrderListRequest"
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
],
|
||||||
},
|
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"201": {
|
||||||
"description": "Success",
|
"description": "Success",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/RepairOrderListResponse"
|
"type": "object"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -758,16 +556,155 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/repair-orders/{id}": {
|
"/equipment/{id}/attachments/{attachmentId}": {
|
||||||
"get": {
|
"delete": {
|
||||||
"summary": "Получить заявку на ремонт по идентификатору",
|
"summary": "Удалить файл-вложение из хранилища и из записи",
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"bearerAuth": []
|
"bearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"заявками на ремонт"
|
"справочником оборудования"
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "attachmentId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "No content"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/equipment/{id}/attachments/{attachmentId}/download": {
|
||||||
|
"get": {
|
||||||
|
"summary": "Скачать файл-вложение (Content-Disposition: attachment, поток из MinIO)",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"справочником оборудования"
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "attachmentId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/status-changes/page": {
|
||||||
|
"post": {
|
||||||
|
"summary": "Постраничный список документов изменения статуса с фильтрацией",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"документами изменения статуса оборудования"
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ChangeEquipmentStatusListRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ChangeEquipmentStatusListResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/status-changes/{id}": {
|
||||||
|
"get": {
|
||||||
|
"summary": "Получить документ изменения статуса по идентификатору",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"документами изменения статуса оборудования"
|
||||||
],
|
],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -786,7 +723,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/RepairOrder"
|
"$ref": "#/components/schemas/ChangeEquipmentStatus"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -800,14 +737,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"put": {
|
"put": {
|
||||||
"summary": "Обновить заявку на ремонт",
|
"summary": "Обновить документ изменения статуса",
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"bearerAuth": []
|
"bearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"заявками на ремонт"
|
"документами изменения статуса оборудования"
|
||||||
],
|
],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -825,7 +762,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/RepairOrderUpdate"
|
"$ref": "#/components/schemas/ChangeEquipmentStatusUpdate"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -850,14 +787,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"summary": "Удалить заявку на ремонт",
|
"summary": "Удалить документ изменения статуса",
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"bearerAuth": []
|
"bearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"заявками на ремонт"
|
"документами изменения статуса оборудования"
|
||||||
],
|
],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -886,23 +823,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/repair-orders": {
|
"/status-changes": {
|
||||||
"post": {
|
"post": {
|
||||||
"summary": "Создать заявку на ремонт",
|
"summary": "Создать документ изменения статуса оборудования",
|
||||||
"security": [
|
"security": [
|
||||||
{
|
{
|
||||||
"bearerAuth": []
|
"bearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"заявками на ремонт"
|
"документами изменения статуса оборудования"
|
||||||
],
|
],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/RepairOrderCreate"
|
"$ref": "#/components/schemas/ChangeEquipmentStatusCreate"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
package-lock.json
generated
Normal file
10
package-lock.json
generated
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "toir-generation-context",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "toir-generation-context"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,13 +20,20 @@ else
|
|||||||
if [ "$MIGRATE_EXIT" -eq 0 ]; then
|
if [ "$MIGRATE_EXIT" -eq 0 ]; then
|
||||||
cat "$MIGRATE_LOG"
|
cat "$MIGRATE_LOG"
|
||||||
rm -f "$MIGRATE_LOG"
|
rm -f "$MIGRATE_LOG"
|
||||||
elif grep -q P3005 "$MIGRATE_LOG"; then
|
elif grep -q "P3005" "$MIGRATE_LOG"; then
|
||||||
cat "$MIGRATE_LOG" >&2
|
cat "$MIGRATE_LOG" >&2
|
||||||
echo "" >&2
|
echo "" >&2
|
||||||
echo "prisma migrate deploy failed with P3005: database already has schema but no migration history (typical after prisma db push)." >&2
|
echo "prisma migrate deploy failed with P3005: database already has schema but no migration history (typical after prisma db push)." >&2
|
||||||
echo "Falling back to prisma db push so the schema stays in sync." >&2
|
echo "Falling back to prisma db push so the schema stays in sync." >&2
|
||||||
rm -f "$MIGRATE_LOG"
|
rm -f "$MIGRATE_LOG"
|
||||||
run_db_push
|
run_db_push
|
||||||
|
elif grep -q "P3009" "$MIGRATE_LOG" || grep -q "P3018" "$MIGRATE_LOG" || grep -q "42P01" "$MIGRATE_LOG"; then
|
||||||
|
cat "$MIGRATE_LOG" >&2
|
||||||
|
echo "" >&2
|
||||||
|
echo "prisma migrate deploy failed due to an unrecoverable migration state for this environment (failed migration / missing relation)." >&2
|
||||||
|
echo "Falling back to prisma db push to ensure the schema exists and matches schema.prisma." >&2
|
||||||
|
rm -f "$MIGRATE_LOG"
|
||||||
|
run_db_push
|
||||||
else
|
else
|
||||||
cat "$MIGRATE_LOG" >&2
|
cat "$MIGRATE_LOG" >&2
|
||||||
rm -f "$MIGRATE_LOG"
|
rm -f "$MIGRATE_LOG"
|
||||||
|
|||||||
1910
server/package-lock.json
generated
1910
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
-- Add user-managed lifecycle dates for equipment list and cards.
|
||||||
|
ALTER TABLE "Equipment" ADD COLUMN IF NOT EXISTS "installationDate" TIMESTAMP(3);
|
||||||
|
ALTER TABLE "Equipment" ADD COLUMN IF NOT EXISTS "writeOffDate" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Allow equipment cards to store multiple file attachments, same format as status changes.
|
||||||
|
ALTER TABLE "Equipment" ADD COLUMN IF NOT EXISTS "attachments" JSONB;
|
||||||
@@ -19,7 +19,11 @@ model Equipment {
|
|||||||
serialNumber String
|
serialNumber String
|
||||||
dateOfInspection DateTime?
|
dateOfInspection DateTime?
|
||||||
commissionedAt DateTime?
|
commissionedAt DateTime?
|
||||||
|
installationDate DateTime?
|
||||||
|
writeOffDate DateTime?
|
||||||
status EquipmentStatus @default(Active)
|
status EquipmentStatus @default(Active)
|
||||||
|
/// JSON: [{ id, objectKey, originalFileName, contentType, sizeBytes }] — файлы в MinIO/S3
|
||||||
|
attachments Json?
|
||||||
changeEquipmentStatuses ChangeEquipmentStatus[]
|
changeEquipmentStatuses ChangeEquipmentStatus[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ export class CreateEquipmentDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
commissionedAt?: string;
|
commissionedAt?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
installationDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
writeOffDate?: string;
|
||||||
|
|
||||||
@IsEnum(EquipmentStatus)
|
@IsEnum(EquipmentStatus)
|
||||||
status!: EquipmentStatus;
|
status!: EquipmentStatus;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ export class UpdateEquipmentDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
commissionedAt?: string;
|
commissionedAt?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
installationDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
writeOffDate?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(EquipmentStatus)
|
@IsEnum(EquipmentStatus)
|
||||||
status?: EquipmentStatus;
|
status?: EquipmentStatus;
|
||||||
|
|||||||
@@ -8,9 +8,14 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Res,
|
Res,
|
||||||
|
StreamableFile,
|
||||||
|
UploadedFile,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
|
import { memoryStorage } from 'multer';
|
||||||
import { Roles } from '../../auth/decorators/roles.decorator';
|
import { Roles } from '../../auth/decorators/roles.decorator';
|
||||||
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from '../../auth/guards/roles.guard';
|
import { RolesGuard } from '../../auth/guards/roles.guard';
|
||||||
@@ -47,6 +52,40 @@ export class EquipmentController {
|
|||||||
return this.equipmentService.update(id, dto);
|
return this.equipmentService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles('editor', 'admin')
|
||||||
|
@Post(':id/attachments')
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor('file', {
|
||||||
|
storage: memoryStorage(),
|
||||||
|
limits: { fileSize: 50 * 1024 * 1024 },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
uploadAttachment(@Param('id') id: string, @UploadedFile() file: Express.Multer.File) {
|
||||||
|
return this.equipmentService.uploadAttachment(id, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles('editor', 'admin')
|
||||||
|
@Delete(':id/attachments/:attachmentId')
|
||||||
|
removeAttachment(@Param('id') id: string, @Param('attachmentId') attachmentId: string) {
|
||||||
|
return this.equipmentService.removeAttachment(id, attachmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles('viewer', 'editor', 'admin')
|
||||||
|
@Get(':id/attachments/:attachmentId/download')
|
||||||
|
async downloadAttachment(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Param('attachmentId') attachmentId: string,
|
||||||
|
): Promise<StreamableFile> {
|
||||||
|
const { stream, contentType, fileName, contentLength } =
|
||||||
|
await this.equipmentService.getAttachmentDownloadStream(id, attachmentId);
|
||||||
|
const encoded = encodeURIComponent(fileName);
|
||||||
|
return new StreamableFile(stream, {
|
||||||
|
type: contentType,
|
||||||
|
disposition: `attachment; filename*=UTF-8''${encoded}`,
|
||||||
|
length: contentLength,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Roles('admin')
|
@Roles('admin')
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
|
|||||||
@@ -8,10 +8,14 @@ import { EquipmentStatus, Prisma } from '@prisma/client';
|
|||||||
import type { Equipment } from '@prisma/client';
|
import type { Equipment } from '@prisma/client';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import type { Express } from 'express';
|
import type { Express } from 'express';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { Readable } from 'node:stream';
|
||||||
import { decodeUtf8FilenameFromMultipart } from '../../common/multipart-filename';
|
import { decodeUtf8FilenameFromMultipart } from '../../common/multipart-filename';
|
||||||
import { setListHeaders } from '../../common/http';
|
import { setListHeaders } from '../../common/http';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { StorageService } from '../../storage/storage.service';
|
import { StorageService } from '../../storage/storage.service';
|
||||||
|
import type { StoredAttachmentMeta } from './attachment.types';
|
||||||
|
import { isStoredAttachmentMeta } from './attachment.types';
|
||||||
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';
|
||||||
|
|
||||||
@@ -82,6 +86,8 @@ export class EquipmentService {
|
|||||||
serialNumber: dto.serialNumber,
|
serialNumber: dto.serialNumber,
|
||||||
dateOfInspection: dto.dateOfInspection ? new Date(dto.dateOfInspection) : undefined,
|
dateOfInspection: dto.dateOfInspection ? new Date(dto.dateOfInspection) : undefined,
|
||||||
commissionedAt: dto.commissionedAt ? new Date(dto.commissionedAt) : undefined,
|
commissionedAt: dto.commissionedAt ? new Date(dto.commissionedAt) : undefined,
|
||||||
|
installationDate: dto.installationDate ? new Date(dto.installationDate) : undefined,
|
||||||
|
writeOffDate: dto.writeOffDate ? new Date(dto.writeOffDate) : undefined,
|
||||||
status: dto.status,
|
status: dto.status,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -99,6 +105,8 @@ export class EquipmentService {
|
|||||||
...payload,
|
...payload,
|
||||||
dateOfInspection: dto.dateOfInspection ? new Date(dto.dateOfInspection) : undefined,
|
dateOfInspection: dto.dateOfInspection ? new Date(dto.dateOfInspection) : undefined,
|
||||||
commissionedAt: dto.commissionedAt ? new Date(dto.commissionedAt) : undefined,
|
commissionedAt: dto.commissionedAt ? new Date(dto.commissionedAt) : undefined,
|
||||||
|
installationDate: dto.installationDate ? new Date(dto.installationDate) : undefined,
|
||||||
|
writeOffDate: dto.writeOffDate ? new Date(dto.writeOffDate) : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -114,6 +122,113 @@ export class EquipmentService {
|
|||||||
return this.toRecord(deleted);
|
return this.toRecord(deleted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async uploadAttachment(id: string, file: Express.Multer.File | undefined) {
|
||||||
|
if (!file?.buffer?.length) {
|
||||||
|
throw new BadRequestException('File is required (multipart field "file").');
|
||||||
|
}
|
||||||
|
if (!this.storage.isConfigured()) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
'Object storage is not configured (set S3_* environment variables).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = await this.prisma.equipment.findUnique({ where: { id } });
|
||||||
|
if (!item) {
|
||||||
|
throw new NotFoundException(`Equipment ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodedOriginalName = decodeUtf8FilenameFromMultipart(file.originalname);
|
||||||
|
const objectKey = this.storage.buildEquipmentObjectKey(id, decodedOriginalName);
|
||||||
|
const meta: StoredAttachmentMeta = {
|
||||||
|
id: randomUUID(),
|
||||||
|
objectKey,
|
||||||
|
originalFileName: decodedOriginalName,
|
||||||
|
contentType: file.mimetype || 'application/octet-stream',
|
||||||
|
sizeBytes: file.size,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.storage.putObject(objectKey, file.buffer, meta.contentType);
|
||||||
|
|
||||||
|
const current = this.parseAttachments(item.attachments);
|
||||||
|
const next = [...current, meta];
|
||||||
|
const updated = await this.prisma.equipment.update({
|
||||||
|
where: { id },
|
||||||
|
data: { attachments: next as unknown as Prisma.InputJsonValue },
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.toRecord(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeAttachment(id: string, attachmentId: string) {
|
||||||
|
if (!this.storage.isConfigured()) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
'Object storage is not configured (set S3_* environment variables).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = await this.prisma.equipment.findUnique({ where: { id } });
|
||||||
|
if (!item) {
|
||||||
|
throw new NotFoundException(`Equipment ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = this.parseAttachments(item.attachments);
|
||||||
|
const found = current.find((a) => a.id === attachmentId);
|
||||||
|
if (!found) {
|
||||||
|
throw new NotFoundException(`Attachment ${attachmentId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.storage.deleteObject(found.objectKey);
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = current.filter((a) => a.id !== attachmentId);
|
||||||
|
const updated = await this.prisma.equipment.update({
|
||||||
|
where: { id },
|
||||||
|
data: { attachments: next.length ? (next as unknown as Prisma.InputJsonValue) : Prisma.JsonNull },
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.toRecord(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAttachmentDownloadStream(
|
||||||
|
id: string,
|
||||||
|
attachmentId: string,
|
||||||
|
): Promise<{
|
||||||
|
stream: Readable;
|
||||||
|
contentType: string;
|
||||||
|
fileName: string;
|
||||||
|
contentLength?: number;
|
||||||
|
}> {
|
||||||
|
if (!this.storage.isConfigured()) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
'Object storage is not configured (set S3_* environment variables).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = await this.prisma.equipment.findUnique({ where: { id } });
|
||||||
|
if (!item) {
|
||||||
|
throw new NotFoundException(`Equipment ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = this.parseAttachments(item.attachments);
|
||||||
|
const found = current.find((a) => a.id === attachmentId);
|
||||||
|
if (!found) {
|
||||||
|
throw new NotFoundException(`Attachment ${attachmentId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = decodeUtf8FilenameFromMultipart(found.originalFileName) || 'file';
|
||||||
|
const { stream, contentType, contentLength } = await this.storage.getObjectStream(found.objectKey);
|
||||||
|
const len = typeof contentLength === 'bigint' ? Number(contentLength) : contentLength;
|
||||||
|
|
||||||
|
return {
|
||||||
|
stream,
|
||||||
|
contentType: found.contentType || contentType,
|
||||||
|
fileName,
|
||||||
|
contentLength: len,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async toRecord(item: Equipment) {
|
private async toRecord(item: Equipment) {
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@@ -121,7 +236,31 @@ export class EquipmentService {
|
|||||||
serialNumber: item.serialNumber,
|
serialNumber: item.serialNumber,
|
||||||
dateOfInspection: item.dateOfInspection?.toISOString() ?? null,
|
dateOfInspection: item.dateOfInspection?.toISOString() ?? null,
|
||||||
commissionedAt: item.commissionedAt?.toISOString() ?? null,
|
commissionedAt: item.commissionedAt?.toISOString() ?? null,
|
||||||
|
installationDate: item.installationDate?.toISOString() ?? null,
|
||||||
|
writeOffDate: item.writeOffDate?.toISOString() ?? null,
|
||||||
status: item.status,
|
status: item.status,
|
||||||
|
attachments: this.serializeAttachments(item.attachments),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private parseAttachments(raw: Prisma.JsonValue | null | undefined): StoredAttachmentMeta[] {
|
||||||
|
if (!raw) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (!Array.isArray(raw)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return raw.filter(isStoredAttachmentMeta);
|
||||||
|
}
|
||||||
|
|
||||||
|
private serializeAttachments(raw: Prisma.JsonValue | null | undefined) {
|
||||||
|
const items = this.parseAttachments(raw);
|
||||||
|
return items.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
originalFileName: decodeUtf8FilenameFromMultipart(a.originalFileName),
|
||||||
|
contentType: a.contentType,
|
||||||
|
sizeBytes: a.sizeBytes,
|
||||||
|
downloadUrl: null as string | null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ function interpretS3Failure(operation: string, err: unknown): never {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = formatS3ErrorMessage(err);
|
||||||
const status = (err as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode;
|
const status = (err as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode;
|
||||||
const code = (err as { Code?: string; name?: string }).Code ?? (err as { name?: string }).name;
|
const code = (err as { Code?: string; name?: string }).Code ?? (err as { name?: string }).name;
|
||||||
|
|
||||||
@@ -176,3 +176,28 @@ function interpretS3Failure(operation: string, err: unknown): never {
|
|||||||
|
|
||||||
throw new ServiceUnavailableException(`S3 ${operation}: ${message}`);
|
throw new ServiceUnavailableException(`S3 ${operation}: ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatS3ErrorMessage(err: unknown): string {
|
||||||
|
const error = err as {
|
||||||
|
Code?: string;
|
||||||
|
code?: string;
|
||||||
|
name?: string;
|
||||||
|
message?: string;
|
||||||
|
cause?: unknown;
|
||||||
|
$metadata?: { httpStatusCode?: number; requestId?: string };
|
||||||
|
};
|
||||||
|
const parts = [
|
||||||
|
error.name,
|
||||||
|
error.Code ?? error.code,
|
||||||
|
error.message,
|
||||||
|
error.$metadata?.httpStatusCode ? `HTTP ${error.$metadata.httpStatusCode}` : undefined,
|
||||||
|
error.$metadata?.requestId ? `requestId ${error.$metadata.requestId}` : undefined,
|
||||||
|
error.cause instanceof Error
|
||||||
|
? `${error.cause.name}: ${error.cause.message}`
|
||||||
|
: error.cause
|
||||||
|
? String(error.cause)
|
||||||
|
: undefined,
|
||||||
|
].filter((part): part is string => Boolean(part));
|
||||||
|
|
||||||
|
return parts.length ? parts.join(' | ') : String(err);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user