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:
45
client/package-lock.json
generated
45
client/package-lock.json
generated
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
44
client/src/i18n/ru.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 }}>
|
||||
Отображаются записи со статусом 'Active'
|
||||
{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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
117
client/src/theme/toirMuiTheme.ts
Normal file
117
client/src/theme/toirMuiTheme.ts
Normal 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}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user