refactor(frontend): normalize requests list shell

This commit is contained in:
lizerk 2026-04-13 13:17:34 +03:00
parent 8b150e270d
commit 2cfa1099f3
8 changed files with 279 additions and 205 deletions

View File

@ -33,6 +33,7 @@
Для `filter form` дополнительно:
- toolbar и фильтрующий блок лучше держать отдельным feature-компонентом, если page-level файл уже управляет списком, detail modal и compose flow одновременно
- если filter-toolbar уже соседствует с плотным list shell, сам list shell тоже должен иметь явную anatomy: action rail, content column и отдельные secondary panels вместо одного бесформенного nested block
### Dropdown-like UX

View File

@ -179,7 +179,7 @@
- compose-like form pattern
- detail modal с participant-heavy content
- page-level toolbar/filter block, который вынесен из страницы в feature component
- dense list row с actions/comments/expandable secondary content
- dense list row с actions/comments/expandable secondary content, где row anatomy разложена на action rail, content column и отдельные detail/comments panels
- feature container для list shell, который собирает rows + empty state + load more
- отдельный mode-panel для alternate flow вроде swipe-review
- attachment preview modal как отдельный feature block, а не хвост page-level JSX

View File

@ -78,6 +78,7 @@
- то же касается toolbar/filter block и attachment preview, если они уже стали самостоятельными зонами сценария
- router-sync, deep-link opening и dismissable menu state тоже можно выносить в feature hook, если это screen orchestration, а не данные домена
- если внутри одной зоны уже несколько leaf-компонентов, стоит ввести feature container вроде `*Section` или `*Panel`, а не возвращать всё обратно в page-level JSX
- dense row после такого выноса не должна оставаться одним бесформенным JSX-комком: отдельно собираются action rail, main content и secondary panels вроде details/comments
## 5. Как делать dropdown-поле

View File

@ -13,7 +13,7 @@
| Зона | Статус | Что уже хорошо | Основные проблемы | Рекомендуемый следующий шаг |
| --- | --- | --- | --- | --- |
| `requests` | `mixed` | compose-настроение, participant-only логика, UI вокруг `can_decide`, compose/list/detail/controls/preview разведены по feature components, screen orchestration вынесена в hook, list/swipe flow собраны в feature containers | visual debt остаётся в отдельных локальных визуальных решениях; зона ещё не стала полным target-state | использовать как референс для разделения orchestration/UI и participant-heavy request flows, затем отдельно чистить локальную визуальную неоднородность |
| `requests` | `mixed` | compose-настроение, participant-only логика, UI вокруг `can_decide`, compose/list/detail/controls/preview разведены по feature components, screen orchestration вынесена в hook, list/swipe flow собраны в feature containers, dense row приведён к явной anatomy с отдельными detail/comments panels | visual debt сузился, но ещё остаются image warnings и контракт controls block | использовать как референс для разделения orchestration/UI, participant-heavy request flows и anatomy плотной строки, затем добить image/controls cleanup |
| `settings` | `mixed` | сильная surface hierarchy, хорошие primary sections | встречаются custom radii и локальная геометрическая неоднородность | рефакторить карточки и кастомные радиусы без смены общего layout |
| `documents` | `needs refactor` | богатый набор сценариев, detail/upload/comment patterns уже выделены в компоненты | смешение `rounded-md`, `rounded-lg`, `rounded-xl`, разные уровни visual density | сделать отдельный pass по карточкам и metadata/upload flows |
| `equipment` | `needs refactor` | понятные формы и вторичные блоки | много локальных card/form решений, геометрия плавает | после `documents` пройтись по secondary cards и action forms |
@ -33,7 +33,7 @@
4. `procurement`
5. отдельный audit по `calendar`
`requests` лучше использовать как референс для compose-настроения, выделения feature-level workspace/list-detail/controls блоков, screen-specific orchestration hooks и feature containers уже сейчас, а не как первую цель глобального переписывания.
`requests` лучше использовать как референс для compose-настроения, выделения feature-level workspace/list-detail/controls блоков, screen-specific orchestration hooks, feature containers и anatomy плотной строки уже сейчас, а не как первую цель глобального переписывания.
## Как использовать эту карту

View File

