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",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@mui/material": "^7.3.9",
|
"@mui/material": "^7.3.9",
|
||||||
"keycloak-js": "^26.2.3",
|
"keycloak-js": "^26.2.3",
|
||||||
|
"ra-i18n-polyglot": "^5.14.5",
|
||||||
|
"ra-language-russian": "^5.4.5",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-admin": "^5.14.5",
|
"react-admin": "^5.14.5",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
@@ -1081,9 +1083,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1101,9 +1100,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1121,9 +1117,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1141,9 +1134,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1161,9 +1151,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1181,9 +1168,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3099,9 +3083,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3123,9 +3104,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3147,9 +3125,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3171,9 +3146,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3655,6 +3627,15 @@
|
|||||||
"ra-core": "^5.14.5"
|
"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": {
|
"node_modules/ra-ui-materialui": {
|
||||||
"version": "5.14.5",
|
"version": "5.14.5",
|
||||||
"resolved": "https://registry.npmjs.org/ra-ui-materialui/-/ra-ui-materialui-5.14.5.tgz",
|
"resolved": "https://registry.npmjs.org/ra-ui-materialui/-/ra-ui-materialui-5.14.5.tgz",
|
||||||
@@ -4335,8 +4316,10 @@
|
|||||||
"version": "2.8.3",
|
"version": "2.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||||
"extraneous": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"yaml": "bin.mjs"
|
"yaml": "bin.mjs"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@mui/material": "^7.3.9",
|
"@mui/material": "^7.3.9",
|
||||||
"keycloak-js": "^26.2.3",
|
"keycloak-js": "^26.2.3",
|
||||||
|
"ra-i18n-polyglot": "^5.14.5",
|
||||||
|
"ra-language-russian": "^5.4.5",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-admin": "^5.14.5",
|
"react-admin": "^5.14.5",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { createTheme } from '@mui/material/styles';
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Admin, Resource } from 'react-admin';
|
import { Admin, Resource } from 'react-admin';
|
||||||
|
import polyglotI18nProvider from 'ra-i18n-polyglot';
|
||||||
import { authProvider } from './auth/authProvider';
|
import { authProvider } from './auth/authProvider';
|
||||||
import { dataProvider } from './dataProvider';
|
import { dataProvider } from './dataProvider';
|
||||||
import { useEmbeddedParentTheme } from './embed/useEmbeddedParentTheme';
|
import { useEmbeddedParentTheme } from './embed/useEmbeddedParentTheme';
|
||||||
|
import { messagesRu } from './i18n/ru';
|
||||||
import { EmbeddedActiveEquipmentPage } from './pages/EmbeddedActiveEquipmentPage';
|
import { EmbeddedActiveEquipmentPage } from './pages/EmbeddedActiveEquipmentPage';
|
||||||
|
import { buildToirMuiTheme } from './theme/toirMuiTheme';
|
||||||
import { EquipmentCreate } from './resources/equipment/EquipmentCreate';
|
import { EquipmentCreate } from './resources/equipment/EquipmentCreate';
|
||||||
import { EquipmentEdit } from './resources/equipment/EquipmentEdit';
|
import { EquipmentEdit } from './resources/equipment/EquipmentEdit';
|
||||||
import { EquipmentList } from './resources/equipment/EquipmentList';
|
import { EquipmentList } from './resources/equipment/EquipmentList';
|
||||||
@@ -17,17 +19,27 @@ import { EquipmentStatusChangeShow } from './resources/equipment-status-change/E
|
|||||||
function ToirAdmin() {
|
function ToirAdmin() {
|
||||||
const paletteMode = useEmbeddedParentTheme();
|
const paletteMode = useEmbeddedParentTheme();
|
||||||
const theme = useMemo(
|
const theme = useMemo(
|
||||||
() =>
|
() => buildToirMuiTheme(paletteMode),
|
||||||
createTheme({
|
|
||||||
palette: { mode: paletteMode },
|
|
||||||
}),
|
|
||||||
[paletteMode],
|
[paletteMode],
|
||||||
);
|
);
|
||||||
|
const i18nProvider = useMemo(
|
||||||
|
() =>
|
||||||
|
polyglotI18nProvider(() => messagesRu, 'ru', {
|
||||||
|
allowMissing: true,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Admin dataProvider={dataProvider} authProvider={authProvider} theme={theme}>
|
<Admin
|
||||||
|
dataProvider={dataProvider}
|
||||||
|
authProvider={authProvider}
|
||||||
|
theme={theme}
|
||||||
|
i18nProvider={i18nProvider}
|
||||||
|
>
|
||||||
<Resource
|
<Resource
|
||||||
name="equipment"
|
name="equipment"
|
||||||
|
options={{ label: 'Оборудование' }}
|
||||||
list={EquipmentList}
|
list={EquipmentList}
|
||||||
create={EquipmentCreate}
|
create={EquipmentCreate}
|
||||||
edit={EquipmentEdit}
|
edit={EquipmentEdit}
|
||||||
@@ -35,6 +47,7 @@ function ToirAdmin() {
|
|||||||
/>
|
/>
|
||||||
<Resource
|
<Resource
|
||||||
name="status-changes"
|
name="status-changes"
|
||||||
|
options={{ label: 'Акты' }}
|
||||||
list={EquipmentStatusChangeList}
|
list={EquipmentStatusChangeList}
|
||||||
create={EquipmentStatusChangeCreate}
|
create={EquipmentStatusChangeCreate}
|
||||||
edit={EquipmentStatusChangeEdit}
|
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 Box from '@mui/material/Box';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import CssBaseline from '@mui/material/CssBaseline';
|
import CssBaseline from '@mui/material/CssBaseline';
|
||||||
import Link from '@mui/material/Link';
|
|
||||||
import Paper from '@mui/material/Paper';
|
import Paper from '@mui/material/Paper';
|
||||||
import Table from '@mui/material/Table';
|
import Table from '@mui/material/Table';
|
||||||
import TableBody from '@mui/material/TableBody';
|
import TableBody from '@mui/material/TableBody';
|
||||||
@@ -11,12 +10,12 @@ import TableContainer from '@mui/material/TableContainer';
|
|||||||
import TableHead from '@mui/material/TableHead';
|
import TableHead from '@mui/material/TableHead';
|
||||||
import TableRow from '@mui/material/TableRow';
|
import TableRow from '@mui/material/TableRow';
|
||||||
import Typography from '@mui/material/Typography';
|
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 { useEffect, useMemo, useState } from 'react';
|
||||||
import { useEmbeddedParentTheme } from '../embed/useEmbeddedParentTheme';
|
import { useEmbeddedParentTheme } from '../embed/useEmbeddedParentTheme';
|
||||||
|
import { buildToirMuiTheme } from '../theme/toirMuiTheme';
|
||||||
import { env } from '../config/env';
|
import { env } from '../config/env';
|
||||||
import { ensureFreshToken, getAccessToken } from '../auth/keycloak';
|
import { ensureFreshToken, getAccessToken } from '../auth/keycloak';
|
||||||
import { downloadEquipmentAttachmentFile } from '../resources/equipment/attachmentDownload';
|
|
||||||
|
|
||||||
type EquipmentRecord = {
|
type EquipmentRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -24,10 +23,6 @@ type EquipmentRecord = {
|
|||||||
serialNumber: string;
|
serialNumber: string;
|
||||||
dateOfInspection: string | null;
|
dateOfInspection: string | null;
|
||||||
commissionedAt: string | null;
|
commissionedAt: string | null;
|
||||||
attachment?: {
|
|
||||||
objectKey?: string;
|
|
||||||
originalFileName?: string | null;
|
|
||||||
} | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDate(value: string | null) {
|
function formatDate(value: string | null) {
|
||||||
@@ -46,19 +41,7 @@ function formatDate(value: string | null) {
|
|||||||
export function EmbeddedActiveEquipmentPage() {
|
export function EmbeddedActiveEquipmentPage() {
|
||||||
const paletteMode = useEmbeddedParentTheme();
|
const paletteMode = useEmbeddedParentTheme();
|
||||||
const muiTheme = useMemo(
|
const muiTheme = useMemo(
|
||||||
() =>
|
() => buildToirMuiTheme(paletteMode),
|
||||||
createTheme({
|
|
||||||
palette: { mode: paletteMode },
|
|
||||||
components: {
|
|
||||||
MuiPaper: {
|
|
||||||
styleOverrides: {
|
|
||||||
root: {
|
|
||||||
backgroundImage: 'none',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[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 (
|
return (
|
||||||
<ThemeProvider theme={muiTheme}>
|
<ThemeProvider theme={muiTheme}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
@@ -147,23 +123,34 @@ export function EmbeddedActiveEquipmentPage() {
|
|||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
p: { xs: 2, md: 3 },
|
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
|
<Paper
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
borderRadius: 3,
|
borderRadius: 4,
|
||||||
border: headerBorder,
|
|
||||||
bgcolor: paletteMode === 'dark' ? '#161b22' : '#fff',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ px: 3, py: 2, borderBottom: headerBorder, bgcolor: headerBg }}>
|
<Box
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700, color: titleColor }}>
|
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>
|
||||||
<Typography variant="body2" sx={{ mt: 0.5, color: subtitleColor }}>
|
<Typography variant="body2" sx={{ mt: 0.5, opacity: 0.85 }}>
|
||||||
Отображаются записи со статусом 'Active'
|
Отображаются записи со статусом 'Active'
|
||||||
{typeof total === 'number' ? `: ${total}` : ''}
|
{typeof total === 'number' ? `: ${total}` : ''}
|
||||||
</Typography>
|
</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>
|
||||||
<TableCell sx={{ fontWeight: 700 }}>Дата поверки</TableCell>
|
<TableCell sx={{ fontWeight: 700 }}>Дата поверки</TableCell>
|
||||||
<TableCell sx={{ fontWeight: 700 }}>Файл</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -196,26 +182,6 @@ export function EmbeddedActiveEquipmentPage() {
|
|||||||
<TableCell>{item.serialNumber}</TableCell>
|
<TableCell>{item.serialNumber}</TableCell>
|
||||||
<TableCell>{formatDate(item.commissionedAt)}</TableCell>
|
<TableCell>{formatDate(item.commissionedAt)}</TableCell>
|
||||||
<TableCell>{formatDate(item.dateOfInspection)}</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>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
TextInput as RaTextInput,
|
TextInput as RaTextInput,
|
||||||
} from 'react-admin';
|
} from 'react-admin';
|
||||||
import { equipmentStatusChoices, equipmentOptionText } from '../equipment/shared';
|
import { equipmentStatusChoices, equipmentOptionText } from '../equipment/shared';
|
||||||
|
import { StatusChangeAttachmentsInput } from './StatusChangeAttachmentsInput';
|
||||||
|
|
||||||
export function ChangeEquipmentStatusEdit() {
|
export function ChangeEquipmentStatusEdit() {
|
||||||
return (
|
return (
|
||||||
@@ -24,6 +25,7 @@ export function ChangeEquipmentStatusEdit() {
|
|||||||
<DateInput source="date" label="Дата" />
|
<DateInput source="date" label="Дата" />
|
||||||
<RaTextInput source="number" label="Номер" />
|
<RaTextInput source="number" label="Номер" />
|
||||||
<RaTextInput source="responsible" label="Ответственный" />
|
<RaTextInput source="responsible" label="Ответственный" />
|
||||||
|
<StatusChangeAttachmentsInput />
|
||||||
</SimpleForm>
|
</SimpleForm>
|
||||||
</Edit>
|
</Edit>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Datagrid,
|
Datagrid,
|
||||||
DateField,
|
DateField,
|
||||||
FilterButton,
|
FilterButton,
|
||||||
|
FunctionField,
|
||||||
List,
|
List,
|
||||||
ReferenceField,
|
ReferenceField,
|
||||||
SelectArrayInput,
|
SelectArrayInput,
|
||||||
@@ -38,6 +39,13 @@ export function ChangeEquipmentStatusList() {
|
|||||||
<TextField source="number" />
|
<TextField source="number" />
|
||||||
<DateField source="date" />
|
<DateField source="date" />
|
||||||
<TextField source="responsible" />
|
<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>
|
</Datagrid>
|
||||||
</List>
|
</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 { equipmentStatusChoices } from '../equipment/shared';
|
||||||
|
import { StatusChangeAttachmentLink } from './StatusChangeAttachmentLink';
|
||||||
|
|
||||||
export function ChangeEquipmentStatusShow() {
|
export function ChangeEquipmentStatusShow() {
|
||||||
return (
|
return (
|
||||||
@@ -13,6 +14,31 @@ export function ChangeEquipmentStatusShow() {
|
|||||||
<TextField source="number" />
|
<TextField source="number" />
|
||||||
<DateField source="date" />
|
<DateField source="date" />
|
||||||
<TextField source="responsible" />
|
<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>
|
</SimpleShowLayout>
|
||||||
</Show>
|
</Show>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { Link } from '@mui/material';
|
import { Link } from '@mui/material';
|
||||||
import { useNotify } from 'react-admin';
|
import { useNotify } from 'react-admin';
|
||||||
import { downloadEquipmentAttachmentFile } from './attachmentDownload';
|
import { downloadStatusChangeAttachmentFile } from './attachmentDownload';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
equipmentId: string;
|
statusChangeId: string;
|
||||||
/** Текст ссылки и имя при сохранении в браузере */
|
attachmentId: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function EquipmentAttachmentLink({ equipmentId, fileName }: Props) {
|
export function StatusChangeAttachmentLink({ statusChangeId, attachmentId, fileName }: Props) {
|
||||||
const notify = useNotify();
|
const notify = useNotify();
|
||||||
const label = fileName.trim() || 'Скачать';
|
const label = fileName.trim() || 'Скачать';
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export function EquipmentAttachmentLink({ equipmentId, fileName }: Props) {
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
void downloadEquipmentAttachmentFile(equipmentId, label).catch(() =>
|
void downloadStatusChangeAttachmentFile(statusChangeId, attachmentId, label).catch(() =>
|
||||||
notify('Не удалось скачать файл', { type: 'warning' }),
|
notify('Не удалось скачать файл', { type: 'warning' }),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -29,3 +29,4 @@ export function EquipmentAttachmentLink({ equipmentId, fileName }: Props) {
|
|||||||
</Link>
|
</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 { ensureFreshToken, getAccessToken } from '../../auth/keycloak';
|
||||||
import { resolveApiBaseUrl } from '../../lib/apiBase';
|
import { resolveApiBaseUrl } from '../../lib/apiBase';
|
||||||
|
|
||||||
/** Скачивание через API (JWT + Content-Disposition), без открытия URL MinIO в браузере. */
|
/** Скачивание через API (JWT + Content-Disposition). */
|
||||||
export async function downloadEquipmentAttachmentFile(equipmentId: string, suggestedFileName: string): Promise<void> {
|
export async function downloadStatusChangeAttachmentFile(
|
||||||
|
statusChangeId: string,
|
||||||
|
attachmentId: string,
|
||||||
|
suggestedFileName: string,
|
||||||
|
): Promise<void> {
|
||||||
await ensureFreshToken();
|
await ensureFreshToken();
|
||||||
const token = getAccessToken();
|
const token = getAccessToken();
|
||||||
const url = `${resolveApiBaseUrl()}/equipment/${equipmentId}/attachment/download`;
|
const url = `${resolveApiBaseUrl()}/status-changes/${statusChangeId}/attachments/${attachmentId}/download`;
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: token ? { Authorization: `Bearer ${token}`, Accept: '*/*' } : { Accept: '*/*' },
|
headers: token ? { Authorization: `Bearer ${token}`, Accept: '*/*' } : { Accept: '*/*' },
|
||||||
@@ -24,3 +28,4 @@ export async function downloadEquipmentAttachmentFile(equipmentId: string, sugge
|
|||||||
a.remove();
|
a.remove();
|
||||||
URL.revokeObjectURL(objectUrl);
|
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 { DateInput, Edit, SelectInput, SimpleForm, TextInput as RaTextInput } from 'react-admin';
|
||||||
import { EquipmentAttachmentInput } from './EquipmentAttachmentInput';
|
|
||||||
import { equipmentStatusChoices } from './shared';
|
import { equipmentStatusChoices } from './shared';
|
||||||
|
|
||||||
const equipmentLabels = {
|
const equipmentLabels = {
|
||||||
@@ -19,7 +18,6 @@ export function EquipmentEdit() {
|
|||||||
<RaTextInput source="name" label={equipmentLabels.name} />
|
<RaTextInput source="name" label={equipmentLabels.name} />
|
||||||
<RaTextInput source="serialNumber" label={equipmentLabels.serialNumber} />
|
<RaTextInput source="serialNumber" label={equipmentLabels.serialNumber} />
|
||||||
<SelectInput source="status" label={equipmentLabels.status} choices={equipmentStatusChoices} />
|
<SelectInput source="status" label={equipmentLabels.status} choices={equipmentStatusChoices} />
|
||||||
<EquipmentAttachmentInput />
|
|
||||||
</SimpleForm>
|
</SimpleForm>
|
||||||
</Edit>
|
</Edit>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
Datagrid,
|
Datagrid,
|
||||||
DateField,
|
DateField,
|
||||||
FilterButton,
|
FilterButton,
|
||||||
FunctionField,
|
|
||||||
List,
|
List,
|
||||||
SelectArrayInput,
|
SelectArrayInput,
|
||||||
SelectField,
|
SelectField,
|
||||||
@@ -11,7 +10,6 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
TopToolbar,
|
TopToolbar,
|
||||||
} from 'react-admin';
|
} from 'react-admin';
|
||||||
import { EquipmentAttachmentLink } from './EquipmentAttachmentLink';
|
|
||||||
import { equipmentStatusChoices } from './shared';
|
import { equipmentStatusChoices } from './shared';
|
||||||
|
|
||||||
const equipmentFilters = [
|
const equipmentFilters = [
|
||||||
@@ -36,22 +34,6 @@ export function EquipmentList() {
|
|||||||
<DateField source="dateOfInspection" />
|
<DateField source="dateOfInspection" />
|
||||||
<DateField source="commissionedAt" />
|
<DateField source="commissionedAt" />
|
||||||
<SelectField source="status" choices={equipmentStatusChoices} />
|
<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>
|
</Datagrid>
|
||||||
</List>
|
</List>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { DateField, FunctionField, SelectField, Show, SimpleShowLayout, TextField } from 'react-admin';
|
import { DateField, SelectField, Show, SimpleShowLayout, TextField } from 'react-admin';
|
||||||
import { EquipmentAttachmentLink } from './EquipmentAttachmentLink';
|
|
||||||
import { equipmentStatusChoices } from './shared';
|
import { equipmentStatusChoices } from './shared';
|
||||||
|
|
||||||
export function EquipmentShow() {
|
export function EquipmentShow() {
|
||||||
@@ -12,22 +11,6 @@ export function EquipmentShow() {
|
|||||||
<DateField source="dateOfInspection" />
|
<DateField source="dateOfInspection" />
|
||||||
<DateField source="commissionedAt" />
|
<DateField source="commissionedAt" />
|
||||||
<SelectField source="status" choices={equipmentStatusChoices} />
|
<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>
|
</SimpleShowLayout>
|
||||||
</Show>
|
</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}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -20,8 +20,6 @@ model Equipment {
|
|||||||
dateOfInspection DateTime?
|
dateOfInspection DateTime?
|
||||||
commissionedAt DateTime?
|
commissionedAt DateTime?
|
||||||
status EquipmentStatus @default(Active)
|
status EquipmentStatus @default(Active)
|
||||||
/// JSON: { objectKey, originalFileName, contentType, sizeBytes } — файл в MinIO/S3
|
|
||||||
attachment Json?
|
|
||||||
changeEquipmentStatuses ChangeEquipmentStatus[]
|
changeEquipmentStatuses ChangeEquipmentStatus[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,4 +31,6 @@ model ChangeEquipmentStatus {
|
|||||||
number String?
|
number String?
|
||||||
date DateTime
|
date DateTime
|
||||||
responsible String?
|
responsible String?
|
||||||
|
/// JSON: [{ id, objectKey, originalFileName, contentType, sizeBytes }] — файлы в MinIO/S3
|
||||||
|
attachments Json?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { EquipmentStatus, Prisma } from '@prisma/client';
|
||||||
import { Response } from 'express';
|
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 { setListHeaders } from '../../common/http';
|
||||||
|
import { decodeUtf8FilenameFromMultipart } from '../../common/multipart-filename';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
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 { CreateChangeEquipmentStatusDto } from './dto/create-change-equipment-status.dto';
|
||||||
import { UpdateChangeEquipmentStatusDto } from './dto/update-change-equipment-status.dto';
|
import { UpdateChangeEquipmentStatusDto } from './dto/update-change-equipment-status.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ChangeEquipmentStatusService {
|
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) {
|
async findAll(query: Record<string, unknown>, response: Response) {
|
||||||
const start = Number(query._start ?? 0);
|
const start = Number(query._start ?? 0);
|
||||||
@@ -135,6 +150,123 @@ export class ChangeEquipmentStatusService {
|
|||||||
return this.toRecord(deleted);
|
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: {
|
private toRecord(item: {
|
||||||
id: string;
|
id: string;
|
||||||
equipmentId: string | null;
|
equipmentId: string | null;
|
||||||
@@ -143,6 +275,7 @@ export class ChangeEquipmentStatusService {
|
|||||||
date: Date;
|
date: Date;
|
||||||
responsible: string | null;
|
responsible: string | null;
|
||||||
equipment?: { id: string; name: string } | null;
|
equipment?: { id: string; name: string } | null;
|
||||||
|
attachments?: Prisma.JsonValue | null;
|
||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@@ -157,6 +290,28 @@ export class ChangeEquipmentStatusService {
|
|||||||
name: item.equipment.name,
|
name: item.equipment.name,
|
||||||
}
|
}
|
||||||
: null,
|
: 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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,14 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Res,
|
Res,
|
||||||
|
StreamableFile,
|
||||||
|
UploadedFile,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
|
import { memoryStorage } from 'multer';
|
||||||
import { Roles } from '../../auth/decorators/roles.decorator';
|
import { Roles } from '../../auth/decorators/roles.decorator';
|
||||||
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from '../../auth/guards/roles.guard';
|
import { RolesGuard } from '../../auth/guards/roles.guard';
|
||||||
@@ -35,6 +40,40 @@ export class EquipmentStatusChangeController {
|
|||||||
return this.equipmentStatusChangeService.findOne(id);
|
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')
|
@Roles('editor', 'admin')
|
||||||
@Post()
|
@Post()
|
||||||
create(@Body() dto: CreateChangeEquipmentStatusDto) {
|
create(@Body() dto: CreateChangeEquipmentStatusDto) {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthModule } from '../../auth/auth.module';
|
import { AuthModule } from '../../auth/auth.module';
|
||||||
|
import { StorageModule } from '../../storage/storage.module';
|
||||||
import { ChangeEquipmentStatusService } from '../change-equipment-status/change-equipment-status.service';
|
import { ChangeEquipmentStatusService } from '../change-equipment-status/change-equipment-status.service';
|
||||||
import { EquipmentStatusChangeController } from './equipment-status-change.controller';
|
import { EquipmentStatusChangeController } from './equipment-status-change.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule],
|
imports: [AuthModule, StorageModule],
|
||||||
controllers: [EquipmentStatusChangeController],
|
controllers: [EquipmentStatusChangeController],
|
||||||
providers: [ChangeEquipmentStatusService],
|
providers: [ChangeEquipmentStatusService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
export type EquipmentAttachmentStored = {
|
export type StoredAttachmentMeta = {
|
||||||
|
id: string;
|
||||||
objectKey: string;
|
objectKey: string;
|
||||||
originalFileName: string;
|
originalFileName: string;
|
||||||
contentType: string;
|
contentType: string;
|
||||||
sizeBytes: number;
|
sizeBytes: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isEquipmentAttachmentStored(value: unknown): value is EquipmentAttachmentStored {
|
export function isStoredAttachmentMeta(value: unknown): value is StoredAttachmentMeta {
|
||||||
if (!value || typeof value !== 'object') {
|
if (!value || typeof value !== 'object') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const o = value as Record<string, unknown>;
|
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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,14 +8,9 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Res,
|
Res,
|
||||||
StreamableFile,
|
|
||||||
UploadedFile,
|
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { memoryStorage } from 'multer';
|
|
||||||
import { Roles } from '../../auth/decorators/roles.decorator';
|
import { Roles } from '../../auth/decorators/roles.decorator';
|
||||||
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from '../../auth/guards/roles.guard';
|
import { RolesGuard } from '../../auth/guards/roles.guard';
|
||||||
@@ -34,37 +29,6 @@ export class EquipmentController {
|
|||||||
return this.equipmentService.findAll(query, response);
|
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')
|
@Roles('viewer', 'editor', 'admin')
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
|
|||||||
@@ -11,12 +11,9 @@ import type { Express } from 'express';
|
|||||||
import { decodeUtf8FilenameFromMultipart } from '../../common/multipart-filename';
|
import { decodeUtf8FilenameFromMultipart } from '../../common/multipart-filename';
|
||||||
import { setListHeaders } from '../../common/http';
|
import { setListHeaders } from '../../common/http';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import type { StoredAttachmentMeta } from '../../storage/storage.service';
|
|
||||||
import { StorageService } from '../../storage/storage.service';
|
import { StorageService } from '../../storage/storage.service';
|
||||||
import type { Readable } from 'node:stream';
|
|
||||||
import { CreateEquipmentDto } from './dto/create-equipment.dto';
|
import { CreateEquipmentDto } from './dto/create-equipment.dto';
|
||||||
import { UpdateEquipmentDto } from './dto/update-equipment.dto';
|
import { UpdateEquipmentDto } from './dto/update-equipment.dto';
|
||||||
import { isEquipmentAttachmentStored } from './attachment.types';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EquipmentService {
|
export class EquipmentService {
|
||||||
@@ -113,118 +110,10 @@ export class EquipmentService {
|
|||||||
if (!item) {
|
if (!item) {
|
||||||
throw new NotFoundException(`Equipment ${id} not found`);
|
throw new NotFoundException(`Equipment ${id} not found`);
|
||||||
}
|
}
|
||||||
await this.deleteStoredAttachmentIfAny(item);
|
|
||||||
const deleted = await this.prisma.equipment.delete({ where: { id } });
|
const deleted = await this.prisma.equipment.delete({ where: { id } });
|
||||||
return this.toRecord(deleted);
|
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) {
|
private async toRecord(item: Equipment) {
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@@ -233,31 +122,6 @@ export class EquipmentService {
|
|||||||
dateOfInspection: item.dateOfInspection?.toISOString() ?? null,
|
dateOfInspection: item.dateOfInspection?.toISOString() ?? null,
|
||||||
commissionedAt: item.commissionedAt?.toISOString() ?? null,
|
commissionedAt: item.commissionedAt?.toISOString() ?? null,
|
||||||
status: item.status,
|
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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,14 @@ export class StorageService {
|
|||||||
const safe = sanitizeFileName(originalName);
|
const safe = sanitizeFileName(originalName);
|
||||||
return `${prefix}/${equipmentId}/${randomUUID()}-${safe}`;
|
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 {
|
function sanitizeFileName(name: string): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user