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

@@ -12,6 +12,8 @@
"@emotion/styled": "^11.14.1",
"@mui/material": "^7.3.9",
"keycloak-js": "^26.2.3",
"ra-i18n-polyglot": "^5.14.5",
"ra-language-russian": "^5.4.5",
"react": "^19.2.4",
"react-admin": "^5.14.5",
"react-dom": "^19.2.4",
@@ -1081,9 +1083,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1101,9 +1100,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1121,9 +1117,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1141,9 +1134,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1161,9 +1151,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1181,9 +1168,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3099,9 +3083,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3123,9 +3104,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3147,9 +3125,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3171,9 +3146,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3655,6 +3627,15 @@
"ra-core": "^5.14.5"
}
},
"node_modules/ra-language-russian": {
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/ra-language-russian/-/ra-language-russian-5.4.5.tgz",
"integrity": "sha512-hCr1KKpcfuIjKbxCbMspBwhEFcQDgjSISvOmCcFcQlLJ+LLni5BOwvDqToh87oOZ9A29wn6NGDqmpwl87PbzQA==",
"license": "MIT",
"dependencies": {
"ra-core": "^5.13.5"
}
},
"node_modules/ra-ui-materialui": {
"version": "5.14.5",
"resolved": "https://registry.npmjs.org/ra-ui-materialui/-/ra-ui-materialui-5.14.5.tgz",
@@ -4335,8 +4316,10 @@
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"extraneous": true,
"dev": true,
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},

View File

@@ -14,6 +14,8 @@
"@emotion/styled": "^11.14.1",
"@mui/material": "^7.3.9",
"keycloak-js": "^26.2.3",
"ra-i18n-polyglot": "^5.14.5",
"ra-language-russian": "^5.4.5",
"react": "^19.2.4",
"react-admin": "^5.14.5",
"react-dom": "^19.2.4",

View File