@ -115,7 +115,7 @@ function RequestsPageContent() {
requestMenuRef={screen.requestMenuRef}
requests={h.requests}
setAttachmentPreview={h.setAttachmentPreview}
setCommentDrafts={h.setCommentDrafts}
onSetCommentDraft={h.setCommentDraft}
setDetailsRequest={h.setDetailsRequest}
setRequestMenuOpenId={screen.setRequestMenuOpenId}
toggleComments={h.toggleComments}

View File

@ -5,10 +5,10 @@ import {
statusMeta,
type RequestAttachmentPreview,
} from "@/hooks/useRequestsPage";
import { displayUserName, formatDate, userProfileLink } from "@/lib/shared";
import type { Request, RequestComment } from "@/types/api";
import { displayUserName, formatDate, formatDateTime, userProfileLink } from "@/lib/shared";
import type { Request, RequestComment, User } from "@/types/api";
import { Ban, ChevronDown, ChevronRight, MessageSquare, Paperclip, Pencil, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react";
import type { Ref } from "react";
import type { ReactNode, Ref } from "react";
import { RequestUserBadge } from "./RequestUserBadge";
type RequestListItemProps = {
@ -38,6 +38,77 @@ type RequestListItemProps = {
rowOpen: boolean;
};
type RequestMetaFieldProps = {
label: string;
value: ReactNode;
};
type RequestAudienceRowProps = {
count?: number;
currentUserId?: number | null;
emptyText: string;
label: string;
people: User[];
};
function RequestMetaField({ label, value }: RequestMetaFieldProps) {
return (
<div className="space-y-1">
<p className="app-text-muted text-[11px] font-medium uppercase tracking-wide">{label}</p>
<div className="text-sm text-[var(--foreground)]">{value}</div>
</div>
);
}
function RequestAudienceRow({
count,
currentUserId,
emptyText,
label,
people,
}: RequestAudienceRowProps) {
return (
<div className="space-y-2">
<p className="app-text-muted text-[11px] font-medium uppercase tracking-wide">{label}</p>
{people.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{people.slice(0, 2).map((person) => (
<RequestUserBadge
key={person.id}
person={person}
currentUserId={currentUserId}
/>
))}
{people.length > 2 ? (
<span className="app-badge inline-flex rounded-full px-2.5 py-1 text-xs font-medium">
+{people.length - 2}
</span>
) : null}
</div>
) : (
<p className="app-text-muted text-sm">{count ? count : emptyText}</p>
)}
</div>
);
}
function renderDecisionMaker(decisionMaker: User | undefined, currentUserId?: number | null) {
if (!decisionMaker) {
return <span className="app-text-muted">-</span>;
}
const decisionMakerLink = userProfileLink(decisionMaker, currentUserId);
const decisionMakerName = displayUserName(decisionMaker);
return decisionMakerLink ? (
<Link href={decisionMakerLink} className="app-link-accent font-medium">
{decisionMakerName}
</Link>
) : (
<span className="font-medium text-[var(--foreground)]">{decisionMakerName}</span>
);
}
export function RequestListItem({
busyKey,
commentDraft,
@ -66,10 +137,10 @@ export function RequestListItem({
}: RequestListItemProps) {
const requestAuthor = request.employee || request.created_by;
const authorName = displayUserName(requestAuthor);
const statusKey = String(request.status || "").toLowerCase();
const typeKey = String(request.type || request.request_type || "").toLowerCase();
const status = statusMeta[statusKey] ?? defaultStatusMeta;
const authorLink = requestAuthor ? userProfileLink(requestAuthor, currentUserId) : null;
const statusKey = String(request.status || "").toLowerCase();
const status = statusMeta[statusKey] ?? defaultStatusMeta;
const typeKey = String(request.type || request.request_type || "").toLowerCase();
const typeLabel = requestTypeLabels[typeKey] || String(request.type || request.request_type || "Другое");
const title = request.display_title || request.title || "Без заголовка";
const canProcess = Boolean(statusKey === "pending" && request.can_decide);
@ -79,49 +150,58 @@ export function RequestListItem({
const canDelete = Boolean(isAuthor && !isFinal(statusKey));
const hasSecondaryActions = canCancel || canEdit || canDelete;
const canComment = statusKey !== "draft";
const summary = request.comment || request.description;
const commentCount = request.comments_count ?? comments.length;
const trimmedCommentDraft = commentDraft.trim();
const recipients = request.recipients || [];
const ccUsers = request.cc_users || [];
const decisionMaker = request.approver || request.assigned_to;
const departmentLabels = (request.departments || [])
.map((id) => departmentNameMap.get(Number(id)) || `Отдел #${id}`)
.join(", ");
const recipients = request.recipients || [];
const ccUsers = request.cc_users || [];
const summary = request.comment || request.description;
const attachmentUrl = request.attachment_url || request.attachment || "";
const attachmentName = attachmentUrl
? decodeURIComponent(attachmentUrl.split("/").pop() || "Вложение")
: "";
const hasExpandedPanels = rowOpen || commentsOpen;
return (
<article className={`app-surface-muted rounded-xl transition hover:border-[var(--border-strong)] ${isMenuOpen ? "relative z-20 overflow-visible" : "overflow-hidden"}`}>
<div className="p-4">
<div className="flex items-start gap-3">
<div className="flex shrink-0 flex-col items-center gap-3 pt-0.5">
<article
className={`app-surface-muted rounded-xl border border-transparent transition hover:border-[var(--border-strong)] ${isMenuOpen ? "relative z-20 overflow-visible" : "overflow-hidden"}`}
>
<div className="p-4 sm:p-5">
<div className="flex items-start gap-4">
<div className="app-surface flex shrink-0 flex-col items-center gap-2 rounded-xl p-1.5">
<button
type="button"
onClick={() => onToggleRow(request.id)}
className="app-action-secondary inline-flex h-8 w-8 items-center justify-center rounded-lg transition"
className={`inline-flex h-8 w-8 items-center justify-center rounded-lg transition ${rowOpen ? "app-selected app-accent-text" : "app-action-secondary"}`}
title={rowOpen ? "Скрыть подробности" : "Показать подробности"}
aria-expanded={rowOpen}
aria-label={rowOpen ? "Скрыть подробности" : "Показать подробности"}
>
<ChevronDown size={15} className={`transition ${rowOpen ? "rotate-180" : ""}`} />
</button>
<button
type="button"
title={canComment ? `Комментарии (${request.comments_count ?? comments.length})` : "Комментарии для черновика недоступны"}
title={canComment ? `Комментарии (${commentCount})` : "Комментарии для черновика недоступны"}
onClick={() => canComment && void onToggleComments(request.id)}
disabled={!canComment}
className="app-action-secondary relative inline-flex h-8 w-8 items-center justify-center rounded-lg disabled:cursor-not-allowed disabled:opacity-50"
className={`relative inline-flex h-8 w-8 items-center justify-center rounded-lg transition disabled:cursor-not-allowed disabled:opacity-50 ${commentsOpen ? "app-selected app-accent-text" : "app-action-secondary"}`}
>
<MessageSquare size={15} />
{(request.comments_count ?? comments.length) > 0 && (
{commentCount > 0 ? (
<span className="app-counter absolute -right-1.5 -top-1.5 flex h-4 min-w-4 px-1 text-[10px] font-bold">
{request.comments_count ?? comments.length}
{commentCount}
</span>
)}
) : null}
</button>
</div>
<div className="min-w-0 flex-1">
<div className="min-w-0 flex-1 space-y-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="mb-2 flex items-center gap-2">
<div className="min-w-0 flex-1 space-y-3">
<div className="flex flex-wrap items-center gap-2">
{authorLink ? (
<Link href={authorLink} className="group flex min-w-0 items-center gap-2">
{requestAuthor?.avatar ? (
@ -145,24 +225,36 @@ export function RequestListItem({
<span className="truncate text-sm font-medium text-[var(--foreground)]">{authorName}</span>
</div>
)}
</div>
<button type="button" onClick={() => onOpenDetails(request)} className="block w-full text-left">
<h3 className={`${rowOpen ? "app-text-wrap line-clamp-3" : "truncate"} text-sm font-semibold text-[var(--foreground)] transition hover:text-[var(--accent-primary-strong)]`}>
<span className="app-text-muted">{typeLabel}:</span>{" "}
<span className="text-[var(--foreground)]">{title}</span>
</h3>
</button>
<div className="app-text-muted mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span>
Период: {request.date_from ? formatDate(request.date_from) : "—"}
{request.date_to ? ` — ${formatDate(request.date_to)}` : ""}
<span className="app-badge inline-flex rounded-full px-2.5 py-1 text-xs font-medium">
{typeLabel}
</span>
</div>
<div className="space-y-2">
<button type="button" onClick={() => onOpenDetails(request)} className="block w-full text-left">
<h3 className={`${rowOpen ? "app-text-wrap line-clamp-3" : "truncate"} text-sm font-semibold text-[var(--foreground)] transition hover:text-[var(--accent-primary-strong)]`}>
{title}
</h3>
</button>
<div className="app-text-muted flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span>
Период: {request.date_from ? formatDate(request.date_from) : "-"}
{request.date_to ? ` - ${formatDate(request.date_to)}` : ""}
</span>
<span>Создано: {formatDate(request.created_at) || "-"}</span>
{departmentLabels ? <span>Отделы: {departmentLabels}</span> : null}
</div>
</div>
{summary ? (
<p className={`${rowOpen ? "app-text-wrap line-clamp-10" : "app-text-wrap line-clamp-3"} text-sm leading-relaxed text-[var(--foreground)]`}>
{summary}
</p>
) : null}
</div>
<div className="shrink-0">
<div className="flex shrink-0 flex-col items-end gap-2">
<div ref={isMenuOpen ? menuRef : null} className="flex items-center justify-end gap-2">
<span className={`inline-flex rounded-full px-2.5 py-1 text-xs ring-1 ${status.className}`}>
{status.label}
@ -231,174 +323,150 @@ export function RequestListItem({
</div>
) : null}
</div>
{canProcess ? (
<div className="app-surface flex items-center gap-1 rounded-xl p-1">
<button
type="button"
title="Одобрить"
onClick={() => void onApprove(request.id)}
disabled={busyKey === `approve-${request.id}`}
className="app-feedback-success inline-flex items-center justify-center rounded-lg p-2 disabled:opacity-60"
>
<ThumbsUp size={18} />
</button>
<button
type="button"
title="Отклонить"
onClick={() => void onReject(request.id)}
disabled={busyKey === `reject-${request.id}`}
className="app-action-danger inline-flex items-center justify-center rounded-lg p-2 disabled:opacity-60"
>
<ThumbsDown size={18} />
</button>
</div>
) : null}
</div>
</div>
{summary ? (
<p className={`${rowOpen ? "app-text-wrap line-clamp-10" : "app-text-wrap line-clamp-3"} mt-3 text-sm text-[var(--foreground)]`}>
{summary}
</p>
) : null}
{hasExpandedPanels ? (
<div className="space-y-3 border-t border-[var(--border-subtle)] pt-4">
{rowOpen ? (
<section className="app-surface-elevated rounded-xl p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Подробности</p>
{attachmentUrl ? (
<button
type="button"
onClick={() => onPreviewAttachment({ url: attachmentUrl, name: attachmentName })}
className="app-action-secondary inline-flex min-w-0 items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium"
>
<Paperclip size={13} className="shrink-0" />
<span className="truncate">{attachmentName}</span>
</button>
) : null}
</div>
{canProcess ? (
<div className={`${summary ? "mt-3" : "mt-2"} flex flex-wrap items-center gap-1.5`}>
<span className="ml-auto inline-flex items-center gap-2">
<button
type="button"
title="Одобрить"
onClick={() => void onApprove(request.id)}
disabled={busyKey === `approve-${request.id}`}
className="app-feedback-success inline-flex items-center justify-center rounded-lg p-2 disabled:opacity-60"
>
<ThumbsUp size={18} />
</button>
<button
type="button"
title="Отклонить"
onClick={() => void onReject(request.id)}
disabled={busyKey === `reject-${request.id}`}
className="app-action-danger inline-flex items-center justify-center rounded-lg p-2 disabled:opacity-60"
>
<ThumbsDown size={18} />
</button>
</span>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<div className="app-surface rounded-xl p-3">
<div className="space-y-3">
<RequestMetaField
label="Принял решение"
value={renderDecisionMaker(decisionMaker, currentUserId)}
/>
<RequestMetaField
label="Создано"
value={formatDateTime(request.created_at) || "-"}
/>
<RequestMetaField
label="Обновлено"
value={formatDateTime(request.updated_at) || "-"}
/>
<RequestMetaField
label="Отделы"
value={departmentLabels || <span className="app-text-muted">-</span>}
/>
</div>
</div>
<div className="app-surface rounded-xl p-3">
<div className="space-y-3">
<RequestAudienceRow
label="Получатели"
people={recipients}
count={request.recipient_count}
currentUserId={currentUserId}
emptyText="-"
/>
<RequestAudienceRow
label="В копии"
people={ccUsers}
currentUserId={currentUserId}
emptyText="-"
/>
</div>
</div>
</div>
</section>
) : null}
{commentsOpen ? (
<section className="app-surface-elevated rounded-xl p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<p className="app-text-muted text-xs font-semibold uppercase tracking-wide">Комментарии</p>
<span className="app-badge inline-flex rounded-full px-2.5 py-1 text-xs font-medium">
{commentCount}
</span>
</div>
<div className="space-y-2">
{comments.length === 0 ? (
<p className="app-text-muted text-sm">Комментариев пока нет</p>
) : comments.map((comment) => (
<div key={comment.id} className="app-surface rounded-xl px-3 py-3 text-sm text-[var(--foreground)]">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="font-medium">{displayUserName(comment.author)}</span>
<div className="flex items-center gap-2">
<span className="app-text-muted text-xs">{formatDateTime(comment.created_at) || "-"}</span>
{Boolean(comment.author?.id && currentUserId === comment.author.id) ? (
<button
type="button"
onClick={() => void onDeleteComment(request.id, comment.id)}
disabled={busyKey === `comment-delete-${comment.id}`}
className="app-action-danger inline-flex rounded-lg px-2 py-1 text-xs font-medium disabled:opacity-60"
>
Удалить
</button>
) : null}
</div>
</div>
<p className="app-text-wrap leading-relaxed text-[var(--foreground)]">{comment.text}</p>
</div>
))}
</div>
<div className="mt-3 flex flex-col gap-2 sm:flex-row">
<input
value={commentDraft}
onChange={(event) => onSetCommentDraft(request.id, event.target.value)}
placeholder="Добавить комментарий"
className="app-input flex-1 rounded-lg px-3 py-2.5 text-sm"
/>
<button
type="button"
onClick={() => void onAddComment(request.id)}
disabled={busyKey === `comment-${request.id}` || !trimmedCommentDraft}
className="app-action-primary rounded-lg px-4 py-2.5 text-sm font-medium disabled:opacity-60"
>
Отправить
</button>
</div>
</section>
) : null}
</div>
) : null}
</div>
</div>
{(rowOpen || commentsOpen) ? (
<div className="app-surface-elevated mt-4 rounded-xl p-4">
{rowOpen ? (
<div className="space-y-3 text-xs">
<div className="grid grid-cols-1 gap-x-4 gap-y-2 sm:grid-cols-2">
<div>
<span className="app-text-muted">Принял решение:</span>{" "}
{(() => {
const approver = request.approver || request.assigned_to;
if (!approver) return <span className="app-text-muted">—</span>;
const approverLink = userProfileLink(approver, currentUserId);
const approverName = displayUserName(approver);
return approverLink
? <Link href={approverLink} className="app-link-accent font-medium">{approverName}</Link>
: <span className="font-medium text-[var(--foreground)]">{approverName}</span>;
})()}
</div>
<div>
<span className="app-text-muted">Создано:</span>{" "}
<span className="font-medium text-[var(--foreground)]">{formatDate(request.created_at) || "—"}</span>
</div>
<div>
<span className="app-text-muted">Обновлено:</span>{" "}
<span className="font-medium text-[var(--foreground)]">{formatDate(request.updated_at) || "—"}</span>
</div>
{departmentLabels ? (
<div className="sm:col-span-2">
<span className="app-text-muted">Отделы:</span>{" "}
<span className="font-medium text-[var(--foreground)]">{departmentLabels}</span>
</div>
) : null}
</div>
<div className="space-y-2">
<div className="flex flex-wrap items-start gap-2">
<span className="app-text-muted pt-1">Получатели:</span>
<div className="flex min-w-0 flex-1 flex-wrap gap-1.5">
{recipients.slice(0, 2).map((recipient) => (
<RequestUserBadge
key={recipient.id}
person={recipient}
currentUserId={currentUserId}
/>
))}
{recipients.length === 0 ? (
<span className="app-text-muted pt-1">{request.recipient_count ?? 0}</span>
) : null}
{recipients.length > 2 ? (
<span className="app-badge px-2 py-1 text-xs font-medium">+{recipients.length - 2}</span>
) : null}
</div>
</div>
<div className="flex flex-wrap items-start gap-2">
<span className="app-text-muted pt-1">В копии:</span>
<div className="flex min-w-0 flex-1 flex-wrap gap-1.5">
{ccUsers.slice(0, 2).map((ccUser) => (
<RequestUserBadge
key={ccUser.id}
person={ccUser}
currentUserId={currentUserId}
/>
))}
{ccUsers.length === 0 ? <span className="app-text-muted pt-1">—</span> : null}
{ccUsers.length > 2 ? (
<span className="app-badge px-2 py-1 text-xs font-medium">+{ccUsers.length - 2}</span>
) : null}
</div>
</div>
{attachmentUrl ? (
<div className="flex min-w-0 items-center gap-1.5">
<button
type="button"
onClick={() => onPreviewAttachment({ url: attachmentUrl, name: attachmentName })}
className="app-badge app-badge-accent inline-flex min-w-0 max-w-full items-center gap-1.5 px-2.5 py-1 text-xs font-medium"
>
<Paperclip size={13} className="shrink-0" />
<span className="truncate">{attachmentName}</span>
</button>
</div>
) : null}
</div>
</div>
) : null}
{commentsOpen ? (
<div className={rowOpen ? "app-surface mt-3 rounded-xl p-3" : "app-surface rounded-xl p-3"}>
<div className="space-y-2">
{comments.length === 0 ? (
<p className="app-text-muted text-xs">Комментариев пока нет</p>
) : comments.map((comment) => (
<div key={comment.id} className="app-surface-muted rounded-lg px-3 py-2 text-xs text-[var(--foreground)]">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="font-medium">{displayUserName(comment.author)}</span>
<div className="flex items-center gap-2">
<span className="app-text-muted">{formatDate(comment.created_at)}</span>
{Boolean(comment.author?.id && currentUserId === comment.author.id) ? (
<button
type="button"
onClick={() => void onDeleteComment(request.id, comment.id)}
className="app-action-danger rounded-lg px-1.5 py-0.5"
>
удалить
</button>
) : null}
</div>
</div>
<p className="app-text-wrap text-[var(--foreground)]">{comment.text}</p>
</div>
))}
</div>
<div className="mt-2 flex items-center gap-2">
<input
value={commentDraft}
onChange={(event) => onSetCommentDraft(request.id, event.target.value)}
placeholder="Добавить комментарий"
className="app-input flex-1 rounded-lg px-3 py-2 text-xs"
/>
<button
type="button"
onClick={() => void onAddComment(request.id)}
disabled={busyKey === `comment-${request.id}`}
className="app-action-primary rounded-lg px-3 py-2 text-xs font-medium disabled:opacity-60"
>
Отправить
</button>
</div>
</div>
) : null}
</div>
) : null}
</div>
</article>
);

View File

@ -1,6 +1,6 @@
import type { RequestAttachmentPreview } from "@/hooks/useRequestsPage";
import type { Request, RequestComment } from "@/types/api";
import type { Dispatch, RefObject, SetStateAction } from "react";
import type { RefObject } from "react";
import { FileSignature } from "lucide-react";
import { RequestListItem } from "./RequestListItem";
@ -26,8 +26,8 @@ type RequestListSectionProps = {
requestMenuOpenId: number | null;
requestMenuRef: RefObject<HTMLDivElement | null>;
requests: Request[];
onSetCommentDraft: (requestId: number, value: string) => void;
setAttachmentPreview: (preview: RequestAttachmentPreview | null) => void;
setCommentDrafts: Dispatch<SetStateAction<Record<number, string>>>;
setDetailsRequest: (request: Request | null) => void;
setRequestMenuOpenId: (requestId: number | null) => void;
toggleComments: (requestId: number) => void | Promise<void>;
@ -56,8 +56,8 @@ export function RequestListSection({
requestMenuOpenId,
requestMenuRef,
requests,
onSetCommentDraft,
setAttachmentPreview,
setCommentDrafts,
setDetailsRequest,
setRequestMenuOpenId,
toggleComments,
@ -92,7 +92,7 @@ export function RequestListSection({
onOpenDetails={setDetailsRequest}
onPreviewAttachment={setAttachmentPreview}
onReject={handleReject}
onSetCommentDraft={(requestId, value) => setCommentDrafts((prev) => ({ ...prev, [requestId]: value }))}
onSetCommentDraft={onSetCommentDraft}
onToggleComments={toggleComments}
onToggleMenu={setRequestMenuOpenId}
onToggleRow={toggleRow}

View File

@ -431,6 +431,10 @@ export function useRequestsPage(_userId: number | null | undefined) {
finally { setBusyKey(null); }
};
const setCommentDraft = useCallback((requestId: number, value: string) => {
setCommentDrafts((prev) => ({ ...prev, [requestId]: value }));
}, []);
const isFinal = (status?: string) => ["approved", "rejected", "cancelled"].includes(String(status || "").toLowerCase());
const loadMoreRef = useRef<HTMLDivElement>(null);
@ -456,7 +460,7 @@ export function useRequestsPage(_userId: number | null | undefined) {
expandedRows,
expandedComments,
commentDrafts,
setCommentDrafts,
setCommentDraft,
/* form */
form,