AURORA-1683 экран документа: список комментариев в боковой панели

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-27 00:23:11 +04:00
parent ff2232787f
commit 63251b287a
5 changed files with 121 additions and 55 deletions

View File

@ -11,7 +11,7 @@ import { getDocument } from '../services/api/documents';
import { getFile, listLegalEntityFiles } from '../services/api/files';
import type { DocumentOut } from '../types/api';
import { documentTypeLabel, periodLabel } from '../utils/labels';
import CommentModal from '../widgets/DocumentReview/CommentModal';
import { DocumentCommentsDrawer } from '../widgets/DocumentComments/DocumentCommentsDrawer';
import { OsvDocument } from '../widgets/DocumentReview/OsvDocument';
import { StatementReview } from '../widgets/DocumentReview/StatementReview';
import { documentPageTitle, documentSubtitle, optionStatusBadge, statusBadge } from '../widgets/DocumentReview/reviewView';
@ -116,7 +116,7 @@ export function DocumentPage() {
/>
<div style={{ alignSelf: 'flex-end' }}>
<Button icon={<CommentOutlined />} onClick={() => setCommentOpen(true)}>
Добавить комментарий
Комментарии ({doc.comments_count})
</Button>
</div>
<main
@ -133,7 +133,7 @@ export function DocumentPage() {
{!broken && kind === 'statement' && <StatementReview key={doc.id} document={doc} onChanged={load} />}
{kind === 'none' && <Empty description='Документ без данных для проверки' />}
</main>
<CommentModal open={commentOpen} documentId={doc.id} onClose={() => setCommentOpen(false)} />
<DocumentCommentsDrawer documentId={doc.id} open={commentOpen} onClose={() => setCommentOpen(false)} onAdded={load} />
</div>
);
}

View File

@ -0,0 +1,25 @@
import { Spin, Typography } from 'antd';
import { formatMoscowDateTime } from '../../utils/dates';
import type { CommentsState } from './useDocumentComments';
/** Список комментариев документа: автор, дата и время по Москве, текст с переносами строк. */
export function CommentsList({ state, emptyText }: { state: CommentsState | null; emptyText: string }) {
if (state === null || state.status === 'loading') return <Spin size='small' />;
if (state.status === 'error') return <span style={{ color: '#cf1322' }}>Не удалось загрузить комментарии</span>;
if (state.comments.length === 0) return <span style={{ color: '#8c8c8c' }}>{emptyText}</span>;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{state.comments.map((c) => (
<div key={c.id}>
<div style={{ display: 'flex', gap: 8, alignItems: 'baseline', flexWrap: 'wrap' }}>
<Typography.Text strong>{c.author.full_name}</Typography.Text>
<Typography.Text type='secondary' style={{ fontSize: 12 }}>
{formatMoscowDateTime(c.created_at)}
</Typography.Text>
</div>
<div style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>{c.text}</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,63 @@
import { Button, Drawer, Input, message } from 'antd';
import { useEffect, useState } from 'react';
import { ApiError } from '../../api/client';
import { postComment } from '../../services/api/documents';
import { CommentsList } from './CommentsList';
import { useDocumentComments } from './useDocumentComments';
type DocumentCommentsDrawerProps = { documentId: string; open: boolean; onClose: () => void; onAdded: () => void };
/** Боковая панель экрана документа: список комментариев и добавление нового (правки и удаления нет). */
export function DocumentCommentsDrawer({ documentId, open, onClose, onAdded }: DocumentCommentsDrawerProps) {
const { state, load, reset } = useDocumentComments(documentId);
const [text, setText] = useState('');
const [saving, setSaving] = useState(false);
// список — при каждом открытии; при закрытии сбрасываем, чтобы не показать чужой список после смены документа
useEffect(() => {
if (open) load();
else reset();
}, [open, load, reset]);
const handleAdd = async () => {
setSaving(true);
try {
await postComment(documentId, text.trim());
message.success('Комментарий добавлен');
setText('');
load();
onAdded();
} catch (e: unknown) {
// текст в поле не трогаем: можно поправить и повторить
message.error(e instanceof ApiError ? e.message : 'Не удалось добавить комментарий');
} finally {
setSaving(false);
}
};
return (
<Drawer
title='Комментарии к документу'
placement='right'
width={420}
open={open}
onClose={onClose}
footer={
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<Input.TextArea
value={text}
onChange={(e) => setText(e.target.value)}
autoSize={{ minRows: 3, maxRows: 8 }}
maxLength={4000}
showCount
placeholder='Опишите результат проверки или внесенные правки'
/>
<Button type='primary' style={{ alignSelf: 'flex-end' }} disabled={text.trim().length === 0} loading={saving} onClick={handleAdd}>
Добавить
</Button>
</div>
}>
<CommentsList state={state} emptyText='Комментариев нет' />
</Drawer>
);
}

View File

@ -0,0 +1,30 @@
import { useCallback, useRef, useState } from 'react';
import { getComments } from '../../services/api/documents';
import type { CommentOut } from '../../types/api';
export type CommentsState = { status: 'loading' } | { status: 'error' } | { status: 'done'; comments: CommentOut[] };
/** Комментарии документа по запросу: `load` перечитывает список, `reset` сбрасывает его (при закрытии панели или поповера). */
export function useDocumentComments(documentId: string | null) {
const [state, setState] = useState<CommentsState | null>(null);
// номер последнего запроса: ответ устаревшего запроса или пришедший после reset игнорируется
const requestNo = useRef(0);
const load = useCallback(() => {
requestNo.current += 1;
const mine = requestNo.current;
if (!documentId) return;
// при перечитывании после добавления старый список остаётся на экране до ответа
setState((prev) => (prev?.status === 'done' ? prev : { status: 'loading' }));
getComments(documentId)
.then((comments) => mine === requestNo.current && setState({ status: 'done', comments }))
.catch(() => mine === requestNo.current && setState({ status: 'error' }));
}, [documentId]);
const reset = useCallback(() => {
requestNo.current += 1;
setState(null);
}, []);
return { state, load, reset };
}

View File

@ -1,52 +0,0 @@
import { Form, Input, Modal, message } from 'antd';
import { useState } from 'react';
import { ApiError } from '../../api/client';
import { postComment } from '../../services/api/documents';
type CommentModalProps = { documentId: string; open: boolean; onClose: () => void };
export default function CommentModal({ documentId, open, onClose }: CommentModalProps) {
const [comment, setComment] = useState('');
const [saving, setSaving] = useState(false);
const close = () => {
setComment('');
onClose();
};
const handleOk = async () => {
setSaving(true);
try {
await postComment(documentId, comment.trim());
message.success('Комментарий добавлен');
close();
} catch (e: unknown) {
// окно не закрываем: текст не теряется, можно повторить
message.error(e instanceof ApiError ? e.message : 'Не удалось добавить комментарий');
} finally {
setSaving(false);
}
};
return (
<Modal
title='Добавить комментарий'
okText='Добавить'
onOk={handleOk}
okButtonProps={{ disabled: comment.trim().length === 0 }}
confirmLoading={saving}
open={open}
cancelText='Отмена'
onCancel={close}
destroyOnHidden>
<Form layout='vertical'>
<Form.Item label={<span style={{ fontWeight: 600 }}>Комментарий</span>}>
<Input.TextArea
value={comment}
onChange={(e) => setComment(e.target.value)}
autoSize={{ minRows: 3 }}
maxLength={4000}
placeholder='Опишите результат проверки или внесенные правки'
/>
</Form.Item>
</Form>
</Modal>
);
}