Split archive journal to 2 sub-modules by status. Add possibility to upload file for equipment. Change date fields for equipment.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
VITE_API_URL=http://localhost:3000
|
||||
VITE_API_URL=/api
|
||||
VITE_KEYCLOAK_URL=https://sso.greact.ru
|
||||
VITE_KEYCLOAK_REALM=toir
|
||||
VITE_KEYCLOAK_CLIENT_ID=toir-frontend
|
||||
|
||||
644
client/package-lock.json
generated
644
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
export const env = {
|
||||
apiUrl: import.meta.env.VITE_API_URL ?? 'http://localhost:3000',
|
||||
apiUrl: import.meta.env.VITE_API_URL ?? '/api',
|
||||
keycloakUrl: import.meta.env.VITE_KEYCLOAK_URL ?? 'https://sso.greact.ru',
|
||||
keycloakRealm: import.meta.env.VITE_KEYCLOAK_REALM ?? 'toir',
|
||||
keycloakClientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID ?? 'toir-frontend',
|
||||
|
||||
@@ -10,6 +10,8 @@ const customRu = {
|
||||
serialNumber: 'Серийный номер',
|
||||
dateOfInspection: 'Дата поверки',
|
||||
commissionedAt: 'Дата изготовления',
|
||||
installationDate: 'Дата установки',
|
||||
writeOffDate: 'Дата списания',
|
||||
status: 'Статус',
|
||||
},
|
||||
},
|
||||
@@ -28,7 +30,7 @@ const customRu = {
|
||||
},
|
||||
toir: {
|
||||
actions: {
|
||||
addFiles: 'Добавить файлы…',
|
||||
addFiles: 'Добавить файлы...',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -41,4 +43,3 @@ export const messagesRu = {
|
||||
...customRu.resources,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
131
client/src/layout/ToirMenu.css
Normal file
131
client/src/layout/ToirMenu.css
Normal file
@@ -0,0 +1,131 @@
|
||||
.toir-menu-item.MuiMenuItem-root,
|
||||
.toir-menu-group.MuiListItemButton-root {
|
||||
min-height: var(--toir-menu-item-height);
|
||||
gap: var(--toir-menu-item-gap);
|
||||
padding: var(--toir-menu-item-padding-y) var(--toir-menu-item-padding-x);
|
||||
color: var(--toir-menu-text);
|
||||
border-radius: 0 var(--toir-menu-radius) var(--toir-menu-radius) 0;
|
||||
transition:
|
||||
background-color var(--toir-menu-transition),
|
||||
color var(--toir-menu-transition);
|
||||
}
|
||||
|
||||
.toir-menu-item.MuiMenuItem-root:hover,
|
||||
.toir-menu-item.RaMenuItemLink-active,
|
||||
.toir-menu-group.MuiListItemButton-root:hover,
|
||||
.toir-menu-group.Mui-selected {
|
||||
background-color: var(--toir-menu-active-bg);
|
||||
color: var(--toir-menu-text);
|
||||
}
|
||||
|
||||
.toir-menu-item.MuiMenuItem-root:hover,
|
||||
.toir-menu-group.MuiListItemButton-root:hover {
|
||||
background-color: var(--toir-menu-hover-bg);
|
||||
}
|
||||
|
||||
.toir-menu-item.MuiMenuItem-root .MuiListItemIcon-root {
|
||||
min-width: var(--toir-menu-icon-slot);
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.toir-menu-item.MuiMenuItem-root > .MuiTypography-root,
|
||||
.toir-menu-group .MuiListItemText-primary {
|
||||
font-size: var(--toir-menu-font-size);
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.toir-menu-group__chevron {
|
||||
display: inline-flex;
|
||||
width: var(--toir-menu-chevron-size);
|
||||
height: var(--toir-menu-chevron-size);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.toir-menu-group__chevron span {
|
||||
width: var(--toir-menu-chevron-stroke-size);
|
||||
height: var(--toir-menu-chevron-stroke-size);
|
||||
border-right: var(--toir-menu-guide-width) solid currentColor;
|
||||
border-bottom: var(--toir-menu-guide-width) solid currentColor;
|
||||
transform: rotate(45deg) translate(-2px, -2px);
|
||||
transition: transform var(--toir-menu-transition);
|
||||
}
|
||||
|
||||
.toir-menu-group__chevron.is-open span {
|
||||
transform: rotate(225deg) translate(-2px, -2px);
|
||||
}
|
||||
|
||||
.toir-menu-subtree {
|
||||
position: relative;
|
||||
margin-left: var(--toir-menu-item-padding-x);
|
||||
padding: 4px 0 var(--toir-menu-item-padding-y) var(--toir-menu-subtree-offset);
|
||||
}
|
||||
|
||||
.toir-menu-subtree::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: calc(var(--toir-menu-subitem-height) / 2 + 6px);
|
||||
left: 0;
|
||||
border-left: var(--toir-menu-guide-width) dashed var(--toir-menu-guide);
|
||||
}
|
||||
|
||||
.toir-menu-subitem.MuiMenuItem-root {
|
||||
position: relative;
|
||||
min-height: var(--toir-menu-subitem-height);
|
||||
padding: 8px var(--toir-menu-subitem-padding-x) 8px var(--toir-menu-subitem-label-offset);
|
||||
color: var(--toir-menu-text-muted);
|
||||
border-radius: 0 var(--toir-menu-radius) var(--toir-menu-radius) 0;
|
||||
white-space: normal;
|
||||
transition:
|
||||
background-color var(--toir-menu-transition),
|
||||
color var(--toir-menu-transition);
|
||||
}
|
||||
|
||||
.toir-menu-subitem.MuiMenuItem-root::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: calc(var(--toir-menu-subtree-offset) * -1);
|
||||
top: 50%;
|
||||
width: var(--toir-menu-subtree-offset);
|
||||
border-top: var(--toir-menu-guide-width) dashed var(--toir-menu-guide);
|
||||
}
|
||||
|
||||
.toir-menu-subitem.MuiMenuItem-root::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: calc(var(--toir-menu-marker-size) / -2);
|
||||
top: calc(50% - var(--toir-menu-marker-size) / 2);
|
||||
width: var(--toir-menu-marker-size);
|
||||
height: var(--toir-menu-marker-size);
|
||||
box-sizing: border-box;
|
||||
background: var(--toir-menu-marker-bg);
|
||||
border: var(--toir-menu-guide-width) solid var(--toir-menu-guide-strong);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.toir-menu-subitem.MuiMenuItem-root .MuiListItemIcon-root {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toir-menu-subitem.MuiMenuItem-root .MuiListItemText-primary {
|
||||
font-size: var(--toir-menu-subitem-font-size);
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.toir-menu-subitem.MuiMenuItem-root > .MuiTypography-root {
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.toir-menu-subitem.MuiMenuItem-root:hover,
|
||||
.toir-menu-subitem.RaMenuItemLink-active {
|
||||
background-color: var(--toir-menu-hover-bg);
|
||||
color: var(--toir-menu-text);
|
||||
}
|
||||
@@ -1,22 +1,66 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Box, Collapse, ListItemButton, ListItemText } from '@mui/material';
|
||||
import { Menu, MenuItemLink } from 'react-admin';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import {
|
||||
EQUIPMENT_SIDEBAR_ARCHIVE_FILTER,
|
||||
EQUIPMENT_SIDEBAR_IN_WORK_FILTER,
|
||||
STATUS_CHANGES_CLOSING_FILTER,
|
||||
STATUS_CHANGES_DOWNTIME_FILTER,
|
||||
equipmentListSearch,
|
||||
statusChangesListSearch,
|
||||
} from './toirMenuLinks';
|
||||
import './ToirMenu.css';
|
||||
|
||||
export function ToirMenu() {
|
||||
const location = useLocation();
|
||||
const isStatusChangesRoute = location.pathname.startsWith('/status-changes');
|
||||
const [statusChangesOpen, setStatusChangesOpen] = useState(false);
|
||||
|
||||
const statusChangesLinks = useMemo(
|
||||
() => ({
|
||||
downtime: `/status-changes?${statusChangesListSearch(STATUS_CHANGES_DOWNTIME_FILTER)}`,
|
||||
closing: `/status-changes?${statusChangesListSearch(STATUS_CHANGES_CLOSING_FILTER)}`,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Menu>
|
||||
<MenuItemLink
|
||||
className="toir-menu-item"
|
||||
to={`/equipment?${equipmentListSearch(EQUIPMENT_SIDEBAR_IN_WORK_FILTER)}`}
|
||||
primaryText="В работе"
|
||||
/>
|
||||
<MenuItemLink
|
||||
className="toir-menu-item"
|
||||
to={`/equipment?${equipmentListSearch(EQUIPMENT_SIDEBAR_ARCHIVE_FILTER)}`}
|
||||
primaryText="Архив"
|
||||
/>
|
||||
<MenuItemLink to="/status-changes" primaryText="Журнал актов" />
|
||||
<ListItemButton
|
||||
className="toir-menu-group"
|
||||
selected={isStatusChangesRoute}
|
||||
onClick={() => setStatusChangesOpen((open) => !open)}
|
||||
>
|
||||
<ListItemText primary="Журнал актов" />
|
||||
<span className={`toir-menu-group__chevron${statusChangesOpen ? ' is-open' : ''}`} aria-hidden>
|
||||
<span />
|
||||
</span>
|
||||
</ListItemButton>
|
||||
<Collapse in={statusChangesOpen} timeout="auto" unmountOnExit>
|
||||
<Box className="toir-menu-subtree">
|
||||
<MenuItemLink
|
||||
className="toir-menu-subitem"
|
||||
to={statusChangesLinks.downtime}
|
||||
primaryText="Простои"
|
||||
/>
|
||||
<MenuItemLink
|
||||
className="toir-menu-subitem"
|
||||
to={statusChangesLinks.closing}
|
||||
primaryText="Закрывающие документы"
|
||||
/>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,3 +11,16 @@ export const EQUIPMENT_SIDEBAR_IN_WORK_FILTER = { status: ['Active'] };
|
||||
|
||||
/** «Архив» → списанное оборудование (WriteOff). */
|
||||
export const EQUIPMENT_SIDEBAR_ARCHIVE_FILTER = { status: ['WriteOff'] };
|
||||
|
||||
/** Параметры списка журнала актов в формате react-admin. */
|
||||
export function statusChangesListSearch(filter: Record<string, unknown>): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set('filter', JSON.stringify(filter));
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
/** «Простои» → акты, переводящие оборудование в ремонт. */
|
||||
export const STATUS_CHANGES_DOWNTIME_FILTER = { newStatus: ['Repair'] };
|
||||
|
||||
/** «Закрывающие документы» → акты списания оборудования. */
|
||||
export const STATUS_CHANGES_CLOSING_FILTER = { newStatus: ['WriteOff'] };
|
||||
|
||||
@@ -23,6 +23,7 @@ type EquipmentRecord = {
|
||||
serialNumber: string;
|
||||
dateOfInspection: string | null;
|
||||
commissionedAt: string | null;
|
||||
installationDate: string | null;
|
||||
};
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
@@ -171,7 +172,7 @@ export function EmbeddedActiveEquipmentPage() {
|
||||
<TableRow>
|
||||
<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>
|
||||
</TableHead>
|
||||
@@ -180,7 +181,7 @@ export function EmbeddedActiveEquipmentPage() {
|
||||
<TableRow key={item.id} hover>
|
||||
<TableCell>{item.name}</TableCell>
|
||||
<TableCell>{item.serialNumber}</TableCell>
|
||||
<TableCell>{formatDate(item.commissionedAt)}</TableCell>
|
||||
<TableCell>{formatDate(item.installationDate)}</TableCell>
|
||||
<TableCell>{formatDate(item.dateOfInspection)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import {
|
||||
CreateButton,
|
||||
Datagrid,
|
||||
@@ -6,7 +8,6 @@ import {
|
||||
FunctionField,
|
||||
List,
|
||||
ReferenceField,
|
||||
SelectArrayInput,
|
||||
SelectField,
|
||||
TextField,
|
||||
TextInput,
|
||||
@@ -15,10 +16,24 @@ import {
|
||||
import { equipmentStatusChoices } from '../equipment/shared';
|
||||
import { StatusChangeAttachmentLink } from './StatusChangeAttachmentLink';
|
||||
|
||||
function parseStatusChangesFilterFromSearch(search: string): Record<string, unknown> | undefined {
|
||||
const params = new URLSearchParams(search);
|
||||
const raw = params.get('filter');
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const statusFilters = [
|
||||
<TextInput key="q" source="q" label="Поиск" alwaysOn />,
|
||||
<TextInput key="equipmentId" source="equipmentId" label="Оборудование" />,
|
||||
<SelectArrayInput key="newStatus" source="newStatus" label="Новый статус" choices={equipmentStatusChoices} />,
|
||||
];
|
||||
|
||||
const ListActions = () => (
|
||||
@@ -29,16 +44,29 @@ const ListActions = () => (
|
||||
);
|
||||
|
||||
export function ChangeEquipmentStatusList() {
|
||||
const location = useLocation();
|
||||
const filterDefaultValues = useMemo(
|
||||
() => parseStatusChangesFilterFromSearch(location.search),
|
||||
[location.search],
|
||||
);
|
||||
const listKey = `${location.pathname}${location.search}`;
|
||||
|
||||
return (
|
||||
<List filters={statusFilters} actions={<ListActions />} sort={{ field: 'date', order: 'DESC' }}>
|
||||
<List
|
||||
key={listKey}
|
||||
filters={statusFilters}
|
||||
actions={<ListActions />}
|
||||
sort={{ field: 'date', order: 'DESC' }}
|
||||
filterDefaultValues={filterDefaultValues}
|
||||
>
|
||||
<Datagrid rowClick="show">
|
||||
<TextField source="number" />
|
||||
<DateField source="date" />
|
||||
<TextField source="number" label="Номер" />
|
||||
<DateField source="date" label="Дата" />
|
||||
<ReferenceField source="equipmentId" reference="equipment" link="show">
|
||||
<TextField source="name" />
|
||||
<TextField source="name" label="Оборудование" />
|
||||
</ReferenceField>
|
||||
<SelectField source="newStatus" choices={equipmentStatusChoices} />
|
||||
<TextField source="responsible" />
|
||||
<SelectField source="newStatus" label="Новый статус" choices={equipmentStatusChoices} />
|
||||
<TextField source="responsible" label="Ответственный" />
|
||||
<FunctionField
|
||||
label="Файлы"
|
||||
render={(record: {
|
||||
|
||||
31
client/src/resources/equipment/EquipmentAttachmentLink.tsx
Normal file
31
client/src/resources/equipment/EquipmentAttachmentLink.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Link } from '@mui/material';
|
||||
import { useNotify } from 'react-admin';
|
||||
import { downloadEquipmentAttachmentFile } from './attachmentDownload';
|
||||
|
||||
type Props = {
|
||||
equipmentId: string;
|
||||
attachmentId: string;
|
||||
fileName: string;
|
||||
};
|
||||
|
||||
export function EquipmentAttachmentLink({ equipmentId, attachmentId, fileName }: Props) {
|
||||
const notify = useNotify();
|
||||
const label = fileName.trim() || 'Скачать';
|
||||
|
||||
return (
|
||||
<Link
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void downloadEquipmentAttachmentFile(equipmentId, attachmentId, label).catch(() =>
|
||||
notify('Не удалось скачать файл', { type: 'warning' }),
|
||||
);
|
||||
}}
|
||||
sx={{ cursor: 'pointer', textAlign: 'left', verticalAlign: 'inherit' }}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
172
client/src/resources/equipment/EquipmentAttachmentsInput.tsx
Normal file
172
client/src/resources/equipment/EquipmentAttachmentsInput.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { Button, 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 { EquipmentAttachmentLink } from './EquipmentAttachmentLink';
|
||||
|
||||
export type EquipmentAttachmentValue = {
|
||||
id: string;
|
||||
originalFileName?: 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 EquipmentAttachmentsInput() {
|
||||
const record = useRecordContext();
|
||||
const notify = useNotify();
|
||||
const refresh = useRefresh();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const equipmentId = record?.id ? String(record.id) : null;
|
||||
const attachments = useMemo(() => {
|
||||
const raw = (record as { attachments?: EquipmentAttachmentValue | 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 || !equipmentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
await ensureFreshToken();
|
||||
const token = getAccessToken();
|
||||
const url = `${resolveApiBaseUrl()}/equipment/${equipmentId}/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);
|
||||
}
|
||||
},
|
||||
[equipmentId, notify, refresh],
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
async (attachmentId: string) => {
|
||||
if (!equipmentId) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await ensureFreshToken();
|
||||
const token = getAccessToken();
|
||||
const url = `${resolveApiBaseUrl()}/equipment/${equipmentId}/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);
|
||||
}
|
||||
},
|
||||
[equipmentId, notify, refresh],
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack spacing={1.25} sx={{ maxWidth: 680 }}>
|
||||
<Typography component="div" variant="body2" sx={{ fontWeight: 600 }}>
|
||||
Вложения (файлы)
|
||||
</Typography>
|
||||
|
||||
{!equipmentId ? (
|
||||
<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 }}>
|
||||
<EquipmentAttachmentLink
|
||||
equipmentId={equipmentId}
|
||||
attachmentId={att.id}
|
||||
fileName={att.originalFileName ?? 'файл'}
|
||||
/>
|
||||
</Typography>
|
||||
{formatBytes(att.sizeBytes) ? (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatBytes(att.sizeBytes)}
|
||||
</Typography>
|
||||
) : null}
|
||||
</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 || !equipmentId}>
|
||||
Добавить файлы...
|
||||
<input type="file" hidden multiple onChange={upload} />
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,25 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Create, DateInput, SelectInput, SimpleForm, TextInput as RaTextInput } from 'react-admin';
|
||||
import { equipmentStatusChoices } from './shared';
|
||||
|
||||
const equipmentLabels = {
|
||||
dateOfInspection: 'Дата поверки',
|
||||
commissionedAt: 'Дата изготовления',
|
||||
name: 'Название',
|
||||
serialNumber: 'Серийный номер',
|
||||
status: 'Статус',
|
||||
};
|
||||
import {
|
||||
equipmentLabels,
|
||||
equipmentStatusChoices,
|
||||
getEquipmentCreateDefaultValues,
|
||||
getEquipmentPageContextFromSearch,
|
||||
} from './shared';
|
||||
|
||||
export function EquipmentCreate() {
|
||||
const location = useLocation();
|
||||
const pageContext = getEquipmentPageContextFromSearch(location.search);
|
||||
|
||||
return (
|
||||
<Create>
|
||||
<SimpleForm>
|
||||
<DateInput source="dateOfInspection" label={equipmentLabels.dateOfInspection} />
|
||||
<DateInput source="commissionedAt" label={equipmentLabels.commissionedAt} />
|
||||
<SimpleForm defaultValues={getEquipmentCreateDefaultValues(pageContext)}>
|
||||
<RaTextInput source="name" label={equipmentLabels.name} required />
|
||||
<RaTextInput source="serialNumber" label={equipmentLabels.serialNumber} required />
|
||||
<SelectInput source="status" label={equipmentLabels.status} choices={equipmentStatusChoices} required />
|
||||
<DateInput source="dateOfInspection" label={equipmentLabels.dateOfInspection} />
|
||||
<DateInput source="installationDate" label={equipmentLabels.installationDate} />
|
||||
<DateInput source="writeOffDate" label={equipmentLabels.writeOffDate} />
|
||||
</SimpleForm>
|
||||
</Create>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { DateInput, Edit, SelectInput, SimpleForm, TextInput as RaTextInput } from 'react-admin';
|
||||
import { equipmentStatusChoices } from './shared';
|
||||
|
||||
const equipmentLabels = {
|
||||
dateOfInspection: 'Дата поверки',
|
||||
commissionedAt: 'Дата изготовления',
|
||||
name: 'Название',
|
||||
serialNumber: 'Серийный номер',
|
||||
status: 'Статус',
|
||||
};
|
||||
import { EquipmentAttachmentsInput } from './EquipmentAttachmentsInput';
|
||||
import { equipmentLabels, equipmentStatusChoices } from './shared';
|
||||
|
||||
export function EquipmentEdit() {
|
||||
return (
|
||||
@@ -27,8 +20,11 @@ export function EquipmentEdit() {
|
||||
fullWidth
|
||||
required
|
||||
/>
|
||||
<DateInput source="commissionedAt" label={equipmentLabels.commissionedAt} fullWidth />
|
||||
<DateInput source="dateOfInspection" label={equipmentLabels.dateOfInspection} fullWidth />
|
||||
<DateInput source="commissionedAt" label={equipmentLabels.commissionedAt} fullWidth />
|
||||
<DateInput source="installationDate" label={equipmentLabels.installationDate} fullWidth />
|
||||
<DateInput source="writeOffDate" label={equipmentLabels.writeOffDate} fullWidth />
|
||||
<EquipmentAttachmentsInput />
|
||||
</SimpleForm>
|
||||
</Edit>
|
||||
);
|
||||
|
||||
@@ -1,60 +1,171 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import {
|
||||
CreateButton,
|
||||
Datagrid,
|
||||
DateField,
|
||||
FilterButton,
|
||||
FunctionField,
|
||||
List,
|
||||
TextField,
|
||||
TextInput,
|
||||
TopToolbar,
|
||||
} from 'react-admin';
|
||||
|
||||
function parseListFilterFromSearch(search: string): Record<string, unknown> | undefined {
|
||||
const params = new URLSearchParams(search);
|
||||
const raw = params.get('filter');
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
import { EquipmentAttachmentLink } from './EquipmentAttachmentLink';
|
||||
import {
|
||||
equipmentLabels,
|
||||
getEquipmentCreatePath,
|
||||
getEquipmentPageContextFromFilter,
|
||||
parseEquipmentFilterFromSearch,
|
||||
type EquipmentPageContext,
|
||||
} from './shared';
|
||||
|
||||
const equipmentFilters = [
|
||||
<TextInput key="q" source="q" label="Поиск" alwaysOn />,
|
||||
];
|
||||
|
||||
const ListActions = () => (
|
||||
type ListActionsProps = {
|
||||
pageContext: EquipmentPageContext;
|
||||
};
|
||||
|
||||
type EquipmentAttachmentListItem = {
|
||||
id: string;
|
||||
originalFileName?: string | null;
|
||||
};
|
||||
|
||||
type EquipmentWithAttachments = {
|
||||
id: string;
|
||||
attachments?: EquipmentAttachmentListItem[] | null;
|
||||
};
|
||||
|
||||
type EquipmentFilesFieldProps = {
|
||||
source: string;
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
};
|
||||
|
||||
const EquipmentFilesField = (props: EquipmentFilesFieldProps) => (
|
||||
<FunctionField
|
||||
{...props}
|
||||
source="attachments"
|
||||
label="Файлы"
|
||||
sortable={false}
|
||||
render={(record: EquipmentWithAttachments) => {
|
||||
const attachments = Array.isArray(record.attachments) ? record.attachments : [];
|
||||
if (!attachments.length) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const [firstAttachment, ...restAttachments] = attachments;
|
||||
|
||||
return (
|
||||
<span>
|
||||
<EquipmentAttachmentLink
|
||||
equipmentId={record.id}
|
||||
attachmentId={firstAttachment.id}
|
||||
fileName={firstAttachment.originalFileName ?? 'файл'}
|
||||
/>
|
||||
{restAttachments.length ? ` +${restAttachments.length}` : null}
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const ListActions = ({ pageContext }: ListActionsProps) => (
|
||||
<TopToolbar>
|
||||
<CreateButton />
|
||||
<CreateButton to={getEquipmentCreatePath(pageContext)} />
|
||||
<FilterButton />
|
||||
</TopToolbar>
|
||||
);
|
||||
|
||||
export function EquipmentList() {
|
||||
const location = useLocation();
|
||||
const filterDefaultValues = useMemo(() => parseListFilterFromSearch(location.search), [location.search]);
|
||||
const listKey = `${location.pathname}${location.search}`;
|
||||
type EquipmentListLayoutProps = {
|
||||
children: ReactNode;
|
||||
filterDefaultValues?: Record<string, unknown>;
|
||||
listKey: string;
|
||||
pageContext: EquipmentPageContext;
|
||||
};
|
||||
|
||||
function EquipmentListLayout({
|
||||
children,
|
||||
filterDefaultValues,
|
||||
listKey,
|
||||
pageContext,
|
||||
}: EquipmentListLayoutProps) {
|
||||
return (
|
||||
<List
|
||||
key={listKey}
|
||||
filters={equipmentFilters}
|
||||
actions={<ListActions />}
|
||||
actions={<ListActions pageContext={pageContext} />}
|
||||
sort={{ field: 'name', order: 'ASC' }}
|
||||
filterDefaultValues={filterDefaultValues}
|
||||
>
|
||||
<Datagrid rowClick="show">
|
||||
<TextField source="name" />
|
||||
<TextField source="serialNumber" />
|
||||
<DateField source="dateOfInspection" />
|
||||
<DateField source="commissionedAt" />
|
||||
</Datagrid>
|
||||
{children}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
|
||||
type EquipmentStatusListProps = {
|
||||
filterDefaultValues?: Record<string, unknown>;
|
||||
listKey: string;
|
||||
};
|
||||
|
||||
export function EquipmentActiveList({ filterDefaultValues, listKey }: EquipmentStatusListProps) {
|
||||
return (
|
||||
<EquipmentListLayout filterDefaultValues={filterDefaultValues} listKey={listKey} pageContext="active">
|
||||
<Datagrid rowClick="show">
|
||||
<TextField source="name" label={equipmentLabels.name} />
|
||||
<TextField source="serialNumber" label={equipmentLabels.serialNumber} />
|
||||
<DateField source="dateOfInspection" label={equipmentLabels.dateOfInspection} />
|
||||
<DateField source="installationDate" label={equipmentLabels.installationDate} />
|
||||
<EquipmentFilesField source="attachments" label="Файлы" sortable={false} />
|
||||
</Datagrid>
|
||||
</EquipmentListLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export function EquipmentArchiveList({ filterDefaultValues, listKey }: EquipmentStatusListProps) {
|
||||
return (
|
||||
<EquipmentListLayout filterDefaultValues={filterDefaultValues} listKey={listKey} pageContext="archive">
|
||||
<Datagrid rowClick="show">
|
||||
<TextField source="name" label={equipmentLabels.name} />
|
||||
<TextField source="serialNumber" label={equipmentLabels.serialNumber} />
|
||||
<DateField source="installationDate" label={equipmentLabels.installationDate} />
|
||||
<DateField source="writeOffDate" label={equipmentLabels.writeOffDate} />
|
||||
<EquipmentFilesField source="attachments" label="Файлы" sortable={false} />
|
||||
</Datagrid>
|
||||
</EquipmentListLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export function EquipmentDefaultList({ filterDefaultValues, listKey }: EquipmentStatusListProps) {
|
||||
return (
|
||||
<EquipmentListLayout filterDefaultValues={filterDefaultValues} listKey={listKey} pageContext="default">
|
||||
<Datagrid rowClick="show">
|
||||
<TextField source="name" label={equipmentLabels.name} />
|
||||
<TextField source="serialNumber" label={equipmentLabels.serialNumber} />
|
||||
<DateField source="dateOfInspection" label={equipmentLabels.dateOfInspection} />
|
||||
<DateField source="commissionedAt" label={equipmentLabels.commissionedAt} />
|
||||
<DateField source="installationDate" label={equipmentLabels.installationDate} />
|
||||
<DateField source="writeOffDate" label={equipmentLabels.writeOffDate} />
|
||||
</Datagrid>
|
||||
</EquipmentListLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export function EquipmentList() {
|
||||
const location = useLocation();
|
||||
const filterDefaultValues = useMemo(() => parseEquipmentFilterFromSearch(location.search), [location.search]);
|
||||
const pageContext = getEquipmentPageContextFromFilter(filterDefaultValues);
|
||||
const listKey = `${location.pathname}${location.search}`;
|
||||
|
||||
if (pageContext === 'archive') {
|
||||
return <EquipmentArchiveList filterDefaultValues={filterDefaultValues} listKey={listKey} />;
|
||||
}
|
||||
|
||||
if (pageContext === 'active') {
|
||||
return <EquipmentActiveList filterDefaultValues={filterDefaultValues} listKey={listKey} />;
|
||||
}
|
||||
|
||||
return <EquipmentDefaultList filterDefaultValues={filterDefaultValues} listKey={listKey} />;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,44 @@
|
||||
import { DateField, SelectField, Show, SimpleShowLayout, TextField } from 'react-admin';
|
||||
import { equipmentStatusChoices } from './shared';
|
||||
import { DateField, FunctionField, SelectField, Show, SimpleShowLayout, TextField } from 'react-admin';
|
||||
import { EquipmentAttachmentLink } from './EquipmentAttachmentLink';
|
||||
import { equipmentLabels, equipmentStatusChoices } from './shared';
|
||||
|
||||
export function EquipmentShow() {
|
||||
return (
|
||||
<Show>
|
||||
<SimpleShowLayout>
|
||||
<TextField source="id" />
|
||||
<TextField source="name" />
|
||||
<TextField source="serialNumber" />
|
||||
<DateField source="dateOfInspection" />
|
||||
<DateField source="commissionedAt" />
|
||||
<SelectField source="status" choices={equipmentStatusChoices} />
|
||||
<TextField source="name" label={equipmentLabels.name} />
|
||||
<TextField source="serialNumber" label={equipmentLabels.serialNumber} />
|
||||
<DateField source="dateOfInspection" label={equipmentLabels.dateOfInspection} />
|
||||
<DateField source="commissionedAt" label={equipmentLabels.commissionedAt} />
|
||||
<DateField source="installationDate" label={equipmentLabels.installationDate} />
|
||||
<DateField source="writeOffDate" label={equipmentLabels.writeOffDate} />
|
||||
<SelectField source="status" label={equipmentLabels.status} choices={equipmentStatusChoices} />
|
||||
<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}>
|
||||
<EquipmentAttachmentLink
|
||||
equipmentId={record.id}
|
||||
attachmentId={att.id}
|
||||
fileName={att.originalFileName ?? 'файл'}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</SimpleShowLayout>
|
||||
</Show>
|
||||
);
|
||||
|
||||
29
client/src/resources/equipment/attachmentDownload.ts
Normal file
29
client/src/resources/equipment/attachmentDownload.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ensureFreshToken, getAccessToken } from '../../auth/keycloak';
|
||||
import { resolveApiBaseUrl } from '../../lib/apiBase';
|
||||
|
||||
export async function downloadEquipmentAttachmentFile(
|
||||
equipmentId: string,
|
||||
attachmentId: string,
|
||||
suggestedFileName: string,
|
||||
): Promise<void> {
|
||||
await ensureFreshToken();
|
||||
const token = getAccessToken();
|
||||
const url = `${resolveApiBaseUrl()}/equipment/${equipmentId}/attachments/${attachmentId}/download`;
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: token ? { Authorization: `Bearer ${token}`, Accept: '*/*' } : { Accept: '*/*' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Download failed: ${response.status}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = objectUrl;
|
||||
a.download = suggestedFileName || 'download';
|
||||
a.rel = 'noopener';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
@@ -5,6 +5,20 @@ export const equipmentStatusChoices = [
|
||||
{ id: 'WriteOff', name: 'Списано' },
|
||||
];
|
||||
|
||||
export type EquipmentPageContext = 'active' | 'archive' | 'default';
|
||||
|
||||
export const EQUIPMENT_PAGE_CONTEXT_PARAM = 'equipmentPageContext';
|
||||
|
||||
export const equipmentLabels = {
|
||||
dateOfInspection: 'Дата поверки',
|
||||
commissionedAt: 'Дата изготовления',
|
||||
installationDate: 'Дата установки',
|
||||
name: 'Название',
|
||||
serialNumber: 'Серийный номер',
|
||||
status: 'Статус',
|
||||
writeOffDate: 'Дата списания',
|
||||
};
|
||||
|
||||
export const equipmentOptionText = (record?: {
|
||||
name?: string;
|
||||
serialNumber?: string;
|
||||
@@ -16,3 +30,70 @@ export const equipmentOptionText = (record?: {
|
||||
|
||||
return record.name ?? record.serialNumber ?? record.id ?? '';
|
||||
};
|
||||
|
||||
export function parseEquipmentFilterFromSearch(search: string): Record<string, unknown> | undefined {
|
||||
const params = new URLSearchParams(search);
|
||||
const raw = params.get('filter');
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function equipmentFilterHasStatus(filter: Record<string, unknown> | undefined, status: string) {
|
||||
const value = filter?.status;
|
||||
return Array.isArray(value) ? value.includes(status) : value === status;
|
||||
}
|
||||
|
||||
export function getEquipmentPageContextFromFilter(
|
||||
filter: Record<string, unknown> | undefined,
|
||||
): EquipmentPageContext {
|
||||
if (equipmentFilterHasStatus(filter, 'WriteOff')) {
|
||||
return 'archive';
|
||||
}
|
||||
|
||||
if (equipmentFilterHasStatus(filter, 'Active')) {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
return 'default';
|
||||
}
|
||||
|
||||
export function getEquipmentPageContextFromSearch(search: string): EquipmentPageContext {
|
||||
const params = new URLSearchParams(search);
|
||||
const context = params.get(EQUIPMENT_PAGE_CONTEXT_PARAM);
|
||||
|
||||
if (context === 'active' || context === 'archive') {
|
||||
return context;
|
||||
}
|
||||
|
||||
return getEquipmentPageContextFromFilter(parseEquipmentFilterFromSearch(search));
|
||||
}
|
||||
|
||||
export function getEquipmentCreatePath(context: EquipmentPageContext): string {
|
||||
if (context === 'default') {
|
||||
return '/equipment/create';
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set(EQUIPMENT_PAGE_CONTEXT_PARAM, context);
|
||||
return `/equipment/create?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function getEquipmentCreateDefaultValues(context: EquipmentPageContext) {
|
||||
if (context === 'active') {
|
||||
return { status: 'Active' };
|
||||
}
|
||||
|
||||
if (context === 'archive') {
|
||||
return { status: 'WriteOff' };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,13 @@ function buildTokens(mode: Mode) {
|
||||
accent: '#bd8d64',
|
||||
accentSoft: 'rgba(182, 130, 81, 0.22)',
|
||||
shadow: '0 20px 48px rgba(42, 29, 20, 0.10)',
|
||||
menuText: '#342820',
|
||||
menuTextMuted: 'rgba(52, 40, 32, 0.72)',
|
||||
menuHover: 'rgba(189, 141, 100, 0.10)',
|
||||
menuActive: 'rgba(189, 141, 100, 0.16)',
|
||||
menuGuide: 'rgba(52, 40, 32, 0.24)',
|
||||
menuGuideStrong: 'rgba(52, 40, 32, 0.40)',
|
||||
menuMarkerBg: '#fffaf4',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,6 +37,13 @@ function buildTokens(mode: Mode) {
|
||||
accent: '#d4a574',
|
||||
accentSoft: 'rgba(201, 122, 61, 0.24)',
|
||||
shadow: '0 34px 86px rgba(0, 0, 0, 0.56)',
|
||||
menuText: '#f8fafc',
|
||||
menuTextMuted: 'rgba(248, 250, 252, 0.72)',
|
||||
menuHover: 'rgba(212, 165, 116, 0.12)',
|
||||
menuActive: 'rgba(212, 165, 116, 0.18)',
|
||||
menuGuide: 'rgba(248, 250, 252, 0.22)',
|
||||
menuGuideStrong: 'rgba(248, 250, 252, 0.38)',
|
||||
menuMarkerBg: '#18120d',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,6 +51,10 @@ export function buildToirMuiTheme(mode: Mode): Theme {
|
||||
const t = buildTokens(mode);
|
||||
|
||||
return createTheme({
|
||||
sidebar: {
|
||||
width: 268,
|
||||
closedWidth: 55,
|
||||
},
|
||||
palette: {
|
||||
mode,
|
||||
primary: { main: t.accent },
|
||||
@@ -63,6 +81,30 @@ export function buildToirMuiTheme(mode: Mode): Theme {
|
||||
styleOverrides: {
|
||||
body: {
|
||||
background: t.bgDefault,
|
||||
'--toir-menu-text': t.menuText,
|
||||
'--toir-menu-text-muted': t.menuTextMuted,
|
||||
'--toir-menu-hover-bg': t.menuHover,
|
||||
'--toir-menu-active-bg': t.menuActive,
|
||||
'--toir-menu-guide': t.menuGuide,
|
||||
'--toir-menu-guide-strong': t.menuGuideStrong,
|
||||
'--toir-menu-marker-bg': t.menuMarkerBg,
|
||||
'--toir-menu-radius': '24px',
|
||||
'--toir-menu-item-height': '56px',
|
||||
'--toir-menu-subitem-height': '52px',
|
||||
'--toir-menu-item-padding-y': '10px',
|
||||
'--toir-menu-item-padding-x': '24px',
|
||||
'--toir-menu-item-gap': '12px',
|
||||
'--toir-menu-icon-slot': '36px',
|
||||
'--toir-menu-chevron-size': '24px',
|
||||
'--toir-menu-chevron-stroke-size': '11px',
|
||||
'--toir-menu-font-size': '1.12rem',
|
||||
'--toir-menu-subitem-font-size': '0.98rem',
|
||||
'--toir-menu-subtree-offset': '30px',
|
||||
'--toir-menu-subitem-padding-x': '14px',
|
||||
'--toir-menu-subitem-label-offset': '24px',
|
||||
'--toir-menu-marker-size': '10px',
|
||||
'--toir-menu-guide-width': '2px',
|
||||
'--toir-menu-transition': '160ms ease',
|
||||
'& .RaList-content > .MuiCardContent-root:only-child:has(button[aria-label="Очистить фильтры"])': {
|
||||
display: 'none',
|
||||
},
|
||||
|
||||
@@ -4,4 +4,12 @@ import react from '@vitejs/plugin-react'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user