refactor(frontend): normalize requests list shell
This commit is contained in:
parent
8b150e270d
commit
2cfa1099f3
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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-поле
|
||||
|
||||
|
||||
@ -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 плотной строки уже сейчас, а не как первую цель глобального переписывания.
|
||||
|
||||
## Как использовать эту карту
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user