Add support for file attachments in equipment status changes

- Introduced functionality for uploading and managing attachments in the ChangeEquipmentStatus module.
- Added new endpoints for uploading and deleting attachments, as well as for downloading them.
- Updated the ChangeEquipmentStatusService to handle attachment storage and retrieval using the new storage service methods.
- Enhanced the ChangeEquipmentStatusEdit and ChangeEquipmentStatusShow components to support attachment input and display.
- Removed deprecated attachment handling from Equipment module to streamline functionality.
- Updated Prisma schema to reflect changes in attachment management.
This commit is contained in:
Первов Артем
2026-04-21 12:19:49 +03:00
parent d572647772
commit b1aefae2fa
25 changed files with 669 additions and 430 deletions

View File

@@ -8,6 +8,7 @@ import {
TextInput as RaTextInput,
} from 'react-admin';
import { equipmentStatusChoices, equipmentOptionText } from '../equipment/shared';
import { StatusChangeAttachmentsInput } from './StatusChangeAttachmentsInput';
export function ChangeEquipmentStatusEdit() {
return (
@@ -24,6 +25,7 @@ export function ChangeEquipmentStatusEdit() {
<DateInput source="date" label="Дата" />
<RaTextInput source="number" label="Номер" />
<RaTextInput source="responsible" label="Ответственный" />
<StatusChangeAttachmentsInput />
</SimpleForm>
</Edit>
);

View File

@@ -3,6 +3,7 @@ import {
Datagrid,
DateField,
FilterButton,
FunctionField,
List,
ReferenceField,
SelectArrayInput,
@@ -38,6 +39,13 @@ export function ChangeEquipmentStatusList() {
<TextField source="number" />
<DateField source="date" />
<TextField source="responsible" />
<FunctionField
label="Файлы"
render={(record: { attachments?: { id: string }[] | null }) => {
const count = Array.isArray(record.attachments) ? record.attachments.length : 0;
return count ? String(count) : '—';
}}
/>
</Datagrid>
</List>
);

View File

@@ -1,5 +1,6 @@
import { DateField, ReferenceField, SelectField, Show, SimpleShowLayout, TextField } from 'react-admin';
import { DateField, FunctionField, ReferenceField, SelectField, Show, SimpleShowLayout, TextField } from 'react-admin';
import { equipmentStatusChoices } from '../equipment/shared';
import { StatusChangeAttachmentLink } from './StatusChangeAttachmentLink';
export function ChangeEquipmentStatusShow() {
return (
@@ -13,6 +14,31 @@ export function ChangeEquipmentStatusShow() {
<TextField source="number" />
<DateField source="date" />
<TextField source="responsible" />
<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}>
<StatusChangeAttachmentLink
statusChangeId={record.id}
attachmentId={att.id}
fileName={att.originalFileName ?? 'файл'}
/>
</div>
))}
</div>
);
}}
/>
</SimpleShowLayout>
</Show>
);

View File

