refactor(frontend): restore requests visual baseline

This commit is contained in:
lizerk 2026-04-13 13:45:45 +03:00
parent 5584c88d1c
commit 08518a6dc5
3 changed files with 351 additions and 444 deletions

View File

@ -1,7 +1,7 @@
import { orderingOptions, requestTypeLabels, statusMeta } from "@/hooks/useRequestsPage";
import { displayUserName } from "@/lib/shared";
import type { User } from "@/types/api";
import { ArrowUpDown, ChevronDown, Filter, Plus, Search, X, Zap } from "lucide-react";
import { ArrowUpDown, ChevronDown, Filter, Plus, Search, Zap } from "lucide-react";
type RequestListControlsState = {
activeFiltersCount: number;
@ -55,209 +55,178 @@ export function RequestListControls({
state,
}: RequestListControlsProps) {
return (
<div className="mb-4 space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<>
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-3">
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">Заявления</p>
{state.pendingDecisionCount > 0 ? (
<span className="app-badge inline-flex rounded-full px-2.5 py-1 text-xs font-medium">
{state.pendingDecisionCount} на рассмотрении
</span>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
{state.pendingDecisionCount > 0 ? (
<button
type="button"
onClick={actions.openSwipeMode}
className="app-feedback-warning inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium transition"
className="app-feedback-warning group flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-medium transition"
title="Быстрый разбор"
>
<Zap size={14} />
Быстрый разбор
<Zap size={11} className="transition group-hover:text-amber-700" />
</button>
) : null}
<button
type="button"
onClick={actions.openCreate}
className="app-action-primary inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium"
>
<Plus size={14} />
Создать заявление
</button>
</div>
<button
type="button"
onClick={actions.openCreate}
className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium"
>
<Plus size={14} /> Создать заявление
</button>
</div>
{feedback.actionError ? (
<p className="app-feedback-danger rounded-lg px-3 py-2 text-sm">{feedback.actionError}</p>
<p className="app-feedback-danger mb-3 rounded-lg px-3 py-2 text-sm">{feedback.actionError}</p>
) : null}
{feedback.actionSuccess ? (
<p className="app-feedback-success rounded-lg px-3 py-2 text-sm">{feedback.actionSuccess}</p>
<p className="app-feedback-success mb-3 rounded-lg px-3 py-2 text-sm">{feedback.actionSuccess}</p>
) : null}
<section className="app-surface-muted rounded-xl p-3">
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
<div className="relative flex-1">
<Search
size={16}
className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2"
/>
<input
value={state.search}
onChange={(event) => actions.setSearch(event.target.value)}
placeholder="Поиск по заявлениям"
className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm"
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
title="Фильтры"
onClick={actions.toggleFilters}
className={`inline-flex items-center gap-2 rounded-lg px-3 py-2.5 text-sm font-medium transition ${state.filtersOpen ? "app-selected app-accent-text" : "app-surface app-text-muted hover:bg-[var(--surface-tertiary)]"}`}
>
<Filter size={16} />
<span>Фильтры</span>
{state.activeFiltersCount > 0 ? (
<span className="app-counter flex h-5 min-w-5 items-center justify-center px-1 text-[10px] font-bold">
{state.activeFiltersCount}
</span>
) : null}
</button>
<div className="relative min-w-[168px] flex-1 sm:flex-none">
<ArrowUpDown
size={15}
className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2"
/>
<select
value={state.ordering}
onChange={(event) => actions.setOrdering(event.target.value)}
className="app-select w-full appearance-none rounded-lg py-2.5 pl-9 pr-8 text-sm font-medium"
aria-label="Сортировка"
>
{orderingOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<ChevronDown
size={14}
className="app-text-muted pointer-events-none absolute right-3 top-1/2 -translate-y-1/2"
/>
</div>
</div>
<div className="mb-4 flex items-center gap-2">
<div className="relative flex-1">
<Search size={16} className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" />
<input
value={state.search}
onChange={(event) => actions.setSearch(event.target.value)}
placeholder="Поиск по заявлениям"
className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm"
/>
</div>
{state.filtersOpen ? (
<div className="mt-3 space-y-3 border-t border-[var(--border-subtle)] pt-3">
<div className="grid grid-cols-1 gap-2 md:grid-cols-2 xl:grid-cols-4">
<select
value={state.view}
onChange={(event) => actions.setView(event.target.value as "" | "mine" | "addressed")}
className="app-select rounded-lg px-3 py-2 text-sm"
>
<option value="">Все заявления</option>
<option value="mine">Мои заявления</option>
<option value="addressed">Адресованные мне</option>
</select>
<button
type="button"
title="Фильтры"
onClick={actions.toggleFilters}
className={`relative inline-flex items-center justify-center rounded-lg p-2.5 transition ${state.filtersOpen ? "app-selected app-accent-text" : "app-surface-muted app-text-muted hover:bg-[var(--surface-tertiary)]"}`}
>
<Filter size={16} />
{state.activeFiltersCount > 0 ? (
<span className="app-counter absolute -right-1 -top-1 flex h-4 min-w-4 px-1 text-[10px] font-bold">
{state.activeFiltersCount}
</span>
) : null}
</button>
<select
value={state.typeFilter}
onChange={(event) => actions.setTypeFilter(event.target.value)}
className="app-select rounded-lg px-3 py-2 text-sm"
>
<option value="">Тип заявления</option>
{Object.entries(requestTypeLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<div className="relative w-[148px] shrink-0">
<ArrowUpDown size={15} className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" />
<select
value={state.ordering}
onChange={(event) => actions.setOrdering(event.target.value)}
className="app-select w-full appearance-none rounded-lg py-2.5 pl-9 pr-8 text-xs font-medium"
aria-label="Сортировка"
>
{orderingOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<ChevronDown size={14} className="app-text-muted pointer-events-none absolute right-3 top-1/2 -translate-y-1/2" />
</div>
</div>
<select
value={state.statusFilter}
onChange={(event) => actions.setStatusFilter(event.target.value)}
className="app-select rounded-lg px-3 py-2 text-sm"
>
<option value="">Статус заявления</option>
{Object.entries(statusMeta).map(([value, meta]) => (
<option key={value} value={value}>
{meta.label}
</option>
))}
</select>
{state.filtersOpen ? (
<div className="app-surface-muted mb-3 flex flex-col gap-2 rounded-xl p-3">
<select
value={state.view}
onChange={(event) => actions.setView(event.target.value as "" | "mine" | "addressed")}
className="app-select rounded-lg px-3 py-2 text-sm"
>
<option value="">Все заявления</option>
<option value="mine">Мои заявления</option>
<option value="addressed">Адресованные мне</option>
</select>
<select
value={state.employeeFilter}
onChange={(event) => actions.setEmployeeFilter(event.target.value)}
className="app-select rounded-lg px-3 py-2 text-sm"
>
<option value="">Все сотрудники</option>
{state.employees.map((employee) => (
<option key={employee.id} value={employee.id}>
{displayUserName(employee)}
</option>
))}
</select>
<select
value={state.typeFilter}
onChange={(event) => actions.setTypeFilter(event.target.value)}
className="app-select rounded-lg px-3 py-2 text-sm"
>
<option value="">Тип заявления</option>
{Object.entries(requestTypeLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<select
value={state.statusFilter}
onChange={(event) => actions.setStatusFilter(event.target.value)}
className="app-select rounded-lg px-3 py-2 text-sm"
>
<option value="">Статус заявления</option>
{Object.entries(statusMeta).map(([value, meta]) => (
<option key={value} value={value}>
{meta.label}
</option>
))}
</select>
<select
value={state.employeeFilter}
onChange={(event) => actions.setEmployeeFilter(event.target.value)}
className="app-select rounded-lg px-3 py-2 text-sm"
>
<option value="">Все сотрудники</option>
{state.employees.map((employee) => (
<option key={employee.id} value={employee.id}>
{displayUserName(employee)}
</option>
))}
</select>
<div className="space-y-1.5">
<label className="app-text-muted px-1 text-xs font-medium">Дата создания</label>
<div className="flex gap-2">
<input
type="date"
value={state.createdFromFilter}
onChange={(event) => actions.setCreatedFromFilter(event.target.value)}
className="app-input flex-1 rounded-lg px-3 py-2 text-sm"
/>
<input
type="date"
value={state.createdToFilter}
onChange={(event) => actions.setCreatedToFilter(event.target.value)}
className="app-input flex-1 rounded-lg px-3 py-2 text-sm"
/>
</div>
<div className="grid grid-cols-1 gap-2 lg:grid-cols-2">
<div className="space-y-1.5">
<label className="app-text-muted px-1 text-xs font-medium">Дата создания</label>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<input
type="date"
value={state.createdFromFilter}
onChange={(event) => actions.setCreatedFromFilter(event.target.value)}
className="app-input rounded-lg px-3 py-2 text-sm"
/>
<input
type="date"
value={state.createdToFilter}
onChange={(event) => actions.setCreatedToFilter(event.target.value)}
className="app-input rounded-lg px-3 py-2 text-sm"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="app-text-muted px-1 text-xs font-medium">Период заявления</label>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<input
type="date"
value={state.periodFromFilter}
onChange={(event) => actions.setPeriodFromFilter(event.target.value)}
className="app-input rounded-lg px-3 py-2 text-sm"
/>
<input
type="date"
value={state.periodToFilter}
onChange={(event) => actions.setPeriodToFilter(event.target.value)}
className="app-input rounded-lg px-3 py-2 text-sm"
/>
</div>
</div>
</div>
{state.hasActiveFilters ? (
<div className="flex justify-end">
<button
type="button"
onClick={actions.clearFilters}
className="app-action-secondary inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium transition"
>
<X size={14} />
Очистить фильтры
</button>
</div>
) : null}
</div>
) : null}
</section>
</div>
<div className="space-y-1.5">
<label className="app-text-muted px-1 text-xs font-medium">Период заявления</label>
<div className="flex gap-2">
<input
type="date"
value={state.periodFromFilter}
onChange={(event) => actions.setPeriodFromFilter(event.target.value)}
className="app-input flex-1 rounded-lg px-3 py-2 text-sm"
/>
<input
type="date"
value={state.periodToFilter}
onChange={(event) => actions.setPeriodToFilter(event.target.value)}
className="app-input flex-1 rounded-lg px-3 py-2 text-sm"
/>
</div>
</div>
{state.hasActiveFilters ? (
<button
type="button"
onClick={actions.clearFilters}
className="app-action-secondary rounded-lg px-3 py-2 text-sm font-medium transition"
>
Очистить фильтры
</button>
) : null}
</div>
) : null}
</>
);
}

View File

@ -5,7 +5,7 @@ import {
statusMeta,
type RequestAttachmentPreview,
} from "@/hooks/useRequestsPage";
import { displayUserName, formatDate, formatDateTime, userProfileLink } from "@/lib/shared";
import { displayUserName, formatDate, userProfileLink } from "@/lib/shared";
import type { Request, RequestComment, User } from "@/types/api";
import {
Ban,
@ -18,7 +18,7 @@ import {
ThumbsUp,
Trash2,
} from "lucide-react";
import type { ReactNode, Ref } from "react";
import type { Ref } from "react";
import { RequestAvatar } from "./RequestAvatar";
import { RequestUserBadge } from "./RequestUserBadge";
@ -49,64 +49,8 @@ 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="min-w-0 space-y-1">
<p className="app-text-muted text-[11px] font-medium uppercase tracking-wide">{label}</p>
<div className="min-w-0 text-sm text-[var(--foreground)]">{value}</div>
</div>
);
}
function RequestAudienceRow({
count,
currentUserId,
emptyText,
label,
people,
}: RequestAudienceRowProps) {
return (
<div className="rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-primary)] px-3 py-2.5">
<p className="app-text-muted text-[11px] font-medium uppercase tracking-wide">{label}</p>
{people.length > 0 ? (
<div className="mt-2 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 mt-2 text-sm">{count ? count : emptyText}</p>
)}
</div>
);
}
function renderDecisionMaker(decisionMaker: User | undefined, currentUserId?: number | null) {
if (!decisionMaker) {
return <span className="app-text-muted">-</span>;
}
if (!decisionMaker) return <span className="app-text-muted">—</span>;
const decisionMakerLink = userProfileLink(decisionMaker, currentUserId);
const decisionMakerName = displayUserName(decisionMaker);
@ -149,10 +93,10 @@ export function RequestListItem({
const requestAuthor = request.employee || request.created_by;
const authorName = displayUserName(requestAuthor);
const authorFallback = (requestAuthor?.first_name?.[0] || requestAuthor?.last_name?.[0] || "?").toUpperCase();
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 status = statusMeta[statusKey] ?? defaultStatusMeta;
const authorLink = requestAuthor ? userProfileLink(requestAuthor, currentUserId) : null;
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);
@ -162,35 +106,30 @@ 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;
const commentCount = request.comments_count ?? comments.length;
const decisionMaker = request.approver || request.assigned_to;
return (
<article
className={`app-surface-muted rounded-xl border border-transparent transition hover:border-[var(--border-strong)] ${isMenuOpen ? "relative z-20 overflow-visible" : "overflow-hidden"}`}
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 gap-2 pt-0.5">
<div className="flex shrink-0 flex-col items-center gap-3 pt-0.5">
<button
type="button"
onClick={() => onToggleRow(request.id)}
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 ? "Скрыть подробности" : "Показать подробности"}
className="app-action-secondary inline-flex h-8 w-8 items-center justify-center rounded-lg transition"
>
<ChevronDown size={15} className={`transition ${rowOpen ? "rotate-180" : ""}`} />
</button>
@ -199,7 +138,7 @@ export function RequestListItem({
title={canComment ? `Комментарии (${commentCount})` : "Комментарии для черновика недоступны"}
onClick={() => canComment && void onToggleComments(request.id)}
disabled={!canComment}
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"}`}
className="app-action-secondary relative inline-flex h-8 w-8 items-center justify-center rounded-lg disabled:cursor-not-allowed disabled:opacity-50"
>
<MessageSquare size={15} />
{commentCount > 0 ? (
@ -212,8 +151,8 @@ export function RequestListItem({
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-2.5">
<div className="flex flex-wrap items-center gap-2">
<div className="min-w-0 flex-1">
<div className="mb-2 flex items-center gap-2">
{authorLink ? (
<Link href={authorLink} className="group flex min-w-0 items-center gap-2">
<RequestAvatar
@ -236,74 +175,30 @@ export function RequestListItem({
<span className="truncate text-sm font-medium text-[var(--foreground)]">{authorName}</span>
</div>
)}
<span className="app-badge inline-flex rounded-full px-2 py-1 text-[11px] font-medium">
{typeLabel}
</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>
</div>
<div className="space-y-1.5">
<button
type="button"
onClick={() => onOpenDetails(request)}
className="block w-full text-left"
>
<h3
className={`${rowOpen ? "app-text-wrap line-clamp-3" : "app-text-wrap line-clamp-2"} text-sm font-semibold leading-6 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>
</div>
</div>
{summary ? (
<p
className={`${rowOpen ? "app-text-wrap line-clamp-4" : "app-text-wrap line-clamp-2"} text-sm leading-relaxed text-[var(--foreground)]`}
>
{summary}
</p>
) : null}
</div>
<div className="shrink-0 pl-1">
<div
ref={isMenuOpen ? menuRef : null}
className="flex max-w-[220px] flex-wrap items-center justify-end gap-2"
>
<div className="shrink-0">
<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}
</span>
{canProcess ? (
<>
<button
type="button"
title="Одобрить"
onClick={() => void onApprove(request.id)}
disabled={busyKey === `approve-${request.id}`}
className="app-feedback-success inline-flex h-8 w-8 items-center justify-center rounded-lg disabled:opacity-60"
>
<ThumbsUp size={16} />
</button>
<button
type="button"
title="Отклонить"
onClick={() => void onReject(request.id)}
disabled={busyKey === `reject-${request.id}`}
className="app-action-danger inline-flex h-8 w-8 items-center justify-center rounded-lg disabled:opacity-60"
>
<ThumbsDown size={16} />
</button>
</>
) : null}
{hasSecondaryActions ? (
<div className="relative">
<button
@ -371,135 +266,178 @@ export function RequestListItem({
</div>
</div>
{hasExpandedPanels ? (
<div className="mt-3 border-t border-[var(--border-subtle)] pt-3">
<div className="app-surface-elevated rounded-xl p-3">
{rowOpen ? (
<section className="space-y-3">
<div className="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-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium"
>
<Paperclip size={13} className="shrink-0" />
<span className="truncate">{attachmentName}</span>
</button>
) : null}
</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}
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,0.95fr)]">
<div className="grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2">
<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 className="space-y-2.5">
<RequestAudienceRow
label="Получатели"
people={recipients}
count={request.recipient_count}
currentUserId={currentUserId}
emptyText="-"
/>
<RequestAudienceRow
label="В копии"
people={ccUsers}
currentUserId={currentUserId}
emptyText="-"
/>
</div>
</div>
</section>
) : null}
{commentsOpen ? (
<section className={rowOpen ? "mt-3 border-t border-[var(--border-subtle)] pt-3" : ""}>
<div className="mb-2.5 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 rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-primary)] px-3 py-2.5 text-sm">
Комментариев пока нет
</p>
) : comments.map((comment) => (
<div
key={comment.id}
className="rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-primary)] px-3 py-2.5 text-sm text-[var(--foreground)]"
>
<div className="mb-1.5 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>
{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>
) : 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>{" "}
{renderDecisionMaker(decisionMaker, currentUserId)}
</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}` || !commentDraft.trim()}
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

@ -65,7 +65,7 @@ export function RequestListSection({
}: RequestListSectionProps) {
return (
<>
<div className="space-y-2.5">
<div className="space-y-3">
{requests.length === 0 ? (
<div className="app-surface-muted rounded-xl p-8 text-center">
<FileSignature size={22} className="app-text-muted mx-auto mb-2" />