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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user