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}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
commissionedAt DateTime?
|
||||
status EquipmentStatus @default(Active)
|
||||
/// JSON: { objectKey, originalFileName, contentType, sizeBytes } — файл в MinIO/S3
|
||||
attachment Json?
|
||||
changeEquipmentStatuses ChangeEquipmentStatus[]
|
||||
}
|
||||
|
||||
@@ -33,4 +31,6 @@ model ChangeEquipmentStatus {
|
||||
number String?
|
||||
date DateTime
|
||||
responsible String?
|
||||
/// JSON: [{ id, objectKey, originalFileName, contentType, sizeBytes }] — файлы в MinIO/S3
|
||||
attachments Json?
|
||||
}
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { EquipmentStatus, Prisma } from '@prisma/client';
|
||||
import { Response } from 'express';
|
||||
import type { Express } from 'express';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Readable } from 'node:stream';
|
||||
import { setListHeaders } from '../../common/http';
|
||||
import { decodeUtf8FilenameFromMultipart } from '../../common/multipart-filename';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { StorageService } from '../../storage/storage.service';
|
||||
import type { StoredAttachmentMeta } from '../equipment/attachment.types';
|
||||
import { isStoredAttachmentMeta } from '../equipment/attachment.types';
|
||||
import { CreateChangeEquipmentStatusDto } from './dto/create-change-equipment-status.dto';
|
||||
import { UpdateChangeEquipmentStatusDto } from './dto/update-change-equipment-status.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ChangeEquipmentStatusService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly storage: StorageService,
|
||||
) {}
|
||||
|
||||
async findAll(query: Record<string, unknown>, response: Response) {
|
||||
const start = Number(query._start ?? 0);
|
||||
@@ -135,6 +150,123 @@ export class ChangeEquipmentStatusService {
|
||||
return this.toRecord(deleted);
|
||||
}
|
||||
|
||||
async uploadAttachment(id: string, file: Express.Multer.File | undefined) {
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException('File is required (multipart field "file").');
|
||||
}
|
||||
if (!this.storage.isConfigured()) {
|
||||
throw new ServiceUnavailableException(
|
||||
'Object storage is not configured (set S3_* environment variables).',
|
||||
);
|
||||
}
|
||||
|
||||
const item = await this.prisma.changeEquipmentStatus.findUnique({
|
||||
where: { id },
|
||||
include: { equipment: true },
|
||||
});
|
||||
if (!item) {
|
||||
throw new NotFoundException(`ChangeEquipmentStatus ${id} not found`);
|
||||
}
|
||||
|
||||
const decodedOriginalName = decodeUtf8FilenameFromMultipart(file.originalname);
|
||||
const objectKey = this.storage.buildStatusChangeObjectKey(id, decodedOriginalName);
|
||||
const meta: StoredAttachmentMeta = {
|
||||
id: randomUUID(),
|
||||
objectKey,
|
||||
originalFileName: decodedOriginalName,
|
||||
contentType: file.mimetype || 'application/octet-stream',
|
||||
sizeBytes: file.size,
|
||||
};
|
||||
|
||||
await this.storage.putObject(objectKey, file.buffer, meta.contentType);
|
||||
|
||||
const current = this.parseAttachments(item.attachments);
|
||||
const next = [...current, meta];
|
||||
|
||||
const updated = await this.prisma.changeEquipmentStatus.update({
|
||||
where: { id },
|
||||
data: { attachments: next as unknown as Prisma.InputJsonValue },
|
||||
include: { equipment: true },
|
||||
});
|
||||
|
||||
return this.toRecord(updated);
|
||||
}
|
||||
|
||||
async removeAttachment(id: string, attachmentId: string) {
|
||||
if (!this.storage.isConfigured()) {
|
||||
throw new ServiceUnavailableException(
|
||||
'Object storage is not configured (set S3_* environment variables).',
|
||||
);
|
||||
}
|
||||
|
||||
const item = await this.prisma.changeEquipmentStatus.findUnique({
|
||||
where: { id },
|
||||
include: { equipment: true },
|
||||
});
|
||||
if (!item) {
|
||||
throw new NotFoundException(`ChangeEquipmentStatus ${id} not found`);
|
||||
}
|
||||
|
||||
const current = this.parseAttachments(item.attachments);
|
||||
const found = current.find((a) => a.id === attachmentId);
|
||||
if (!found) {
|
||||
throw new NotFoundException(`Вложение ${attachmentId} не найдено`);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.storage.deleteObject(found.objectKey);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
|
||||
const next = current.filter((a) => a.id !== attachmentId);
|
||||
const updated = await this.prisma.changeEquipmentStatus.update({
|
||||
where: { id },
|
||||
data: { attachments: next.length ? (next as unknown as Prisma.InputJsonValue) : Prisma.JsonNull },
|
||||
include: { equipment: true },
|
||||
});
|
||||
|
||||
return this.toRecord(updated);
|
||||
}
|
||||
|
||||
async getAttachmentDownloadStream(
|
||||
id: string,
|
||||
attachmentId: string,
|
||||
): Promise<{
|
||||
stream: Readable;
|
||||
contentType: string;
|
||||
fileName: string;
|
||||
contentLength?: number;
|
||||
}> {
|
||||
if (!this.storage.isConfigured()) {
|
||||
throw new ServiceUnavailableException(
|
||||
'Object storage is not configured (set S3_* environment variables).',
|
||||
);
|
||||
}
|
||||
|
||||
const item = await this.prisma.changeEquipmentStatus.findUnique({ where: { id } });
|
||||
if (!item) {
|
||||
throw new NotFoundException(`ChangeEquipmentStatus ${id} not found`);
|
||||
}
|
||||
|
||||
const current = this.parseAttachments(item.attachments);
|
||||
const found = current.find((a) => a.id === attachmentId);
|
||||
if (!found) {
|
||||
throw new NotFoundException(`Вложение ${attachmentId} не найдено`);
|
||||
}
|
||||
|
||||
const fileName = decodeUtf8FilenameFromMultipart(found.originalFileName) || 'file';
|
||||
const { stream, contentType, contentLength } = await this.storage.getObjectStream(found.objectKey);
|
||||
const len = typeof contentLength === 'bigint' ? Number(contentLength) : contentLength;
|
||||
|
||||
return {
|
||||
stream,
|
||||
contentType: found.contentType || contentType,
|
||||
fileName,
|
||||
contentLength: len,
|
||||
};
|
||||
}
|
||||
|
||||
private toRecord(item: {
|
||||
id: string;
|
||||
equipmentId: string | null;
|
||||
@@ -143,6 +275,7 @@ export class ChangeEquipmentStatusService {
|
||||
date: Date;
|
||||
responsible: string | null;
|
||||
equipment?: { id: string; name: string } | null;
|
||||
attachments?: Prisma.JsonValue | null;
|
||||
}) {
|
||||
return {
|
||||
id: item.id,
|
||||
@@ -157,6 +290,28 @@ export class ChangeEquipmentStatusService {
|
||||
name: item.equipment.name,
|
||||
}
|
||||
: null,
|
||||
attachments: this.serializeAttachments(item.attachments),
|
||||
};
|
||||
}
|
||||
|
||||
private parseAttachments(raw: Prisma.JsonValue | null | undefined): StoredAttachmentMeta[] {
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
return raw.filter(isStoredAttachmentMeta);
|
||||
}
|
||||
|
||||
private serializeAttachments(raw: Prisma.JsonValue | null | undefined) {
|
||||
const items = this.parseAttachments(raw);
|
||||
return items.map((a) => ({
|
||||
id: a.id,
|
||||
originalFileName: decodeUtf8FilenameFromMultipart(a.originalFileName),
|
||||
contentType: a.contentType,
|
||||
sizeBytes: a.sizeBytes,
|
||||
downloadUrl: null as string | null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,14 @@ import {
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
StreamableFile,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Response } from 'express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { Roles } from '../../auth/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../../auth/guards/roles.guard';
|
||||
@@ -35,6 +40,40 @@ export class EquipmentStatusChangeController {
|
||||
return this.equipmentStatusChangeService.findOne(id);
|
||||
}
|
||||
|
||||
@Roles('editor', 'admin')
|
||||
@Post(':id/attachments')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 50 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
uploadAttachment(@Param('id') id: string, @UploadedFile() file: Express.Multer.File) {
|
||||
return this.equipmentStatusChangeService.uploadAttachment(id, file);
|
||||
}
|
||||
|
||||
@Roles('editor', 'admin')
|
||||
@Delete(':id/attachments/:attachmentId')
|
||||
removeAttachment(@Param('id') id: string, @Param('attachmentId') attachmentId: string) {
|
||||
return this.equipmentStatusChangeService.removeAttachment(id, attachmentId);
|
||||
}
|
||||
|
||||
@Roles('viewer', 'editor', 'admin')
|
||||
@Get(':id/attachments/:attachmentId/download')
|
||||
async downloadAttachment(
|
||||
@Param('id') id: string,
|
||||
@Param('attachmentId') attachmentId: string,
|
||||
): Promise<StreamableFile> {
|
||||
const { stream, contentType, fileName, contentLength } =
|
||||
await this.equipmentStatusChangeService.getAttachmentDownloadStream(id, attachmentId);
|
||||
const encoded = encodeURIComponent(fileName);
|
||||
return new StreamableFile(stream, {
|
||||
type: contentType,
|
||||
disposition: `attachment; filename*=UTF-8''${encoded}`,
|
||||
length: contentLength,
|
||||
});
|
||||
}
|
||||
|
||||
@Roles('editor', 'admin')
|
||||
@Post()
|
||||
create(@Body() dto: CreateChangeEquipmentStatusDto) {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { StorageModule } from '../../storage/storage.module';
|
||||
import { ChangeEquipmentStatusService } from '../change-equipment-status/change-equipment-status.service';
|
||||
import { EquipmentStatusChangeController } from './equipment-status-change.controller';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
imports: [AuthModule, StorageModule],
|
||||
controllers: [EquipmentStatusChangeController],
|
||||
providers: [ChangeEquipmentStatusService],
|
||||
})
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
export type EquipmentAttachmentStored = {
|
||||
export type StoredAttachmentMeta = {
|
||||
id: string;
|
||||
objectKey: string;
|
||||
originalFileName: string;
|
||||
contentType: string;
|
||||
sizeBytes: number;
|
||||
};
|
||||
|
||||
export function isEquipmentAttachmentStored(value: unknown): value is EquipmentAttachmentStored {
|
||||
export function isStoredAttachmentMeta(value: unknown): value is StoredAttachmentMeta {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const o = value as Record<string, unknown>;
|
||||
return typeof o.objectKey === 'string' && o.objectKey.length > 0;
|
||||
return (
|
||||
typeof o.id === 'string' &&
|
||||
o.id.length > 0 &&
|
||||
typeof o.objectKey === 'string' &&
|
||||
o.objectKey.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,14 +8,9 @@ import {
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
StreamableFile,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Response } from 'express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { Roles } from '../../auth/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../../auth/guards/roles.guard';
|
||||
@@ -34,37 +29,6 @@ export class EquipmentController {
|
||||
return this.equipmentService.findAll(query, response);
|
||||
}
|
||||
|
||||
@Roles('editor', 'admin')
|
||||
@Post(':id/attachment')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 50 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
uploadAttachment(@Param('id') id: string, @UploadedFile() file: Express.Multer.File) {
|
||||
return this.equipmentService.uploadAttachment(id, file);
|
||||
}
|
||||
|
||||
@Roles('editor', 'admin')
|
||||
@Delete(':id/attachment')
|
||||
removeAttachment(@Param('id') id: string) {
|
||||
return this.equipmentService.removeAttachment(id);
|
||||
}
|
||||
|
||||
@Roles('viewer', 'editor', 'admin')
|
||||
@Get(':id/attachment/download')
|
||||
async downloadAttachment(@Param('id') id: string): Promise<StreamableFile> {
|
||||
const { stream, contentType, fileName, contentLength } =
|
||||
await this.equipmentService.getAttachmentDownloadStream(id);
|
||||
const encoded = encodeURIComponent(fileName);
|
||||
return new StreamableFile(stream, {
|
||||
type: contentType,
|
||||
disposition: `attachment; filename*=UTF-8''${encoded}`,
|
||||
length: contentLength,
|
||||
});
|
||||
}
|
||||
|
||||
@Roles('viewer', 'editor', 'admin')
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
|
||||
@@ -11,12 +11,9 @@ import type { Express } from 'express';
|
||||
import { decodeUtf8FilenameFromMultipart } from '../../common/multipart-filename';
|
||||
import { setListHeaders } from '../../common/http';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import type { StoredAttachmentMeta } from '../../storage/storage.service';
|
||||
import { StorageService } from '../../storage/storage.service';
|
||||
import type { Readable } from 'node:stream';
|
||||
import { CreateEquipmentDto } from './dto/create-equipment.dto';
|
||||
import { UpdateEquipmentDto } from './dto/update-equipment.dto';
|
||||
import { isEquipmentAttachmentStored } from './attachment.types';
|
||||
|
||||
@Injectable()
|
||||
export class EquipmentService {
|
||||
@@ -113,118 +110,10 @@ export class EquipmentService {
|
||||
if (!item) {
|
||||
throw new NotFoundException(`Equipment ${id} not found`);
|
||||
}
|
||||
await this.deleteStoredAttachmentIfAny(item);
|
||||
const deleted = await this.prisma.equipment.delete({ where: { id } });
|
||||
return this.toRecord(deleted);
|
||||
}
|
||||
|
||||
async uploadAttachment(id: string, file: Express.Multer.File | undefined) {
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException('File is required (multipart field "file").');
|
||||
}
|
||||
if (!this.storage.isConfigured()) {
|
||||
throw new ServiceUnavailableException(
|
||||
'Object storage is not configured (set S3_* environment variables).',
|
||||
);
|
||||
}
|
||||
|
||||
const item = await this.prisma.equipment.findUnique({ where: { id } });
|
||||
if (!item) {
|
||||
throw new NotFoundException(`Equipment ${id} not found`);
|
||||
}
|
||||
|
||||
await this.deleteStoredAttachmentIfAny(item);
|
||||
|
||||
const decodedOriginalName = decodeUtf8FilenameFromMultipart(file.originalname);
|
||||
const objectKey = this.storage.buildEquipmentObjectKey(id, decodedOriginalName);
|
||||
const meta: StoredAttachmentMeta = {
|
||||
objectKey,
|
||||
originalFileName: decodedOriginalName,
|
||||
contentType: file.mimetype || 'application/octet-stream',
|
||||
sizeBytes: file.size,
|
||||
};
|
||||
|
||||
await this.storage.putObject(objectKey, file.buffer, meta.contentType);
|
||||
|
||||
const updated = await this.prisma.equipment.update({
|
||||
where: { id },
|
||||
data: { attachment: meta as unknown as Prisma.InputJsonValue },
|
||||
});
|
||||
|
||||
return this.toRecord(updated);
|
||||
}
|
||||
|
||||
async getAttachmentDownloadStream(id: string): Promise<{
|
||||
stream: Readable;
|
||||
contentType: string;
|
||||
fileName: string;
|
||||
contentLength?: number;
|
||||
}> {
|
||||
if (!this.storage.isConfigured()) {
|
||||
throw new ServiceUnavailableException(
|
||||
'Object storage is not configured (set S3_* environment variables).',
|
||||
);
|
||||
}
|
||||
|
||||
const item = await this.prisma.equipment.findUnique({ where: { id } });
|
||||
if (!item) {
|
||||
throw new NotFoundException(`Equipment ${id} not found`);
|
||||
}
|
||||
if (!isEquipmentAttachmentStored(item.attachment)) {
|
||||
throw new NotFoundException(`Вложение для оборудования ${id} не найдено`);
|
||||
}
|
||||
|
||||
const raw = item.attachment;
|
||||
const fileName = decodeUtf8FilenameFromMultipart(raw.originalFileName) || 'file';
|
||||
const { stream, contentType, contentLength } = await this.storage.getObjectStream(raw.objectKey);
|
||||
const len =
|
||||
typeof contentLength === 'bigint' ? Number(contentLength) : contentLength;
|
||||
|
||||
return {
|
||||
stream,
|
||||
contentType: raw.contentType || contentType,
|
||||
fileName,
|
||||
contentLength: len,
|
||||
};
|
||||
}
|
||||
|
||||
async removeAttachment(id: string) {
|
||||
if (!this.storage.isConfigured()) {
|
||||
throw new ServiceUnavailableException(
|
||||
'Object storage is not configured (set S3_* environment variables).',
|
||||
);
|
||||
}
|
||||
|
||||
const item = await this.prisma.equipment.findUnique({ where: { id } });
|
||||
if (!item) {
|
||||
throw new NotFoundException(`Equipment ${id} not found`);
|
||||
}
|
||||
|
||||
await this.deleteStoredAttachmentIfAny(item);
|
||||
|
||||
const updated = await this.prisma.equipment.update({
|
||||
where: { id },
|
||||
data: { attachment: Prisma.JsonNull },
|
||||
});
|
||||
|
||||
return this.toRecord(updated);
|
||||
}
|
||||
|
||||
private async deleteStoredAttachmentIfAny(item: Equipment) {
|
||||
const raw = item.attachment;
|
||||
if (!isEquipmentAttachmentStored(raw)) {
|
||||
return;
|
||||
}
|
||||
if (!this.storage.isConfigured()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.storage.deleteObject(raw.objectKey);
|
||||
} catch {
|
||||
// best-effort: DB will still clear / overwrite
|
||||
}
|
||||
}
|
||||
|
||||
private async toRecord(item: Equipment) {
|
||||
return {
|
||||
id: item.id,
|
||||
@@ -233,31 +122,6 @@ export class EquipmentService {
|
||||
dateOfInspection: item.dateOfInspection?.toISOString() ?? null,
|
||||
commissionedAt: item.commissionedAt?.toISOString() ?? null,
|
||||
status: item.status,
|
||||
attachment: await this.serializeAttachment(item.attachment),
|
||||
};
|
||||
}
|
||||
|
||||
private async serializeAttachment(raw: Prisma.JsonValue | null) {
|
||||
if (raw === null || raw === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (!isEquipmentAttachmentStored(raw)) {
|
||||
return null;
|
||||
}
|
||||
let downloadUrl: string | null = null;
|
||||
if (this.storage.isConfigured()) {
|
||||
try {
|
||||
downloadUrl = await this.storage.getDownloadUrl(raw.objectKey);
|
||||
} catch {
|
||||
downloadUrl = null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
objectKey: raw.objectKey,
|
||||
originalFileName: decodeUtf8FilenameFromMultipart(raw.originalFileName),
|
||||
contentType: raw.contentType,
|
||||
sizeBytes: raw.sizeBytes,
|
||||
downloadUrl,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +126,14 @@ export class StorageService {
|
||||
const safe = sanitizeFileName(originalName);
|
||||
return `${prefix}/${equipmentId}/${randomUUID()}-${safe}`;
|
||||
}
|
||||
|
||||
buildStatusChangeObjectKey(statusChangeId: string, originalName: string): string {
|
||||
const prefix = (process.env.S3_OBJECT_PREFIX ?? 'toir-light/equipment')
|
||||
.replace(/^\/+|\/+$/g, '')
|
||||
.replace(/equipment$/, 'status-changes');
|
||||
const safe = sanitizeFileName(originalName);
|
||||
return `${prefix}/${statusChangeId}/${randomUUID()}-${safe}`;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeFileName(name: string): string {
|
||||
|
||||
Reference in New Issue
Block a user