diff --git a/docs/guides/frontend/04-PATTERNS.md b/docs/guides/frontend/04-PATTERNS.md
index 6a19e0a9..b9230d0a 100644
--- a/docs/guides/frontend/04-PATTERNS.md
+++ b/docs/guides/frontend/04-PATTERNS.md
@@ -135,6 +135,7 @@ Target state:
| Обычный input / textarea / select | `app-input` / `app-select` + `rounded-lg` | нет | `ok` |
| Searchable dropdown trigger | shared `SearchableSelect*` + `rounded-lg` | compose inline-row может быть крупнее по shell | `ok` |
| Compose address row | shared select pattern на базе `app-input` | `rounded-2xl` допустим для compose-shell строки | `ok` |
+| Inline comment composer | shared `CommentControls` + icon-only send action | feed/footer variant может добавлять attachment actions перед input | `ok` |
| Helper / alert / selected block | `app-selected`, `app-feedback-*`, `app-surface-muted` + `rounded-lg`/`rounded-xl` | зависит от веса блока | `needs refactor` |
| Pills / status chips / avatar | `rounded-full` | нет | `ok` |
| `rounded-md` у новых карточек и полей | не использовать | только legacy до отдельной переработки | `legacy` |
diff --git a/docs/guides/frontend/06-COMPONENT-INVENTORY.md b/docs/guides/frontend/06-COMPONENT-INVENTORY.md
index b82b7012..3d75dbe3 100644
--- a/docs/guides/frontend/06-COMPONENT-INVENTORY.md
+++ b/docs/guides/frontend/06-COMPONENT-INVENTORY.md
@@ -254,6 +254,7 @@
- modal: `Modal`
- searchable dropdown: `SearchableSelect*`
- compose address line: `SearchableSelectMulti` с compose-like layout
+- comment composer / delete action: `shared/CommentControls.tsx`
- primary/secondary surface: `app-surface` / `app-surface-muted`
- feedback block: `app-selected`, `app-feedback-*`
diff --git a/frontend/src/app/equipment/page.tsx b/frontend/src/app/equipment/page.tsx
index e0568cae..ed9e2191 100644
--- a/frontend/src/app/equipment/page.tsx
+++ b/frontend/src/app/equipment/page.tsx
@@ -8,6 +8,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Archive, ArrowRightLeft, ArrowUpDown, Check, ChevronDown, ChevronRight, Copy, Download, ExternalLink, Filter, MessageSquare, Monitor, Pencil, Plus, QrCode, Search, Shield, Trash2, Wrench } from "lucide-react";
import { SearchableSelectSingle } from "@/components/shared/SearchableSelect";
+import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
import { formatDate, formatMoney } from "@/lib/shared";
import { useEquipmentPage } from "@/hooks/useEquipmentPage";
import { Modal } from "@/components/ui";
@@ -759,7 +760,11 @@ function EquipmentPageContent() {
{displayUserName(c.author)}
{formatDate(c.created_at)}
- {canDel && }
+ {canDel ? (
+ handleDeleteComment(item.id, c.id)}
+ />
+ ) : null}
{c.text}
@@ -768,9 +773,13 @@ function EquipmentPageContent() {
})
)}
-
-
setCommentDrafts((p) => ({ ...p, [item.id]: e.target.value }))} placeholder="Добавить комментарий" className="app-input flex-1 rounded-lg px-3 py-2 text-xs" />
-
+
+ setCommentDrafts((p) => ({ ...p, [item.id]: value }))}
+ onSubmit={() => handleAddComment(item.id)}
+ disabled={busyKey === `comment-${item.id}`}
+ />
)}
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index 4ebcf179..47ab5118 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -208,7 +208,7 @@ function HomePageContent() {
async function loadPosts() {
try {
await refreshPosts();
- } catch (err: any) {
+ } catch (err: unknown) {
console.error('Ошибка загрузки ленты:', err);
setError('Не удалось загрузить ленту');
} finally {
@@ -376,8 +376,8 @@ function HomePageContent() {
try {
const response = await apiClient.getPostLikers(postId);
setLikesUsersMap((prev) => ({ ...prev, [postId]: response.results || [] }));
- } catch (err: any) {
- const message = String(err?.message || "");
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : "";
if (
message.includes("404") ||
message.includes("NetworkError") ||
@@ -579,8 +579,8 @@ function HomePageContent() {
setCreatePostOpen(false);
setEditingPostId(null);
- } catch (err: any) {
- const message = String(err?.message || "Не удалось сохранить публикацию");
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : "Не удалось сохранить публикацию";
setCreateError(message);
} finally {
setCreateSubmitting(false);
@@ -980,7 +980,7 @@ function HomePageContent() {
)}
)}
-
+
{ setCommentImage(e.target.files?.[0] || null); e.target.value = ""; }} />
{ setCommentAttachment(e.target.files?.[0] || null); e.target.value = ""; }} />
diff --git a/frontend/src/app/procurement/page.tsx b/frontend/src/app/procurement/page.tsx
index ba1c5f84..c2dfe7f9 100644
--- a/frontend/src/app/procurement/page.tsx
+++ b/frontend/src/app/procurement/page.tsx
@@ -31,6 +31,7 @@ import {
X,
XCircle,
} from "lucide-react";
+import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
import { SearchableSelectSingle } from "@/components/shared/SearchableSelect";
import { formatDate, formatMoney } from "@/lib/shared";
import { useProcurementPage } from "@/hooks/useProcurementPage";
@@ -870,9 +871,9 @@ export default function ProcurementPage() {
{fmt(comment.created_at)}
{canDeleteCommentThis ? (
-
+ handleDeleteComment(req.id, comment.id)}
+ />
) : null}
@@ -882,9 +883,13 @@ export default function ProcurementPage() {
})
)}
-
-
setCommentDrafts((previous) => ({ ...previous, [req.id]: e.target.value }))} placeholder="Добавить комментарий" className="app-input flex-1 rounded-lg px-3 py-2 text-xs" />
-
+
+ setCommentDrafts((previous) => ({ ...previous, [req.id]: value }))}
+ onSubmit={() => handleAddComment(req.id)}
+ disabled={busyKey === `comment-${req.id}`}
+ />
) : null}
@@ -1006,9 +1011,9 @@ export default function ProcurementPage() {
{fmt(comment.created_at)}
{canDeleteCommentThis ? (
-
+ handleDeleteComment(selectedRequest.id, comment.id)}
+ />
) : null}
@@ -1018,9 +1023,13 @@ export default function ProcurementPage() {
})
)}
-
-
setCommentDrafts((previous) => ({ ...previous, [selectedRequest.id]: e.target.value }))} placeholder="Добавить комментарий" className="app-input flex-1 rounded-lg px-3 py-2 text-xs" />
-
+
+ setCommentDrafts((previous) => ({ ...previous, [selectedRequest.id]: value }))}
+ onSubmit={() => handleAddComment(selectedRequest.id)}
+ disabled={busyKey === `comment-${selectedRequest.id}`}
+ />
diff --git a/frontend/src/components/documents/DocumentComments.tsx b/frontend/src/components/documents/DocumentComments.tsx
index a727dd8c..6ab0e282 100644
--- a/frontend/src/components/documents/DocumentComments.tsx
+++ b/frontend/src/components/documents/DocumentComments.tsx
@@ -1,10 +1,11 @@
"use client";
import { useState, useEffect } from "react";
-import { MessageCircle, Send, Edit2, Trash2, CornerDownRight } from "lucide-react";
+import { MessageCircle, Edit2, CornerDownRight } from "lucide-react";
import { apiClient } from "@/lib/api";
import { toast } from "sonner";
import { useUser } from "@/contexts/UserContext";
+import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
interface Comment {
id: number;
@@ -187,13 +188,7 @@ export function DocumentComments({ documentId }: DocumentCommentsProps) {
>
-
+ handleDeleteComment(comment.id)} />
)}
@@ -251,34 +246,18 @@ export function DocumentComments({ documentId }: DocumentCommentsProps) {
{/* Reply form */}
{replyTo === comment.id && (
-
-
-
-
-
-
+
{
+ setReplyTo(null);
+ setNewComment("");
+ }}
+ disabled={submitting}
+ multiline
+ placeholder="Напишите ответ..."
+ />
)}
@@ -304,23 +283,15 @@ export function DocumentComments({ documentId }: DocumentCommentsProps) {
{/* New comment form */}
{!replyTo && (
-
-
+
)}
{/* Comments list */}
diff --git a/frontend/src/components/requests/RequestListItem.tsx b/frontend/src/components/requests/RequestListItem.tsx
index 4570fa7d..fc69b8d1 100644
--- a/frontend/src/components/requests/RequestListItem.tsx
+++ b/frontend/src/components/requests/RequestListItem.tsx
@@ -19,6 +19,7 @@ import {
Trash2,
} from "lucide-react";
import type { Ref } from "react";
+import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
import { RequestAvatar } from "./RequestAvatar";
import { RequestUserBadge } from "./RequestUserBadge";
@@ -403,13 +404,9 @@ export function RequestListItem({
{formatDate(comment.created_at)}
{Boolean(comment.author?.id && currentUserId === comment.author.id) ? (
-
+ onDeleteComment(request.id, comment.id)}
+ />
) : null}
@@ -418,21 +415,13 @@ export function RequestListItem({
))
)}
-
-
+ onSetCommentDraft(request.id, event.target.value)}
- placeholder="Добавить комментарий"
- className="app-input flex-1 rounded-lg px-3 py-2 text-xs"
+ onChange={(value) => onSetCommentDraft(request.id, value)}
+ onSubmit={() => onAddComment(request.id)}
+ disabled={busyKey === `comment-${request.id}`}
/>
-
) : null}
diff --git a/frontend/src/components/shared/CommentControls.tsx b/frontend/src/components/shared/CommentControls.tsx
new file mode 100644
index 00000000..10e9f9a1
--- /dev/null
+++ b/frontend/src/components/shared/CommentControls.tsx
@@ -0,0 +1,108 @@
+"use client";
+
+import { Send, Trash2 } from "lucide-react";
+
+type CommentComposerProps = {
+ disabled?: boolean;
+ multiline?: boolean;
+ onCancel?: () => void;
+ onChange: (value: string) => void;
+ onSubmit: () => void | Promise;
+ placeholder?: string;
+ rows?: number;
+ value: string;
+};
+
+export function CommentComposer({
+ disabled = false,
+ multiline = false,
+ onCancel,
+ onChange,
+ onSubmit,
+ placeholder = "Добавить комментарий",
+ rows = 2,
+ value,
+}: CommentComposerProps) {
+ const submitDisabled = disabled || !value.trim();
+
+ if (multiline) {
+ return (
+
+ );
+ }
+
+ return (
+
+
onChange(event.target.value)}
+ placeholder={placeholder}
+ className="app-input min-w-0 w-full rounded-lg px-3 py-2 text-xs"
+ />
+
+
+
+
+ );
+}
+
+type CommentDeleteButtonProps = {
+ disabled?: boolean;
+ onClick: () => void | Promise;
+};
+
+export function CommentDeleteButton({
+ disabled = false,
+ onClick,
+}: CommentDeleteButtonProps) {
+ return (
+
+ );
+}