@@ -1,14 +1,14 @@
import { Link } from '@mui/material';
import { useNotify } from 'react-admin';
import { downloadEquipmentAttachmentFile } from './attachmentDownload';
import { downloadStatusChangeAttachmentFile } from './attachmentDownload';
type Props = {
equipmentId: string;
/** Текст ссылки и имя при сохранении в браузере */
statusChangeId: string;
attachmentId: string;
fileName: string;
};
export function EquipmentAttachmentLink({ equipmentId, fileName }: Props) {
export function StatusChangeAttachmentLink({ statusChangeId, attachmentId, fileName }: Props) {
const notify = useNotify();
const label = fileName.trim() || 'Скачать';
@@ -19,7 +19,7 @@ export function EquipmentAttachmentLink({ equipmentId, fileName }: Props) {
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
void downloadEquipmentAttachmentFile(equipmentId, label).catch(() =>
void downloadStatusChangeAttachmentFile(statusChangeId, attachmentId, label).catch(() =>
notify('Не удалось скачать файл', { type: 'warning' }),
);
}}
@@ -29,3 +29,4 @@ export function EquipmentAttachmentLink({ equipmentId, fileName }: Props) {
</Link>
);
}

View File

@@ -0,0 +1,179 @@
import { Button, Chip, 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 { StatusChangeAttachmentLink } from './StatusChangeAttachmentLink';
export type StatusChangeAttachmentValue = {
id: string;
originalFileName?: string | null;
contentType?: 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 StatusChangeAttachmentsInput() {
const record = useRecordContext();
const notify = useNotify();
const refresh = useRefresh();
const [busy, setBusy] = useState(false);
const statusChangeId = record?.id ? String(record.id) : null;
const attachments = useMemo(() => {
const raw = (record as { attachments?: StatusChangeAttachmentValue | 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 || !statusChangeId) {
return;
}
setBusy(true);
try {
await ensureFreshToken();
const token = getAccessToken();
const url = `${resolveApiBaseUrl()}/status-changes/${statusChangeId}/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);
}
},
[notify, refresh, statusChangeId],
);
const remove = useCallback(
async (attachmentId: string) => {
if (!statusChangeId) {
return;
}
setBusy(true);
try {
await ensureFreshToken();
const token = getAccessToken();
const url = `${resolveApiBaseUrl()}/status-changes/${statusChangeId}/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);
}
},
[notify, refresh, statusChangeId],
);
return (
<Stack spacing={1.25} sx={{ maxWidth: 680 }}>
<Typography component="div" variant="body2" sx={{ fontWeight: 600 }}>
Вложения (файлы)
</Typography>
{!statusChangeId ? (
<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 }}>
<StatusChangeAttachmentLink
statusChangeId={statusChangeId}
attachmentId={att.id}
fileName={att.originalFileName ?? 'файл'}
/>
</Typography>
<Stack direction="row" spacing={1} alignItems="center">
{att.contentType ? (
<Chip size="small" label={att.contentType} variant="outlined" />
) : null}
{formatBytes(att.sizeBytes) ? (
<Typography variant="caption" color="text.secondary">
{formatBytes(att.sizeBytes)}
</Typography>
) : null}
</Stack>
</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 || !statusChangeId}>
Добавить файлы
<input type="file" hidden multiple onChange={upload} />
</Button>
</Stack>
</Stack>
);
}

View File

@@ -1,11 +1,15 @@
import { ensureFreshToken, getAccessToken } from '../../auth/keycloak';
import { resolveApiBaseUrl } from '../../lib/apiBase';
/** Скачивание через API (JWT + Content-Disposition), без открытия URL MinIO в браузере. */
export async function downloadEquipmentAttachmentFile(equipmentId: string, suggestedFileName: string): Promise<void> {
/** Скачивание через API (JWT + Content-Disposition). */
export async function downloadStatusChangeAttachmentFile(
statusChangeId: string,
attachmentId: string,
suggestedFileName: string,
): Promise<void> {
await ensureFreshToken();
const token = getAccessToken();
const url = `${resolveApiBaseUrl()}/equipment/${equipmentId}/attachment/download`;
const url = `${resolveApiBaseUrl()}/status-changes/${statusChangeId}/attachments/${attachmentId}/download`;
const response = await fetch(url, {
method: 'GET',
headers: token ? { Authorization: `Bearer ${token}`, Accept: '*/*' } : { Accept: '*/*' },
@@ -24,3 +28,4 @@ export async function downloadEquipmentAttachmentFile(equipmentId: string, sugge
a.remove();
URL.revokeObjectURL(objectUrl);
}

View File

@@ -1,111 +0,0 @@
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';
import { EquipmentAttachmentLink } from './EquipmentAttachmentLink';
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?.objectKey ? (
<Typography variant="body2" component="div">
<EquipmentAttachmentLink
equipmentId={String(record.id)}
fileName={att.originalFileName || 'Скачать файл'}
/>
{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,5 +1,4 @@
import { DateInput, Edit, SelectInput, SimpleForm, TextInput as RaTextInput } from 'react-admin';
import { EquipmentAttachmentInput } from './EquipmentAttachmentInput';
import { equipmentStatusChoices } from './shared';
const equipmentLabels = {
@@ -19,7 +18,6 @@ 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,7 +3,6 @@ import {
Datagrid,
DateField,
FilterButton,
FunctionField,
List,
SelectArrayInput,
SelectField,
@@ -11,7 +10,6 @@ import {
TextInput,
TopToolbar,
} from 'react-admin';
import { EquipmentAttachmentLink } from './EquipmentAttachmentLink';
import { equipmentStatusChoices } from './shared';
const equipmentFilters = [
@@ -36,22 +34,6 @@ export function EquipmentList() {
<DateField source="dateOfInspection" />
<DateField source="commissionedAt" />
<SelectField source="status" choices={equipmentStatusChoices} />
<FunctionField
label="Файл"
render={(record: {
id: string;
attachment?: { objectKey?: string; originalFileName?: string | null } | null;
}) =>
record?.attachment?.objectKey ? (
<EquipmentAttachmentLink
equipmentId={record.id}
fileName={record.attachment.originalFileName ?? 'файл'}
/>
) : (
'—'
)
}
/>
</Datagrid>
</List>
);

View File

@@ -1,5 +1,4 @@
import { DateField, FunctionField, SelectField, Show, SimpleShowLayout, TextField } from 'react-admin';
import { EquipmentAttachmentLink } from './EquipmentAttachmentLink';
import { DateField, SelectField, Show, SimpleShowLayout, TextField } from 'react-admin';
import { equipmentStatusChoices } from './shared';
export function EquipmentShow() {
@@ -12,22 +11,6 @@ export function EquipmentShow() {
<DateField source="dateOfInspection" />
<DateField source="commissionedAt" />
<SelectField source="status" choices={equipmentStatusChoices} />
<FunctionField
label="Вложение"
render={(record: {
id: string;
attachment?: { objectKey?: string | null; originalFileName?: string | null } | null;
}) =>
record?.attachment?.objectKey ? (
<EquipmentAttachmentLink
equipmentId={record.id}
fileName={record.attachment.originalFileName ?? 'файл'}
/>
) : (
'—'
)
}
/>
</SimpleShowLayout>
</Show>
);