refactor(frontend): restore requests visual baseline
This commit is contained in:
parent
5584c88d1c
commit
08518a6dc5
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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" />
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user