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}`,
},
},
},
},
});
}