Merge pull request 'Split archive journal to 2 sub-modules by status. Add possibility to upload file for equipment. Change date fields for equipment.' (#1) from dev into master

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-05-13 06:00:02 +00:00
33 changed files with 2753 additions and 1699 deletions

View File

@@ -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
} }
] ]
} }

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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',

View File

@@ -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,
}, },
}; };

View 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);
}

View File

@@ -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>
); );
} }

View File

@@ -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'] };

View File

@@ -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>
))} ))}

View File

@@ -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: {

View 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>
);
}

View 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>
);
}

View File

@@ -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>
); );

View File

@@ -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>
); );

View File

@@ -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} />;
}

View File

@@ -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>
); );

View 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);
}

View File

@@ -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;
}

View File

@@ -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',
}, },

View File

@@ -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,
},
},
},
}) })

View File

@@ -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;
}
} }
} }
@@ -422,4 +428,4 @@ api API.EquipmentStatusChange {
type uuid; type uuid;
} }
} }
} }

View File

@@ -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": [
"required": true, {
"content": { "name": "id",
"application/json": { "in": "path",
"schema": { "required": true,
"$ref": "#/components/schemas/RepairOrderListRequest" "schema": {
} "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
View File

@@ -0,0 +1,10 @@
{
"name": "toir-generation-context",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "toir-generation-context"
}
}
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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);

View File

@@ -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;

View File

@@ -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[]
} }

View File

@@ -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;
} }

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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,
}));
}
} }

View File

@@ -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);
}