@@ -1,10 +1,12 @@
import { createTheme } from '@mui/material/styles';
import { useMemo } from 'react';
import { Admin, Resource } from 'react-admin';
import polyglotI18nProvider from 'ra-i18n-polyglot';
import { authProvider } from './auth/authProvider';
import { dataProvider } from './dataProvider';
import { useEmbeddedParentTheme } from './embed/useEmbeddedParentTheme';
import { messagesRu } from './i18n/ru';
import { EmbeddedActiveEquipmentPage } from './pages/EmbeddedActiveEquipmentPage';
import { buildToirMuiTheme } from './theme/toirMuiTheme';
import { EquipmentCreate } from './resources/equipment/EquipmentCreate';
import { EquipmentEdit } from './resources/equipment/EquipmentEdit';
import { EquipmentList } from './resources/equipment/EquipmentList';
@@ -17,17 +19,27 @@ import { EquipmentStatusChangeShow } from './resources/equipment-status-change/E
function ToirAdmin() {
const paletteMode = useEmbeddedParentTheme();
const theme = useMemo(
() =>
createTheme({
palette: { mode: paletteMode },
}),
() => buildToirMuiTheme(paletteMode),
[paletteMode],
);
const i18nProvider = useMemo(
() =>
polyglotI18nProvider(() => messagesRu, 'ru', {
allowMissing: true,
}),
[],
);
return (
<Admin dataProvider={dataProvider} authProvider={authProvider} theme={theme}>
<Admin
dataProvider={dataProvider}
authProvider={authProvider}
theme={theme}
i18nProvider={i18nProvider}
>
<Resource
name="equipment"
options={{ label: 'Оборудование' }}
list={EquipmentList}
create={EquipmentCreate}
edit={EquipmentEdit}
@@ -35,6 +47,7 @@ function ToirAdmin() {
/>
<Resource
name="status-changes"
options={{ label: 'Акты' }}
list={EquipmentStatusChangeList}
create={EquipmentStatusChangeCreate}
edit={EquipmentStatusChangeEdit}

44
client/src/i18n/ru.ts Normal file
View File

@@ -0,0 +1,44 @@
import russianMessages from 'ra-language-russian';
const customRu = {
resources: {
equipment: {
name: 'Оборудование |||| Оборудование',
fields: {
id: 'ID',
name: 'Название',
serialNumber: 'Серийный номер',
dateOfInspection: 'Дата поверки',
commissionedAt: 'Дата изготовления',
status: 'Статус',
},
},
'status-changes': {
name: 'Акт |||| Акты',
fields: {
id: 'ID',
equipmentId: 'Оборудование',
newStatus: 'Новый статус',
date: 'Дата',
number: 'Номер',
responsible: 'Ответственный',
attachments: 'Вложения',
},
},
},
toir: {
actions: {
addFiles: 'Добавить файлы…',
},
},
};
export const messagesRu = {
...russianMessages,
...customRu,
resources: {
...(russianMessages as any).resources,
...customRu.resources,
},
};

View File

@@ -2,7 +2,6 @@ import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import CssBaseline from '@mui/material/CssBaseline';
import Link from '@mui/material/Link';
import Paper from '@mui/material/Paper';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
@@ -11,12 +10,12 @@ import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Typography from '@mui/material/Typography';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import { ThemeProvider } from '@mui/material/styles';
import { useEffect, useMemo, useState } from 'react';
import { useEmbeddedParentTheme } from '../embed/useEmbeddedParentTheme';
import { buildToirMuiTheme } from '../theme/toirMuiTheme';
import { env } from '../config/env';
import { ensureFreshToken, getAccessToken } from '../auth/keycloak';
import { downloadEquipmentAttachmentFile } from '../resources/equipment/attachmentDownload';
type EquipmentRecord = {
id: string;
@@ -24,10 +23,6 @@ type EquipmentRecord = {
serialNumber: string;
dateOfInspection: string | null;
commissionedAt: string | null;
attachment?: {
objectKey?: string;
originalFileName?: string | null;
} | null;
};
function formatDate(value: string | null) {
@@ -46,19 +41,7 @@ function formatDate(value: string | null) {
export function EmbeddedActiveEquipmentPage() {
const paletteMode = useEmbeddedParentTheme();
const muiTheme = useMemo(
() =>
createTheme({
palette: { mode: paletteMode },
components: {
MuiPaper: {
styleOverrides: {
root: {
backgroundImage: 'none',
},
},
},
},
}),
() => buildToirMuiTheme(paletteMode),
[paletteMode],
);
@@ -132,13 +115,6 @@ export function EmbeddedActiveEquipmentPage() {
};
}, []);
const pageBg =
paletteMode === 'dark' ? 'linear-gradient(180deg, #0d1117 0%, #0a0c10 100%)' : '#f3f6fa';
const headerBg = paletteMode === 'dark' ? '#161b22' : '#fff';
const headerBorder = paletteMode === 'dark' ? '1px solid #30363d' : '1px solid #d7e0ea';
const titleColor = paletteMode === 'dark' ? '#e6edf3' : '#10233a';
const subtitleColor = paletteMode === 'dark' ? '#8b949e' : '#5b7087';
return (
<ThemeProvider theme={muiTheme}>
<CssBaseline />
@@ -147,23 +123,34 @@ export function EmbeddedActiveEquipmentPage() {
minHeight: '100vh',
boxSizing: 'border-box',
p: { xs: 2, md: 3 },
bgcolor: pageBg,
background:
paletteMode === 'dark'
? 'radial-gradient(circle at 10% 10%, rgba(201, 122, 61, 0.18), transparent 40%), radial-gradient(circle at 90% 90%, rgba(212, 165, 116, 0.14), transparent 42%), #0a0d12'
: 'radial-gradient(circle at 12% 12%, rgba(182, 130, 81, 0.20), transparent 42%), radial-gradient(circle at 88% 88%, rgba(214, 188, 157, 0.26), transparent 44%), #f7f0e5',
}}
>
<Paper
elevation={0}
sx={{
overflow: 'hidden',
borderRadius: 3,
border: headerBorder,
bgcolor: paletteMode === 'dark' ? '#161b22' : '#fff',
borderRadius: 4,
}}
>
<Box sx={{ px: 3, py: 2, borderBottom: headerBorder, bgcolor: headerBg }}>
<Typography variant="h5" sx={{ fontWeight: 700, color: titleColor }}>
<Box
sx={{
px: 3,
py: 2.25,
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
background:
paletteMode === 'dark'
? 'linear-gradient(145deg, rgba(255,255,255,0.06), rgba(0,0,0,0.1))'
: 'linear-gradient(145deg, rgba(255,255,255,0.9), rgba(255,255,255,0.62))',
}}
>
<Typography variant="h5" sx={{ fontWeight: 600, letterSpacing: '-0.02em' }}>
Оборудование в эксплуатации
</Typography>
<Typography variant="body2" sx={{ mt: 0.5, color: subtitleColor }}>
<Typography variant="body2" sx={{ mt: 0.5, opacity: 0.85 }}>
Отображаются записи со статусом &apos;Active&apos;
{typeof total === 'number' ? `: ${total}` : ''}
</Typography>
@@ -186,7 +173,6 @@ export function EmbeddedActiveEquipmentPage() {
<TableCell sx={{ fontWeight: 700 }}>Заводской номер</TableCell>
<TableCell sx={{ fontWeight: 700 }}>Дата изготовления</TableCell>
<TableCell sx={{ fontWeight: 700 }}>Дата поверки</TableCell>
<TableCell sx={{ fontWeight: 700 }}>Файл</TableCell>
</TableRow>
</TableHead>
<TableBody>
@@ -196,26 +182,6 @@ export function EmbeddedActiveEquipmentPage() {
<TableCell>{item.serialNumber}</TableCell>
<TableCell>{formatDate(item.commissionedAt)}</TableCell>
<TableCell>{formatDate(item.dateOfInspection)}</TableCell>
<TableCell>
{item?.attachment?.objectKey ? (
<Link
component="button"
type="button"
underline="hover"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const label = item.attachment?.originalFileName?.trim() || 'файл';
void downloadEquipmentAttachmentFile(item.id, label).catch(() => {});
}}
sx={{ cursor: 'pointer', verticalAlign: 'inherit' }}
>
{item.attachment?.originalFileName?.trim() || 'Скачать'}
</Link>
) : (
'—'
)}
</TableCell>
</TableRow>
))}
</TableBody>

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>
);

View File

@@ -0,0 +1,117 @@
import { createTheme, type Theme } from '@mui/material/styles';
import type { EmbedPaletteMode } from '../embed/useEmbeddedParentTheme';
type Mode = EmbedPaletteMode;
function buildTokens(mode: Mode) {
if (mode === 'light') {
return {
textPrimary: '#2a1d14',
textSecondary: '#4a3526',
divider: 'rgba(76, 59, 45, 0.10)',
border: 'rgba(170, 126, 88, 0.28)',
bgDefault: '#f7f0e5',
bgPaper: 'rgba(255, 252, 247, 0.92)',
glass: 'rgba(255, 249, 242, 0.78)',
accent: '#bd8d64',
accentSoft: 'rgba(182, 130, 81, 0.22)',
shadow: '0 20px 48px rgba(42, 29, 20, 0.10)',
};
}
return {
textPrimary: '#f8fafc',
textSecondary: '#cbd5e1',
divider: 'rgba(255, 255, 255, 0.06)',
border: 'rgba(212, 165, 116, 0.18)',
bgDefault: '#0a0d12',
bgPaper: 'rgba(10, 13, 18, 0.72)',
glass: 'rgba(60, 40, 23, 0.55)',
accent: '#d4a574',
accentSoft: 'rgba(201, 122, 61, 0.24)',
shadow: '0 34px 86px rgba(0, 0, 0, 0.56)',
};
}
export function buildToirMuiTheme(mode: Mode): Theme {
const t = buildTokens(mode);
return createTheme({
palette: {
mode,
primary: { main: t.accent },
secondary: { main: t.accent },
divider: t.divider,
background: {
default: t.bgDefault,
paper: t.bgPaper,
},
text: {
primary: t.textPrimary,
secondary: t.textSecondary,
},
},
shape: {
borderRadius: 14,
},
typography: {
fontFamily:
'"Inter Variable","Inter",-apple-system,BlinkMacSystemFont,"Segoe UI","Roboto","Oxygen","Ubuntu","Cantarell",sans-serif',
},
components: {
MuiCssBaseline: {
styleOverrides: {
body: {
background: t.bgDefault,
},
},
},
MuiPaper: {
styleOverrides: {
root: {
backgroundImage: 'none',
border: `1px solid ${t.border}`,
background: `linear-gradient(155deg, ${t.glass}, ${t.bgPaper})`,
boxShadow: t.shadow,
backdropFilter: 'blur(26px) saturate(1.12)',
WebkitBackdropFilter: 'blur(26px) saturate(1.12)',
},
},
},
MuiTableCell: {
styleOverrides: {
head: {
fontWeight: 700,
borderBottom: `1px solid ${t.border}`,
},
root: {
borderBottom: `1px solid ${t.divider}`,
},
},
},
MuiLink: {
styleOverrides: {
root: {
fontWeight: 500,
},
},
},
MuiButton: {
styleOverrides: {
root: {
textTransform: 'none',
borderRadius: 999,
},
containedPrimary: {
backgroundImage:
mode === 'light'
? 'linear-gradient(135deg, #cb9a6f 0%, #d9b48d 50%, #edd9be 100%)'
: 'linear-gradient(135deg, #c97a3d 0%, #d4a574 50%, #e8c9a0 100%)',
boxShadow: `0 18px 44px ${t.accentSoft}`,
},
},
},
},
});
}

View File

@@ -0,0 +1,4 @@
-- Move attachments from Equipment to ChangeEquipmentStatus and allow multiple files.
ALTER TABLE IF EXISTS "Equipment" DROP COLUMN IF EXISTS "attachment";
ALTER TABLE IF EXISTS "ChangeEquipmentStatus" ADD COLUMN IF NOT EXISTS "attachments" JSONB;

View File

@@ -20,8 +20,6 @@ model Equipment {
dateOfInspection DateTime?
commissionedAt DateTime?
status EquipmentStatus @default(Active)
/// JSON: { objectKey, originalFileName, contentType, sizeBytes } — файл в MinIO/S3
attachment Json?
changeEquipmentStatuses ChangeEquipmentStatus[]
}
@@ -33,4 +31,6 @@ model ChangeEquipmentStatus {
number String?
date DateTime
responsible String?
/// JSON: [{ id, objectKey, originalFileName, contentType, sizeBytes }] — файлы в MinIO/S3
attachments Json?
}

View File

@@ -1,14 +1,29 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
Injectable,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { EquipmentStatus, Prisma } from '@prisma/client';
import { Response } from 'express';
import type { Express } from 'express';
import { randomUUID } from 'node:crypto';
import type { Readable } from 'node:stream';
import { setListHeaders } from '../../common/http';
import { decodeUtf8FilenameFromMultipart } from '../../common/multipart-filename';
import { PrismaService } from '../../prisma/prisma.service';
import { StorageService } from '../../storage/storage.service';
import type { StoredAttachmentMeta } from '../equipment/attachment.types';
import { isStoredAttachmentMeta } from '../equipment/attachment.types';
import { CreateChangeEquipmentStatusDto } from './dto/create-change-equipment-status.dto';
import { UpdateChangeEquipmentStatusDto } from './dto/update-change-equipment-status.dto';
@Injectable()
export class ChangeEquipmentStatusService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly storage: StorageService,
) {}
async findAll(query: Record<string, unknown>, response: Response) {
const start = Number(query._start ?? 0);
@@ -135,6 +150,123 @@ export class ChangeEquipmentStatusService {
return this.toRecord(deleted);
}
async uploadAttachment(id: string, file: Express.Multer.File | undefined) {
if (!file?.buffer?.length) {
throw new BadRequestException('File is required (multipart field "file").');
}
if (!this.storage.isConfigured()) {
throw new ServiceUnavailableException(
'Object storage is not configured (set S3_* environment variables).',
);
}
const item = await this.prisma.changeEquipmentStatus.findUnique({
where: { id },
include: { equipment: true },
});
if (!item) {
throw new NotFoundException(`ChangeEquipmentStatus ${id} not found`);
}
const decodedOriginalName = decodeUtf8FilenameFromMultipart(file.originalname);
const objectKey = this.storage.buildStatusChangeObjectKey(id, decodedOriginalName);
const meta: StoredAttachmentMeta = {
id: randomUUID(),
objectKey,
originalFileName: decodedOriginalName,
contentType: file.mimetype || 'application/octet-stream',
sizeBytes: file.size,
};
await this.storage.putObject(objectKey, file.buffer, meta.contentType);
const current = this.parseAttachments(item.attachments);
const next = [...current, meta];
const updated = await this.prisma.changeEquipmentStatus.update({
where: { id },
data: { attachments: next as unknown as Prisma.InputJsonValue },
include: { equipment: true },
});
return this.toRecord(updated);
}
async removeAttachment(id: string, attachmentId: string) {
if (!this.storage.isConfigured()) {
throw new ServiceUnavailableException(
'Object storage is not configured (set S3_* environment variables).',
);
}
const item = await this.prisma.changeEquipmentStatus.findUnique({
where: { id },
include: { equipment: true },
});
if (!item) {
throw new NotFoundException(`ChangeEquipmentStatus ${id} not found`);
}
const current = this.parseAttachments(item.attachments);
const found = current.find((a) => a.id === attachmentId);
if (!found) {
throw new NotFoundException(`Вложение ${attachmentId} не найдено`);
}
try {
await this.storage.deleteObject(found.objectKey);
} catch {
// best-effort
}
const next = current.filter((a) => a.id !== attachmentId);
const updated = await this.prisma.changeEquipmentStatus.update({
where: { id },
data: { attachments: next.length ? (next as unknown as Prisma.InputJsonValue) : Prisma.JsonNull },
include: { equipment: true },
});
return this.toRecord(updated);
}
async getAttachmentDownloadStream(
id: string,
attachmentId: string,
): Promise<{
stream: Readable;
contentType: string;
fileName: string;
contentLength?: number;
}> {
if (!this.storage.isConfigured()) {
throw new ServiceUnavailableException(
'Object storage is not configured (set S3_* environment variables).',
);
}
const item = await this.prisma.changeEquipmentStatus.findUnique({ where: { id } });
if (!item) {
throw new NotFoundException(`ChangeEquipmentStatus ${id} not found`);
}
const current = this.parseAttachments(item.attachments);
const found = current.find((a) => a.id === attachmentId);
if (!found) {
throw new NotFoundException(`Вложение ${attachmentId} не найдено`);
}
const fileName = decodeUtf8FilenameFromMultipart(found.originalFileName) || 'file';
const { stream, contentType, contentLength } = await this.storage.getObjectStream(found.objectKey);
const len = typeof contentLength === 'bigint' ? Number(contentLength) : contentLength;
return {
stream,
contentType: found.contentType || contentType,
fileName,
contentLength: len,
};
}
private toRecord(item: {
id: string;
equipmentId: string | null;
@@ -143,6 +275,7 @@ export class ChangeEquipmentStatusService {
date: Date;
responsible: string | null;
equipment?: { id: string; name: string } | null;
attachments?: Prisma.JsonValue | null;
}) {
return {
id: item.id,
@@ -157,6 +290,28 @@ export class ChangeEquipmentStatusService {
name: item.equipment.name,
}
: null,
attachments: this.serializeAttachments(item.attachments),
};
}
private parseAttachments(raw: Prisma.JsonValue | null | undefined): StoredAttachmentMeta[] {
if (!raw) {
return [];
}
if (!Array.isArray(raw)) {
return [];
}
return raw.filter(isStoredAttachmentMeta);
}
private serializeAttachments(raw: Prisma.JsonValue | null | undefined) {
const items = this.parseAttachments(raw);
return items.map((a) => ({
id: a.id,
originalFileName: decodeUtf8FilenameFromMultipart(a.originalFileName),
contentType: a.contentType,
sizeBytes: a.sizeBytes,
downloadUrl: null as string | null,
}));
}
}

View File

@@ -8,9 +8,14 @@ import {
Post,
Query,
Res,
StreamableFile,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { memoryStorage } from 'multer';
import { Roles } from '../../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../../auth/guards/roles.guard';
@@ -35,6 +40,40 @@ export class EquipmentStatusChangeController {
return this.equipmentStatusChangeService.findOne(id);
}
@Roles('editor', 'admin')
@Post(':id/attachments')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 50 * 1024 * 1024 },
}),
)
uploadAttachment(@Param('id') id: string, @UploadedFile() file: Express.Multer.File) {
return this.equipmentStatusChangeService.uploadAttachment(id, file);
}
@Roles('editor', 'admin')
@Delete(':id/attachments/:attachmentId')
removeAttachment(@Param('id') id: string, @Param('attachmentId') attachmentId: string) {
return this.equipmentStatusChangeService.removeAttachment(id, attachmentId);
}
@Roles('viewer', 'editor', 'admin')
@Get(':id/attachments/:attachmentId/download')
async downloadAttachment(
@Param('id') id: string,
@Param('attachmentId') attachmentId: string,
): Promise<StreamableFile> {
const { stream, contentType, fileName, contentLength } =
await this.equipmentStatusChangeService.getAttachmentDownloadStream(id, attachmentId);
const encoded = encodeURIComponent(fileName);
return new StreamableFile(stream, {
type: contentType,
disposition: `attachment; filename*=UTF-8''${encoded}`,
length: contentLength,
});
}
@Roles('editor', 'admin')
@Post()
create(@Body() dto: CreateChangeEquipmentStatusDto) {

View File

@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../../auth/auth.module';
import { StorageModule } from '../../storage/storage.module';
import { ChangeEquipmentStatusService } from '../change-equipment-status/change-equipment-status.service';
import { EquipmentStatusChangeController } from './equipment-status-change.controller';
@Module({
imports: [AuthModule],
imports: [AuthModule, StorageModule],
controllers: [EquipmentStatusChangeController],
providers: [ChangeEquipmentStatusService],
})

View File

@@ -1,14 +1,20 @@
export type EquipmentAttachmentStored = {
export type StoredAttachmentMeta = {
id: string;
objectKey: string;
originalFileName: string;
contentType: string;
sizeBytes: number;
};
export function isEquipmentAttachmentStored(value: unknown): value is EquipmentAttachmentStored {
export function isStoredAttachmentMeta(value: unknown): value is StoredAttachmentMeta {
if (!value || typeof value !== 'object') {
return false;
}
const o = value as Record<string, unknown>;
return typeof o.objectKey === 'string' && o.objectKey.length > 0;
return (
typeof o.id === 'string' &&
o.id.length > 0 &&
typeof o.objectKey === 'string' &&
o.objectKey.length > 0
);
}

View File

@@ -8,14 +8,9 @@ import {
Post,
Query,
Res,
StreamableFile,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { memoryStorage } from 'multer';
import { Roles } from '../../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../../auth/guards/roles.guard';
@@ -34,37 +29,6 @@ export class EquipmentController {
return this.equipmentService.findAll(query, response);
}
@Roles('editor', 'admin')
@Post(':id/attachment')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 50 * 1024 * 1024 },
}),
)
uploadAttachment(@Param('id') id: string, @UploadedFile() file: Express.Multer.File) {
return this.equipmentService.uploadAttachment(id, file);
}
@Roles('editor', 'admin')
@Delete(':id/attachment')
removeAttachment(@Param('id') id: string) {
return this.equipmentService.removeAttachment(id);
}
@Roles('viewer', 'editor', 'admin')
@Get(':id/attachment/download')
async downloadAttachment(@Param('id') id: string): Promise<StreamableFile> {
const { stream, contentType, fileName, contentLength } =
await this.equipmentService.getAttachmentDownloadStream(id);
const encoded = encodeURIComponent(fileName);
return new StreamableFile(stream, {
type: contentType,
disposition: `attachment; filename*=UTF-8''${encoded}`,
length: contentLength,
});
}
@Roles('viewer', 'editor', 'admin')
@Get(':id')
findOne(@Param('id') id: string) {

View File

@@ -11,12 +11,9 @@ import type { Express } from 'express';
import { decodeUtf8FilenameFromMultipart } from '../../common/multipart-filename';
import { setListHeaders } from '../../common/http';
import { PrismaService } from '../../prisma/prisma.service';
import type { StoredAttachmentMeta } from '../../storage/storage.service';
import { StorageService } from '../../storage/storage.service';
import type { Readable } from 'node:stream';
import { CreateEquipmentDto } from './dto/create-equipment.dto';
import { UpdateEquipmentDto } from './dto/update-equipment.dto';
import { isEquipmentAttachmentStored } from './attachment.types';
@Injectable()
export class EquipmentService {
@@ -113,118 +110,10 @@ export class EquipmentService {
if (!item) {
throw new NotFoundException(`Equipment ${id} not found`);
}
await this.deleteStoredAttachmentIfAny(item);
const deleted = await this.prisma.equipment.delete({ where: { id } });
return this.toRecord(deleted);
}
async uploadAttachment(id: string, file: Express.Multer.File | undefined) {
if (!file?.buffer?.length) {
throw new BadRequestException('File is required (multipart field "file").');
}
if (!this.storage.isConfigured()) {
throw new ServiceUnavailableException(
'Object storage is not configured (set S3_* environment variables).',
);
}
const item = await this.prisma.equipment.findUnique({ where: { id } });
if (!item) {
throw new NotFoundException(`Equipment ${id} not found`);
}
await this.deleteStoredAttachmentIfAny(item);
const decodedOriginalName = decodeUtf8FilenameFromMultipart(file.originalname);
const objectKey = this.storage.buildEquipmentObjectKey(id, decodedOriginalName);
const meta: StoredAttachmentMeta = {
objectKey,
originalFileName: decodedOriginalName,
contentType: file.mimetype || 'application/octet-stream',
sizeBytes: file.size,
};
await this.storage.putObject(objectKey, file.buffer, meta.contentType);
const updated = await this.prisma.equipment.update({
where: { id },
data: { attachment: meta as unknown as Prisma.InputJsonValue },
});
return this.toRecord(updated);
}
async getAttachmentDownloadStream(id: string): Promise<{
stream: Readable;
contentType: string;
fileName: string;
contentLength?: number;
}> {
if (!this.storage.isConfigured()) {
throw new ServiceUnavailableException(
'Object storage is not configured (set S3_* environment variables).',
);
}
const item = await this.prisma.equipment.findUnique({ where: { id } });
if (!item) {
throw new NotFoundException(`Equipment ${id} not found`);
}
if (!isEquipmentAttachmentStored(item.attachment)) {
throw new NotFoundException(`Вложение для оборудования ${id} не найдено`);
}
const raw = item.attachment;
const fileName = decodeUtf8FilenameFromMultipart(raw.originalFileName) || 'file';
const { stream, contentType, contentLength } = await this.storage.getObjectStream(raw.objectKey);
const len =
typeof contentLength === 'bigint' ? Number(contentLength) : contentLength;
return {
stream,
contentType: raw.contentType || contentType,
fileName,
contentLength: len,
};
}
async removeAttachment(id: string) {
if (!this.storage.isConfigured()) {
throw new ServiceUnavailableException(
'Object storage is not configured (set S3_* environment variables).',
);
}
const item = await this.prisma.equipment.findUnique({ where: { id } });
if (!item) {
throw new NotFoundException(`Equipment ${id} not found`);
}
await this.deleteStoredAttachmentIfAny(item);
const updated = await this.prisma.equipment.update({
where: { id },
data: { attachment: Prisma.JsonNull },
});
return this.toRecord(updated);
}
private async deleteStoredAttachmentIfAny(item: Equipment) {
const raw = item.attachment;
if (!isEquipmentAttachmentStored(raw)) {
return;
}
if (!this.storage.isConfigured()) {
return;
}
try {
await this.storage.deleteObject(raw.objectKey);
} catch {
// best-effort: DB will still clear / overwrite
}
}
private async toRecord(item: Equipment) {
return {
id: item.id,
@@ -233,31 +122,6 @@ export class EquipmentService {
dateOfInspection: item.dateOfInspection?.toISOString() ?? null,
commissionedAt: item.commissionedAt?.toISOString() ?? null,
status: item.status,
attachment: await this.serializeAttachment(item.attachment),
};
}
private async serializeAttachment(raw: Prisma.JsonValue | null) {
if (raw === null || raw === undefined) {
return null;
}
if (!isEquipmentAttachmentStored(raw)) {
return null;
}
let downloadUrl: string | null = null;
if (this.storage.isConfigured()) {
try {
downloadUrl = await this.storage.getDownloadUrl(raw.objectKey);
} catch {
downloadUrl = null;
}
}
return {
objectKey: raw.objectKey,
originalFileName: decodeUtf8FilenameFromMultipart(raw.originalFileName),
contentType: raw.contentType,
sizeBytes: raw.sizeBytes,
downloadUrl,
};
}
}

View File

@@ -126,6 +126,14 @@ export class StorageService {
const safe = sanitizeFileName(originalName);
return `${prefix}/${equipmentId}/${randomUUID()}-${safe}`;
}
buildStatusChangeObjectKey(statusChangeId: string, originalName: string): string {
const prefix = (process.env.S3_OBJECT_PREFIX ?? 'toir-light/equipment')
.replace(/^\/+|\/+$/g, '')
.replace(/equipment$/, 'status-changes');
const safe = sanitizeFileName(originalName);
return `${prefix}/${statusChangeId}/${randomUUID()}-${safe}`;
}
}
function sanitizeFileName(name: string): string {