Add file attachment functionality for equipment management

- Introduced DTO for file attachments with metadata (objectKey, originalFileName, contentType, sizeBytes, downloadUrl).
- Updated Equipment DTO to include an optional attachment field.
- Implemented endpoints for uploading and deleting equipment attachments.
- Enhanced Equipment service to handle file storage and retrieval using S3.
- Updated EquipmentEdit, EquipmentList, and EquipmentShow components to support file attachment input and display.
- Configured S3 settings in docker-compose and environment files.
This commit is contained in:
Первов Артем
2026-04-21 00:46:17 +03:00
parent 62446f2597
commit b60c4ee0ed
19 changed files with 2274 additions and 38 deletions

View File

@@ -0,0 +1,109 @@
import { Button, Stack, Typography } from '@mui/material';
import { useNotify, useRecordContext, useRefresh } from 'react-admin';
import { useCallback, useState, type ChangeEvent } from 'react';
import { ensureFreshToken, getAccessToken } from '../../auth/keycloak';
import { resolveApiBaseUrl } from '../../lib/apiBase';
export type FileAttachmentValue = {
objectKey?: string;
originalFileName?: string | null;
contentType?: string | null;
sizeBytes?: number | null;
downloadUrl?: string | null;
} | null;
export function EquipmentAttachmentInput() {
const record = useRecordContext();
const notify = useNotify();
const refresh = useRefresh();
const [busy, setBusy] = useState(false);
const upload = useCallback(
async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file || !record?.id) {
return;
}
setBusy(true);
try {
await ensureFreshToken();
const token = getAccessToken();
const form = new FormData();
form.append('file', file);
const url = `${resolveApiBaseUrl()}/equipment/${record.id}/attachment`;
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('Файл сохранён', { type: 'success' });
refresh();
} catch {
notify('Не удалось загрузить файл', { type: 'warning' });
} finally {
setBusy(false);
}
},
[notify, refresh, record?.id],
);
const remove = useCallback(async () => {
if (!record?.id) {
return;
}
setBusy(true);
try {
await ensureFreshToken();
const token = getAccessToken();
const url = `${resolveApiBaseUrl()}/equipment/${record.id}/attachment`;
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);
}
}, [notify, refresh, record?.id]);
const att = (record as { attachment?: FileAttachmentValue })?.attachment;
return (
<Stack spacing={1} sx={{ maxWidth: 480 }}>
<Typography component="label" variant="body2">
Вложение (файл в MinIO)
</Typography>
{att?.downloadUrl ? (
<Typography variant="body2">
<a href={att.downloadUrl} target="_blank" rel="noreferrer">
{att.originalFileName || 'Скачать файл'}
</a>
{att.sizeBytes != null ? ` (${Math.round(att.sizeBytes / 1024)} КБ)` : null}
</Typography>
) : (
<Typography variant="body2" color="text.secondary">
Файл не прикреплён
</Typography>
)}
<Stack direction="row" spacing={1} alignItems="center">
<Button variant="outlined" component="label" disabled={busy || !record?.id}>
Выбрать файл
<input type="file" hidden onChange={upload} />
</Button>
<Button variant="text" color="error" disabled={busy || !att?.objectKey} onClick={remove}>
Удалить
</Button>
</Stack>
</Stack>
);
}

View File

@@ -1,4 +1,5 @@
import { DateInput, Edit, SelectInput, SimpleForm, TextInput as RaTextInput } from 'react-admin';
import { EquipmentAttachmentInput } from './EquipmentAttachmentInput';
import { equipmentStatusChoices } from './shared';
const equipmentLabels = {
@@ -18,6 +19,7 @@ export function EquipmentEdit() {
<RaTextInput source="name" label={equipmentLabels.name} />
<RaTextInput source="serialNumber" label={equipmentLabels.serialNumber} />
<SelectInput source="status" label={equipmentLabels.status} choices={equipmentStatusChoices} />
<EquipmentAttachmentInput />
</SimpleForm>
</Edit>
);

View File

@@ -3,6 +3,7 @@ import {
Datagrid,
DateField,
FilterButton,
FunctionField,
List,
SelectArrayInput,
SelectField,
@@ -34,6 +35,10 @@ export function EquipmentList() {
<DateField source="dateOfInspection" />
<DateField source="commissionedAt" />
<SelectField source="status" choices={equipmentStatusChoices} />
<FunctionField
label="Файл"
render={(record: { attachment?: { objectKey?: string } | null }) => (record?.attachment?.objectKey ? '✓' : '—')}
/>
</Datagrid>
</List>
);

View File

@@ -1,4 +1,4 @@
import { DateField, SelectField, Show, SimpleShowLayout, TextField } from 'react-admin';
import { DateField, FunctionField, SelectField, Show, SimpleShowLayout, TextField } from 'react-admin';
import { equipmentStatusChoices } from './shared';
export function EquipmentShow() {
@@ -11,6 +11,18 @@ export function EquipmentShow() {
<DateField source="dateOfInspection" />
<DateField source="commissionedAt" />
<SelectField source="status" choices={equipmentStatusChoices} />
<FunctionField
label="Вложение"
render={(record: { attachment?: { downloadUrl?: string | null; originalFileName?: string | null } | null }) =>
record?.attachment?.downloadUrl ? (
<a href={record.attachment.downloadUrl} target="_blank" rel="noreferrer">
{record.attachment.originalFileName || 'Скачать'}
</a>
) : (
'—'
)
}
/>
</SimpleShowLayout>
</Show>
);