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:
11
client/src/lib/apiBase.ts
Normal file
11
client/src/lib/apiBase.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { env } from '../config/env';
|
||||
|
||||
/** Resolves VITE_API_URL like `/api` to an absolute origin URL. */
|
||||
export function resolveApiBaseUrl(): string {
|
||||
const base = env.apiUrl;
|
||||
if (base.startsWith('http://') || base.startsWith('https://')) {
|
||||
return base.replace(/\/$/, '');
|
||||
}
|
||||
const path = base.startsWith('/') ? base : `/${base}`;
|
||||
return `${window.location.origin.replace(/\/$/, '')}${path}`.replace(/\/$/, '');
|
||||
}
|
||||
109
client/src/resources/equipment/EquipmentAttachmentInput.tsx
Normal file
109
client/src/resources/equipment/EquipmentAttachmentInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user