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

@@ -0,0 +1,32 @@
import { Link } from '@mui/material';
import { useNotify } from 'react-admin';
import { downloadStatusChangeAttachmentFile } from './attachmentDownload';
type Props = {
statusChangeId: string;
attachmentId: string;
fileName: string;
};
export function StatusChangeAttachmentLink({ statusChangeId, attachmentId, fileName }: Props) {
const notify = useNotify();
const label = fileName.trim() || 'Скачать';
return (
<Link
component="button"
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
void downloadStatusChangeAttachmentFile(statusChangeId, attachmentId, label).catch(() =>
notify('Не удалось скачать файл', { type: 'warning' }),
);
}}
sx={{ cursor: 'pointer', textAlign: 'left', verticalAlign: 'inherit' }}
>
{label}
</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

@@ -0,0 +1,31 @@
import { ensureFreshToken, getAccessToken } from '../../auth/keycloak';
import { resolveApiBaseUrl } from '../../lib/apiBase';
/** Скачивание через API (JWT + Content-Disposition). */
export async function downloadStatusChangeAttachmentFile(
statusChangeId: string,
attachmentId: string,
suggestedFileName: string,
): Promise<void> {
await ensureFreshToken();
const token = getAccessToken();
const url = `${resolveApiBaseUrl()}/status-changes/${statusChangeId}/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);
}