2667 lines
140 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AppShell } from "../../components/AppShell";
import { useUser } from "@/contexts/UserContext";
import Link from "next/link";
import { ProcurementRequestDetailContent } from "@/components/procurement/ProcurementRequestDetailContent";
import { ProcurementTaskLinks } from "@/components/procurement/ProcurementTaskLinks";
import ProcurementStatsPanel from "@/components/procurement/ProcurementStatsPanel";
import ProcurementSuppliersPanel from "@/components/procurement/ProcurementSuppliersPanel";
import TaskLinkPill from "@/components/tasks/TaskLinkPill";
import type {
ProcurementApprovalOptions,
ProcurementApprovalRouteOption,
ProcurementApprovalStepSelection,
ProcurementFulfillmentStatus,
ProcurementRequest,
ProcurementStatus,
UrgencyLevel,
} from "@/types/api";
import {
AlertTriangle,
ArrowUpDown,
CalendarDays,
Check,
ChevronDown,
ChevronRight,
ClipboardCheck,
Eye,
EyeOff,
Filter,
Link2,
Loader2,
Maximize2,
MessageSquare,
Minimize2,
Package,
Paperclip,
Pencil,
Play,
Plus,
Search,
Send,
ShoppingCart,
ThumbsDown,
ThumbsUp,
Trash2,
X,
XCircle,
} from "lucide-react";
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
import { SearchableSelectSingle } from "@/components/shared/SearchableSelect";
import { apiClient } from "@/lib/api";
import { formatDate, formatDateTime, formatMoney } from "@/lib/shared";
import { useProcurementPage } from "@/hooks/useProcurementPage";
import { Modal } from "@/components/ui";
import { getExpectedDeliveryDateBadgeClass } from "@/lib/procurementDates";
import { getProcurementQuantityUnitLabel, getProcurementUnitOptions } from "@/lib/procurementUnits";
import { useProcurementDesktopWideMode } from "@/hooks/useProcurementDesktopWideMode";
/* ══════════════════════════════════════════════════════
Constants & helpers
══════════════════════════════════════════════════════ */
const statusMeta: Record<ProcurementStatus, { label: string; cls: string }> = {
draft: { label: "Черновик", cls: "app-badge" },
waiting: { label: "Ожидает", cls: "app-feedback-warning" },
pending: { label: "На согласовании", cls: "app-feedback-approval" },
approved: { label: "Одобрено", cls: "app-feedback-success" },
in_progress: { label: "В работе", cls: "app-selected" },
completed: { label: "Завершено", cls: "app-feedback-success" },
rejected: { label: "Отклонено", cls: "app-feedback-danger" },
cancelled: { label: "Отменено", cls: "app-badge" },
};
const statusOptions = Object.entries(statusMeta) as [ProcurementStatus, { label: string; cls: string }][];
const defaultStatusMeta = { label: "—", cls: "app-badge" };
const getStatusMeta = (status?: string | null) => (
statusMeta[String(status || "").toLowerCase() as ProcurementStatus] ?? defaultStatusMeta
);
const fulfillmentStatusClasses: Record<ProcurementFulfillmentStatus, string> = {
pending: "app-badge",
partially_ordered: "app-selected",
ordered: "app-selected",
partially_received: "app-feedback-warning",
completed: "app-feedback-success",
issues: "app-feedback-warning",
};
const getFulfillmentStatusClass = (status?: string | null) => (
fulfillmentStatusClasses[String(status || "").toLowerCase() as ProcurementFulfillmentStatus] ?? "app-badge"
);
const fulfillmentStatusOptions: { value: ProcurementFulfillmentStatus; label: string }[] = [
{ value: "pending", label: "Не обработано" },
{ value: "partially_ordered", label: "Частично заказано" },
{ value: "ordered", label: "Заказано" },
{ value: "partially_received", label: "Частично получено" },
{ value: "completed", label: "Получено" },
{ value: "issues", label: "Есть проблемы" },
];
const getOrderedProgressClass = (ordered: number, total: number) => (
total > 0 && ordered >= total ? "app-feedback-success" : total > 0 && ordered > 0 ? "app-selected" : "app-badge"
);
const getReceivedProgressClass = (received: number, total: number) => {
if (total <= 0 || received <= 0) return "app-badge";
return received >= total ? "app-feedback-success" : "app-feedback-warning";
};
function newestCommentsFirst<T extends { created_at?: string | null; id?: number }>(comments: T[]): T[] {
return [...comments].sort((left, right) => {
const timeDifference = new Date(right.created_at || 0).getTime() - new Date(left.created_at || 0).getTime();
return timeDifference || Number(right.id || 0) - Number(left.id || 0);
});
}
const canConfirmAllReceived = (request: ProcurementRequest, isAuthor: boolean) => {
const status = String(request.status || "").toLowerCase();
const itemsCount = Number(request.items_total_count ?? request.items_count ?? request.items?.length ?? 0);
return Boolean(
isAuthor &&
itemsCount > 0 &&
request.fulfillment_status !== "completed" &&
["waiting", "in_progress"].includes(status)
);
};
const urgencyMeta: Record<string, { label: string; cls: string }> = {
low: { label: "Низкая", cls: "text-gray-500" },
medium: { label: "Средняя", cls: "text-sky-600" },
high: { label: "Высокая", cls: "text-amber-600" },
critical: { label: "Критическая", cls: "text-rose-600" },
};
const orderingOptions = [
{ value: "-created_at", label: "Сначала новые" },
{ value: "created_at", label: "Сначала старые" },
{ value: "title", label: "По названию" },
{ value: "urgency", label: "По срочности ↑" },
{ value: "-urgency", label: "По срочности ↓" },
];
type ScopeTab = "all" | "mine" | "department" | "processing_department" | "pending_approvals" | "my_work" | "available";
const scopeTabs: { value: ScopeTab; label: string }[] = [
{ value: "all", label: "Все" },
{ value: "mine", label: "Мои" },
{ value: "department", label: "Отдел-заказчик" },
{ value: "processing_department", label: "Отдел-исполнитель" },
{ value: "pending_approvals", label: "На согласование" },
{ value: "my_work", label: "В работе у меня" },
{ value: "available", label: "Доступные" },
];
const periodOptions = [
{ value: "", label: "Любой период" },
{ value: "today", label: "Сегодня" },
{ value: "week", label: "7 дней" },
{ value: "month", label: "30 дней" },
{ value: "quarter", label: "90 дней" },
];
const fmt = formatDate;
const fmtDateTime = formatDateTime;
const money = formatMoney;
type RequestActionDialogKind = "approve" | "reject" | "cancel" | "delete";
type SubmitApprovalMode = "auto" | "manual";
const getApprovalRouteAmountLabel = (option: ProcurementApprovalRouteOption) => {
if (option.min_amount === null || option.min_amount === undefined || option.min_amount === "") {
return "для любой суммы";
}
return `от ${money(option.min_amount)}`;
};
const getRequestListDateLabel = (request: ProcurementRequest, status: string) => {
const createdAt = fmt(request.created_at);
if (status === "completed" && request.completed_at) {
const completedAt = fmt(request.completed_at);
return completedAt ? `${createdAt} - ${completedAt}` : createdAt;
}
return createdAt;
};
const getReadableError = (error: unknown, fallback: string): string => {
const raw = String((error as Error)?.message || fallback);
const jsonStart = raw.indexOf("{");
const payload = jsonStart >= 0 ? raw.slice(jsonStart) : raw;
try {
const parsed = JSON.parse(payload) as Record<string, unknown>;
if (typeof parsed.error === "string" && parsed.error.trim()) {
return parsed.error;
}
if (typeof parsed.detail === "string" && parsed.detail.trim()) {
return parsed.detail;
}
} catch {
return raw;
}
return raw;
};
function ProcurementRequestActionButtons({
request,
busyKey,
canManage,
canSubmitForApproval,
isAuthor,
isExecutor,
isFinal,
showLabels = false,
showApprovalActions = true,
showSecondaryActions = true,
onSubmit,
onEdit,
onDelete,
onApprove,
onReject,
onStart,
onComplete,
onMarkAllReceived,
onNotifyArrival,
onCancel,
}: {
request: ProcurementRequest;
busyKey: string | null;
canManage: boolean;
canSubmitForApproval: boolean;
isAuthor: boolean;
isExecutor: boolean;
isFinal: (status?: string) => boolean;
showLabels?: boolean;
showApprovalActions?: boolean;
showSecondaryActions?: boolean;
onSubmit: (id: number) => void | Promise<unknown>;
onEdit: (request: ProcurementRequest) => void;
onDelete: (id: number) => void | Promise<unknown>;
onApprove: (id: number) => void | Promise<unknown>;
onReject: (id: number) => void | Promise<unknown>;
onStart: (id: number) => void | Promise<unknown>;
onComplete: (id: number) => void | Promise<unknown>;
onMarkAllReceived: (id: number) => void | Promise<unknown>;
onNotifyArrival: (id: number) => void | Promise<unknown>;
onCancel: (id: number) => void | Promise<unknown>;
}) {
const status = String(request.status || "").toLowerCase();
const isDraft = status === "draft";
const isPending = status === "pending";
const canStartWork = Boolean(request.can_current_user_start_work);
const isInProgress = status === "in_progress";
const canNotifyArrival = Boolean(
request.can_current_user_process_items &&
(status === "waiting" || status === "in_progress")
);
const canConfirmRequestReceived = canConfirmAllReceived(request, isAuthor);
const startWorkLabel = request.executor ? "Забрать в работу" : "Взять в работу";
const canApproveThis = Boolean(request.can_current_user_approve);
const buttonClass = (variantClass: string) =>
`${variantClass} inline-flex h-9 items-center justify-center rounded-lg disabled:opacity-60 ${
showLabels ? "gap-1.5 px-3 text-xs font-medium" : "w-9"
}`;
const label = (text: string) => (showLabels ? <span>{text}</span> : null);
const approvalIconSize = showLabels ? 14 : 18;
return (
<div className="flex flex-wrap items-center gap-1.5 pt-1">
{canSubmitForApproval ? (
<button
type="button"
onClick={() => onSubmit(request.id)}
disabled={busyKey === `submit-${request.id}`}
title="Отправить на согласование"
className={buttonClass("app-action-approval")}
>
<Send size={14} />
{label("Отправить на согласование")}
</button>
) : null}
{showSecondaryActions && isDraft && isAuthor ? (
<button
type="button"
onClick={() => onEdit(request)}
title="Редактировать"
className={buttonClass("app-action-secondary")}
>
<Pencil size={14} />
{label("Изменить")}
</button>
) : null}
{showSecondaryActions && isDraft && (isAuthor || canManage) ? (
<button
type="button"
onClick={() => onDelete(request.id)}
disabled={busyKey === `delete-${request.id}`}
title="Удалить"
className={buttonClass("app-action-danger")}
>
<Trash2 size={14} />
{label("Удалить")}
</button>
) : null}
{showApprovalActions && isPending && canApproveThis ? (
<>
<button
type="button"
onClick={() => onApprove(request.id)}
disabled={busyKey === `approve-${request.id}`}
title="Одобрить"
className={buttonClass("app-action-approve")}
>
<ThumbsUp size={approvalIconSize} className="text-emerald-500" />
{showLabels ? <span className="text-emerald-500">Одобрить</span> : null}
</button>
<button
type="button"
onClick={() => onReject(request.id)}
disabled={busyKey === `reject-${request.id}`}
title="Отклонить"
className={buttonClass("app-action-reject")}
>
<ThumbsDown size={approvalIconSize} />
{label("Отклонить")}
</button>
</>
) : null}
{canStartWork ? (
<button
type="button"
onClick={() => onStart(request.id)}
disabled={busyKey === `start-${request.id}`}
title={startWorkLabel}
className={buttonClass("app-action-primary")}
>
<Play size={14} />
{label(startWorkLabel)}
</button>
) : null}
{(isInProgress && isExecutor) || canConfirmRequestReceived ? (
<button
type="button"
onClick={() => (
canConfirmRequestReceived
? onMarkAllReceived(request.id)
: onComplete(request.id)
)}
disabled={busyKey === `${canConfirmRequestReceived ? "mark-all" : "complete"}-${request.id}`}
title={canConfirmRequestReceived ? "Подтвердить получение всей заявки" : "Закрыть заявку"}
className={buttonClass("app-action-success")}
>
<ClipboardCheck size={14} />
{label(canConfirmRequestReceived ? "Подтвердить получение" : "Закрыть заявку")}
</button>
) : null}
{canNotifyArrival ? (
<button
type="button"
onClick={() => onNotifyArrival(request.id)}
disabled={busyKey === `notify-arrival-${request.id}`}
title="Уведомить о поступлении"
className={buttonClass("app-action-notify")}
>
<Package size={14} />
{label("Уведомить о поступлении")}
</button>
) : null}
{showSecondaryActions && isAuthor && !isFinal(status) && status !== "draft" ? (
<button
type="button"
onClick={() => onCancel(request.id)}
disabled={busyKey === `cancel-${request.id}`}
title="Отменить"
className={buttonClass("app-action-secondary")}
>
<XCircle size={14} />
{label("Отменить")}
</button>
) : null}
</div>
);
}
function InfiniteRequestLoader({
hasMore,
loading,
onLoadMore,
}: {
hasMore: boolean;
loading: boolean;
onLoadMore: () => void | Promise<unknown>;
}) {
const sentinelRef = useRef<HTMLDivElement | null>(null);
const requestedRef = useRef(false);
useEffect(() => {
if (!loading) requestedRef.current = false;
}, [loading]);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel || !hasMore) return;
const observer = new IntersectionObserver((entries) => {
if (!entries.some((entry) => entry.isIntersecting) || loading || requestedRef.current) return;
requestedRef.current = true;
void onLoadMore();
}, { rootMargin: "240px 0px" });
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMore, loading, onLoadMore]);
if (!hasMore) return null;
return (
<div ref={sentinelRef} className="flex min-h-10 items-center justify-center py-2" aria-live="polite">
{loading ? (
<span className="app-text-muted inline-flex items-center gap-2 text-xs">
<Loader2 size={14} className="animate-spin" /> Загружаем ещё заявки…
</span>
) : null}
</div>
);
}
/* ══════════════════════════════════════════════════════
Main page component
══════════════════════════════════════════════════════ */
export default function ProcurementPage() {
const { user } = useUser();
const [desktopWideMode, changeDesktopWideMode, desktopWideLayout] = useProcurementDesktopWideMode(user?.id);
const {
activeFilterCount,
activeSection,
actionError,
addItemRow,
busyKey,
canManage,
canSupplierManage,
closeModal,
departmentFilter,
departments,
employees,
processingDepartments,
detailsCache,
displayUserName,
ensureRequestDetail,
ensureCommentsLoaded,
error,
commentsMap,
commentDrafts,
setCommentDrafts,
expandedItemComments,
itemCommentsMap,
itemCommentDrafts,
itemCommentFiles,
setItemCommentDrafts,
expandedIds,
expandedComments,
filteredRequests,
filtersOpen,
form,
getDeptName,
getRequestAmount,
handleApprove,
handleCancel,
handleComplete,
handleMarkAllReceived,
handleNotifyArrival,
handleToggleViewed,
handleReportItemIssue,
handleCancelItemIssue,
handleConfirmItemOrdered,
handleConfirmItemReceived,
handleCancelItemReceived,
handleUpdateItem,
handleDelete,
handleLoadMore,
handleReject,
handleSave,
handleStart,
handleSubmit,
isFinal,
isModalOpen,
loadPage1,
loading,
loadingMore,
modalMode,
nextPage,
openCreate,
openEdit,
ordering,
periodFilter,
processingDepartmentFilter,
requestorFilter,
executorFilter,
fulfillmentFilter,
dateFromFilter,
dateToFilter,
removeItemRow,
resolveUserId,
requests,
searchQuery,
setDepartmentFilter,
setProcessingDepartmentFilter,
setRequestorFilter,
setExecutorFilter,
setFulfillmentFilter,
setFiltersOpen,
setForm,
setOrdering,
setPeriodFilter,
setDateFromFilter,
setDateToFilter,
setScope,
setSearchQuery,
setStatusFilter,
setUrgencyFilter,
statusFilter,
toggleExpand,
toggleComments,
updateItemRow,
urgencyFilter,
userLink,
scope,
scopeCounts,
unreadProcurementRequestCounts,
unreadProcurementItemCommentCounts,
handleAddComment,
handleDeleteComment,
handleAddItemComment,
handleItemCommentFilesChange,
handleDeleteItemComment,
handleDownloadItemAttachment,
handleUploadItemAttachments,
handleDeleteItemAttachment,
markRequestNotificationsRead,
toggleItemComments,
} = useProcurementPage(user);
const processingDepartmentOptions = processingDepartments.length > 0
? processingDepartments
: departments;
const userDepartmentIds = useMemo(
() => new Set((user?.departments || []).map((department) => Number(department.id))),
[user?.departments],
);
const userDepartmentsKnown = Array.isArray(user?.departments);
const canCurrentUserSubmitRequest = useCallback(
(request: ProcurementRequest) => {
const processingDepartmentId = Number(request.processing_department || 0);
const isProcessingDepartmentMember = (
!userDepartmentsKnown ||
userDepartmentIds.has(processingDepartmentId)
);
return Boolean(
request.can_current_user_submit_for_approval &&
user?.id &&
processingDepartmentId &&
isProcessingDepartmentMember
);
},
[
user?.id,
userDepartmentIds,
userDepartmentsKnown,
],
);
const toggleStatusFilter = useCallback((status: ProcurementStatus) => {
setStatusFilter((current) => (
current.includes(status)
? current.filter((item) => item !== status)
: [...current, status]
));
}, [setStatusFilter]);
const [detailModalId, setDetailModalId] = useState<number | null>(null);
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
const [detailModalLoading, setDetailModalLoading] = useState(false);
const [detailModalError, setDetailModalError] = useState<string | null>(null);
const initialRequestParamHandledRef = useRef(false);
const procurementMenuRef = useRef<HTMLDivElement | null>(null);
const statusFilterRef = useRef<HTMLDivElement | null>(null);
const [procurementMenuOpenId, setProcurementMenuOpenId] = useState<number | null>(null);
const [statusFilterOpen, setStatusFilterOpen] = useState(false);
const [taskLinkRequest, setTaskLinkRequest] = useState<ProcurementRequest | null>(null);
const [requestActionDialog, setRequestActionDialog] = useState<{ kind: RequestActionDialogKind; requestId: number } | null>(null);
const [requestActionComment, setRequestActionComment] = useState("");
const [submitApprovalRequestId, setSubmitApprovalRequestId] = useState<number | null>(null);
const [submitApprovalOptions, setSubmitApprovalOptions] = useState<ProcurementApprovalOptions | null>(null);
const [submitApprovalLoading, setSubmitApprovalLoading] = useState(false);
const [submitApprovalError, setSubmitApprovalError] = useState<string | null>(null);
const [submitApprovalMode, setSubmitApprovalMode] = useState<SubmitApprovalMode>("auto");
const [manualApprovalPriorities, setManualApprovalPriorities] = useState<number[]>([]);
const [manualApproverByPriority, setManualApproverByPriority] = useState<Record<number, number | null>>({});
const statusFilterSummary = useMemo(() => {
if (statusFilter.length === 0) return "Все статусы";
const labels = statusFilter
.map((status) => statusMeta[status]?.label)
.filter(Boolean);
if (labels.length <= 2) return labels.join(", ");
return `Выбрано статусов: ${labels.length}`;
}, [statusFilter]);
const syncRequestUrl = useCallback((requestId: number | null) => {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
if (requestId) {
url.searchParams.set("request", String(requestId));
} else {
url.searchParams.delete("request");
}
window.history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`);
}, []);
const selectedRequest = useMemo(() => {
if (!detailModalId) return null;
return detailsCache[detailModalId] || requests.find((request) => request.id === detailModalId) || null;
}, [detailModalId, detailsCache, requests]);
const selectedActionRequest = useMemo(() => {
if (!requestActionDialog) return null;
return detailsCache[requestActionDialog.requestId] || requests.find((request) => request.id === requestActionDialog.requestId) || null;
}, [detailsCache, requestActionDialog, requests]);
const selectedSubmitRequest = useMemo(() => {
if (!submitApprovalRequestId) return null;
return detailsCache[submitApprovalRequestId] || requests.find((request) => request.id === submitApprovalRequestId) || null;
}, [detailsCache, requests, submitApprovalRequestId]);
const closeDetailModal = useCallback(() => {
setDetailDialogOpen(false);
setDetailModalId(null);
setDetailModalError(null);
syncRequestUrl(null);
}, [syncRequestUrl]);
const openRequestActionDialog = useCallback((kind: RequestActionDialogKind, requestId: number) => {
setRequestActionComment("");
setRequestActionDialog({ kind, requestId });
}, []);
const closeRequestActionDialog = useCallback(() => {
setRequestActionDialog(null);
setRequestActionComment("");
}, []);
const closeSubmitApprovalDialog = useCallback(() => {
setSubmitApprovalRequestId(null);
setSubmitApprovalOptions(null);
setSubmitApprovalError(null);
setSubmitApprovalLoading(false);
setSubmitApprovalMode("auto");
setManualApprovalPriorities([]);
setManualApproverByPriority({});
}, []);
const handleTaskLinked = useCallback(async (requestId: number) => {
await Promise.all([
loadPage1(),
ensureRequestDetail(requestId),
]);
}, [ensureRequestDetail, loadPage1]);
const openSubmitApprovalDialog = useCallback(async (requestId: number) => {
const requestForSubmit = detailsCache[requestId] || requests.find((request) => request.id === requestId);
if (requestForSubmit && !canCurrentUserSubmitRequest(requestForSubmit)) {
setSubmitApprovalRequestId(requestId);
setSubmitApprovalOptions(null);
setSubmitApprovalLoading(false);
setSubmitApprovalMode("auto");
setManualApprovalPriorities([]);
setManualApproverByPriority({});
setSubmitApprovalError("Отправить на согласование может только сотрудник отдела-получателя, не автор заявки.");
return;
}
setSubmitApprovalRequestId(requestId);
setSubmitApprovalOptions(null);
setSubmitApprovalError(null);
setSubmitApprovalMode("auto");
setManualApprovalPriorities([]);
setManualApproverByPriority({});
setSubmitApprovalLoading(true);
try {
const options = await apiClient.getProcurementApprovalOptions(requestId);
const approverMap = Object.fromEntries(
options.available_steps.map((option) => [
option.priority,
option.approver?.id ?? null,
]),
) as Record<number, number | null>;
setSubmitApprovalOptions(options);
setManualApprovalPriorities(options.auto_steps.map((option) => option.priority));
setManualApproverByPriority(approverMap);
} catch (approvalError) {
setSubmitApprovalError(getReadableError(approvalError, "Не удалось загрузить согласующих"));
} finally {
setSubmitApprovalLoading(false);
}
}, [
canCurrentUserSubmitRequest,
detailsCache,
requests,
]);
const toggleManualApprovalPriority = useCallback((priority: number) => {
setSubmitApprovalMode("manual");
setManualApprovalPriorities((current) => (
current.includes(priority)
? current.filter((item) => item !== priority)
: [...current, priority].sort((left, right) => left - right)
));
}, []);
const submitApprovalDialog = useCallback(async () => {
if (!submitApprovalRequestId) return;
let approvalSteps: ProcurementApprovalStepSelection[] | undefined;
if (submitApprovalMode === "manual") {
if (manualApprovalPriorities.length === 0) {
setSubmitApprovalError("Выберите хотя бы одного согласующего.");
return;
}
if (!submitApprovalOptions) {
setSubmitApprovalError("Не удалось определить список этапов согласования.");
return;
}
const optionsByPriority = new Map(
submitApprovalOptions.available_steps.map((option) => [option.priority, option]),
);
approvalSteps = manualApprovalPriorities.map((priority) => {
const option = optionsByPriority.get(priority);
const approver = manualApproverByPriority[priority];
return {
priority,
approver: approver || 0,
step_name: option?.step_name,
};
});
const missingStep = approvalSteps.find((step) => !step.approver);
if (missingStep) {
const option = optionsByPriority.get(missingStep.priority);
setSubmitApprovalError(`Выберите согласующего для этапа «${option?.step_name || `Этап ${missingStep.priority}`}».`);
return;
}
}
setSubmitApprovalError(null);
const success = await handleSubmit(submitApprovalRequestId, approvalSteps);
if (success) {
closeSubmitApprovalDialog();
}
}, [
closeSubmitApprovalDialog,
handleSubmit,
manualApprovalPriorities,
manualApproverByPriority,
submitApprovalMode,
submitApprovalOptions,
submitApprovalRequestId,
]);
const submitRequestActionDialog = useCallback(async () => {
if (!requestActionDialog) return;
const { kind, requestId } = requestActionDialog;
const payload = requestActionComment.trim();
let success = false;
if (kind === "approve") {
success = await handleApprove(requestId, payload);
} else if (kind === "reject") {
success = await handleReject(requestId, payload);
} else if (kind === "cancel") {
success = await handleCancel(requestId, payload);
} else if (kind === "delete") {
success = await handleDelete(requestId);
}
if (success) {
closeRequestActionDialog();
if (detailModalId === requestId && kind === "delete") {
closeDetailModal();
}
}
}, [
closeDetailModal,
closeRequestActionDialog,
detailModalId,
handleApprove,
handleCancel,
handleDelete,
handleReject,
requestActionComment,
requestActionDialog,
]);
const openDetailModal = useCallback(async (requestId: number) => {
setDetailModalLoading(true);
setDetailModalError(null);
setDetailModalId(requestId);
setDetailDialogOpen(!desktopWideLayout);
syncRequestUrl(requestId);
try {
await ensureRequestDetail(requestId);
await markRequestNotificationsRead(requestId);
} catch (detailError) {
setDetailModalError(getReadableError(detailError, "Не удалось открыть заявку"));
} finally {
setDetailModalLoading(false);
}
}, [desktopWideLayout, ensureRequestDetail, markRequestNotificationsRead, syncRequestUrl]);
useEffect(() => {
if (typeof window === "undefined") return;
if (initialRequestParamHandledRef.current) return;
initialRequestParamHandledRef.current = true;
const requestParam = new URLSearchParams(window.location.search).get("request");
if (!requestParam) return;
const requestId = Number(requestParam);
if (!Number.isFinite(requestId) || requestId <= 0) {
syncRequestUrl(null);
return;
}
void openDetailModal(requestId);
}, [openDetailModal, syncRequestUrl]);
useEffect(() => {
if (!detailModalId) return;
if (selectedRequest) return;
closeDetailModal();
}, [closeDetailModal, detailModalId, selectedRequest]);
useEffect(() => {
if (!detailModalId) return;
void ensureCommentsLoaded(detailModalId).catch(() => {});
}, [detailModalId, ensureCommentsLoaded]);
useEffect(() => {
if (procurementMenuOpenId === null) return;
const handlePointerDown = (event: MouseEvent) => {
if (procurementMenuRef.current && !procurementMenuRef.current.contains(event.target as Node)) {
setProcurementMenuOpenId(null);
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setProcurementMenuOpenId(null);
}
};
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleEscape);
};
}, [procurementMenuOpenId]);
useEffect(() => {
if (!statusFilterOpen) return;
const handlePointerDown = (event: MouseEvent) => {
if (statusFilterRef.current && !statusFilterRef.current.contains(event.target as Node)) {
setStatusFilterOpen(false);
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setStatusFilterOpen(false);
}
};
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleEscape);
};
}, [statusFilterOpen]);
useEffect(() => {
if (!filtersOpen) {
setStatusFilterOpen(false);
}
}, [filtersOpen]);
/* ══════════════════════════════════════════════════════
RENDER
══════════════════════════════════════════════════════ */
return (
<AppShell
desktopWideMode={desktopWideMode}
onDesktopWideModeChange={changeDesktopWideMode}
>
{loading ? (
<div className="app-surface rounded-2xl p-8 text-center">
<Loader2 size={28} className="mx-auto mb-3 animate-spin text-sky-500" />
<p className="app-text-muted text-sm">Загрузка заявок на закупку...</p>
</div>
) : error ? (
<div className="app-feedback-danger rounded-2xl p-6 text-center">
<p className="text-sm text-red-800">{error}</p>
</div>
) : (
<section className={`app-surface rounded-2xl p-4 ${desktopWideMode ? "lg:min-h-0 lg:flex-1 lg:flex lg:flex-col lg:overflow-hidden" : ""}`}>
{/* ── header ── */}
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div>
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">Закупки</p>
{/* <div className="mt-2 flex flex-wrap gap-2">
<button type="button" onClick={() => setActiveSection("requests")} className={`rounded-full px-3 py-1.5 text-xs font-medium transition ${activeSection === "requests" ? "app-pill-active" : "app-pill"}`}>
Заявки
</button>
<button type="button" onClick={() => setActiveSection("stats")} className={`rounded-full px-3 py-1.5 text-xs font-medium transition ${activeSection === "stats" ? "app-pill-active" : "app-pill"}`}>
Статистика
</button>
<button type="button" onClick={() => setActiveSection("suppliers")} className={`rounded-full px-3 py-1.5 text-xs font-medium transition ${activeSection === "suppliers" ? "app-pill-active" : "app-pill"}`}>
Поставщики
</button>
</div> */}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => changeDesktopWideMode(!desktopWideMode)}
className="app-action-ghost hidden h-9 w-9 items-center justify-center rounded-lg lg:inline-flex"
title={desktopWideMode ? "Вернуть обычный вид" : "Развернуть закупки"}
aria-label={desktopWideMode ? "Вернуть обычный вид" : "Развернуть закупки"}
aria-pressed={desktopWideMode}
>
{desktopWideMode ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
</button>
{activeSection === "requests" && (
<button type="button" onClick={openCreate} className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium transition">
<Plus size={14} /> Создать заявку
</button>
)}
</div>
</div>
{/* ── alerts ── */}
{actionError && <p className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{actionError}</p>}
{activeSection === "stats" && <ProcurementStatsPanel />}
{activeSection === "suppliers" && (
<ProcurementSuppliersPanel canManage={canSupplierManage} />
)}
{activeSection === "requests" && (
<>
{/* ── search + filter ── */}
<div className="mb-4 flex items-center gap-2">
<div className="relative flex-1">
<Search size={16} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") loadPage1(); }}
placeholder="Поиск по заявкам..."
className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm"
/>
</div>
<button
type="button"
title="Фильтры"
onClick={() => setFiltersOpen((v) => !v)}
className={`relative inline-flex items-center justify-center rounded-lg p-2.5 transition ${filtersOpen ? "app-selected app-accent-text" : "app-surface-muted app-text-muted hover:bg-[var(--surface-tertiary)]"}`}
>
<Filter size={16} />
{activeFilterCount > 0 && (
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-sky-500 px-1 text-[10px] font-bold text-white">{activeFilterCount}</span>
)}
</button>
<div className="relative w-[148px] shrink-0">
<ArrowUpDown size={15} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
<select
value={ordering}
onChange={(e) => setOrdering(e.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="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400" />
</div>
</div>
{/* ── scope tabs ── */}
<div className="mb-4 flex flex-wrap gap-2">
{scopeTabs.map((tab) => (
<button
key={tab.value}
type="button"
onClick={() => setScope(tab.value)}
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
scope === tab.value
? "app-pill-active"
: "app-pill"
}`}
>
<span>{tab.label}</span>
<span className={`app-badge px-1.5 py-0.5 text-[10px] font-bold ${
scope === tab.value ? "app-pill-count-active" : "app-pill-count"
}`}>
{scopeCounts[tab.value] ?? 0}
</span>
</button>
))}
</div>
{/* ── filters panel ── */}
{filtersOpen && (
<div className="app-surface-muted mb-3 flex flex-col gap-2 rounded-xl p-3">
<div ref={statusFilterRef} className="relative">
<button
type="button"
onClick={() => setStatusFilterOpen((current) => !current)}
aria-expanded={statusFilterOpen}
className="app-select flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-left text-sm"
>
<span className={statusFilter.length > 0 ? "truncate text-[var(--foreground)]" : "app-text-muted truncate"}>
{statusFilterSummary}
</span>
<span className="flex shrink-0 items-center gap-2">
{statusFilter.length > 0 ? (
<span className="app-badge rounded-full px-1.5 py-0.5 text-[10px] font-bold">
{statusFilter.length}
</span>
) : null}
<ChevronDown size={14} className={`app-text-muted transition ${statusFilterOpen ? "rotate-180" : ""}`} />
</span>
</button>
{statusFilterOpen ? (
<div className="app-menu absolute inset-x-0 z-50 mt-1 overflow-hidden rounded-lg">
<div className="app-divider flex items-center justify-between gap-2 border-b px-3 py-2">
<span className="app-text-muted text-xs font-semibold uppercase">Статусы</span>
{statusFilter.length > 0 ? (
<button
type="button"
onClick={() => setStatusFilter([])}
className="app-link-accent text-xs font-medium"
>
Сбросить
</button>
) : null}
</div>
<div className="max-h-64 overflow-y-auto p-1">
{statusOptions.map(([key, meta]) => (
<label
key={key}
className={`flex cursor-pointer items-center gap-2 rounded-md px-2 py-2 text-sm transition ${
statusFilter.includes(key)
? "app-selected app-accent-text font-medium"
: "hover:bg-[var(--surface-secondary)]"
}`}
>
<input
type="checkbox"
checked={statusFilter.includes(key)}
onChange={() => toggleStatusFilter(key)}
className="h-4 w-4 rounded border-[var(--border-strong)] accent-[var(--accent-primary)]"
/>
<span className="min-w-0 flex-1 truncate">{meta.label}</span>
</label>
))}
</div>
</div>
) : null}
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
<label className="min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Срочность</span>
<select value={urgencyFilter} onChange={(e) => setUrgencyFilter(e.target.value)} className="app-select w-full rounded-lg px-3 py-2 text-sm">
<option value="">Все уровни срочности</option>
{Object.entries(urgencyMeta).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
</label>
<label className="min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Статус исполнения</span>
<select value={fulfillmentFilter} onChange={(e) => setFulfillmentFilter(e.target.value)} className="app-select w-full rounded-lg px-3 py-2 text-sm">
<option value="">Все статусы исполнения</option>
{fulfillmentStatusOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Отдел-заказчик</span>
<select value={departmentFilter} onChange={(e) => setDepartmentFilter(e.target.value)} className="app-select w-full rounded-lg px-3 py-2 text-sm">
<option value="">Все отделы-заказчики</option>
{departments.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
</select>
</label>
<label className="min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Отдел-исполнитель</span>
<select value={processingDepartmentFilter} onChange={(e) => setProcessingDepartmentFilter(e.target.value)} className="app-select w-full rounded-lg px-3 py-2 text-sm">
<option value="">Все отделы-исполнители</option>
{processingDepartmentOptions.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
</select>
</label>
<label className="min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Заказчик</span>
<select value={requestorFilter} onChange={(e) => setRequestorFilter(e.target.value)} className="app-select w-full rounded-lg px-3 py-2 text-sm">
<option value="">Все заказчики</option>
{employees.map((employee) => (
<option key={employee.id} value={employee.id}>
{displayUserName(employee)}
</option>
))}
</select>
</label>
<label className="min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Исполнитель</span>
<select value={executorFilter} onChange={(e) => setExecutorFilter(e.target.value)} className="app-select w-full rounded-lg px-3 py-2 text-sm">
<option value="">Все исполнители</option>
{employees.map((employee) => (
<option key={employee.id} value={employee.id}>
{displayUserName(employee)}
</option>
))}
</select>
</label>
<label className="min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Быстрый период</span>
<select
value={periodFilter}
onChange={(e) => {
setPeriodFilter(e.target.value);
if (e.target.value) {
setDateFromFilter("");
setDateToFilter("");
}
}}
className="app-select w-full rounded-lg px-3 py-2 text-sm"
>
{periodOptions.map((option) => <option key={option.value || "all"} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Создано с</span>
<input
type="date"
value={dateFromFilter}
onChange={(e) => {
setDateFromFilter(e.target.value);
if (e.target.value) setPeriodFilter("");
}}
className="app-input w-full rounded-lg px-3 py-2 text-sm"
/>
</label>
<label className="min-w-0">
<span className="app-text-muted mb-1 block text-xs font-medium">Создано по</span>
<input
type="date"
value={dateToFilter}
onChange={(e) => {
setDateToFilter(e.target.value);
if (e.target.value) setPeriodFilter("");
}}
className="app-input w-full rounded-lg px-3 py-2 text-sm"
/>
</label>
</div>
{activeFilterCount > 0 && (
<button
type="button"
onClick={() => {
setStatusFilter([]);
setUrgencyFilter("");
setFulfillmentFilter("");
setDepartmentFilter("");
setProcessingDepartmentFilter("");
setRequestorFilter("");
setExecutorFilter("");
setPeriodFilter("");
setDateFromFilter("");
setDateToFilter("");
}}
className="app-action-secondary rounded-lg px-3 py-2 text-sm font-medium transition"
>
Очистить фильтры
</button>
)}
</div>
)}
{/* ══════════ Wide master-detail workspace ══════════ */}
<div className={`${desktopWideMode ? "hidden lg:grid" : "hidden"} min-h-0 flex-1 grid-cols-[minmax(300px,380px)_minmax(0,1fr)] gap-3`}>
<aside className="app-surface-muted min-h-0 overflow-y-auto rounded-xl border border-[var(--border-subtle)]" aria-label="Список заявок на закупку">
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-[var(--border-subtle)] bg-[var(--surface-primary)] px-3 py-2.5">
<span className="app-card-caption">Заявки</span>
<span className="app-counter inline-flex h-5 min-w-5 items-center justify-center px-1 text-[10px] font-bold">{filteredRequests.length}</span>
</div>
<div className="space-y-2 p-2">
{filteredRequests.length === 0 ? (
<div className="px-3 py-10 text-center">
<ShoppingCart size={22} className="app-text-muted mx-auto mb-2" />
<p className="app-text-muted text-sm">Заявок на закупку не найдено</p>
</div>
) : filteredRequests.map((req) => {
const status = getStatusMeta(req.status);
const urgency = urgencyMeta[req.urgency] ?? urgencyMeta.medium;
const active = detailModalId === req.id;
const amount = getRequestAmount(req);
const resolvedDetail = detailsCache[req.id] || req;
const detailItems = resolvedDetail.items || req.items || [];
const itemsTotalCount = resolvedDetail.items_total_count ?? req.items_total_count ?? resolvedDetail.items_count ?? req.items_count ?? detailItems.length;
const itemsReceivedCount = resolvedDetail.items_received_count ?? req.items_received_count ?? detailItems.filter((item) => item.execution_status === "received").length;
const itemsProblemCount = resolvedDetail.items_problem_count ?? req.items_problem_count ?? detailItems.filter((item) => ["rejected", "completed_with_issue", "edited", "defective"].includes(String(item.execution_status || ""))).length;
const totalRequestedQuantity = resolvedDetail.total_requested_quantity ?? req.total_requested_quantity ?? detailItems.reduce((sum, item) => sum + Number(item.quantity || 0), 0);
const totalOrderedQuantity = resolvedDetail.total_ordered_quantity ?? req.total_ordered_quantity ?? detailItems.reduce((sum, item) => sum + Number(item.ordered_quantity || 0), 0);
const totalReceivedQuantity = resolvedDetail.total_received_quantity ?? req.total_received_quantity ?? detailItems.reduce((sum, item) => sum + Number(item.received_quantity || 0), 0);
const quantityUnitLabel = getProcurementQuantityUnitLabel(detailItems, resolvedDetail.quantity_unit_label ?? req.quantity_unit_label);
const fulfillmentStatus = resolvedDetail.fulfillment_status ?? req.fulfillment_status;
const fulfillmentStatusDisplay = resolvedDetail.fulfillment_status_display ?? req.fulfillment_status_display;
const nextExpectedDeliveryDate = resolvedDetail.next_expected_delivery_date ?? req.next_expected_delivery_date ?? null;
const isRequestFullyReceived = totalRequestedQuantity > 0 && totalReceivedQuantity >= totalRequestedQuantity;
const requestorName = displayUserName(req.requestor, req.requestor_name, req.requestor_email);
const executorName = req.executor || req.executor_name ? displayUserName(req.executor, req.executor_name || undefined) : "не назначен";
const isViewed = Boolean(resolvedDetail.is_viewed ?? req.is_viewed);
const unreadNotificationCount = unreadProcurementRequestCounts[req.id] || 0;
return (
<article
key={req.id}
className={`${active ? "app-selected border-[var(--accent-primary)]" : "app-surface hover:border-[var(--border-strong)]"} relative rounded-xl border ${isViewed ? "p-2.5" : "p-3"} transition`}
aria-current={active ? "true" : undefined}
>
<button type="button" onClick={() => void openDetailModal(req.id)} className="block w-full text-left">
<span className="flex min-w-0 items-start justify-between gap-2">
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-1.5">
{unreadNotificationCount > 0 ? (
<span className="app-counter inline-flex h-5 min-w-5 shrink-0 items-center justify-center px-1 text-[10px] font-bold text-white" title={`Новых уведомлений: ${unreadNotificationCount}`} aria-label={`Новых уведомлений: ${unreadNotificationCount}`}>
{unreadNotificationCount > 99 ? "99+" : unreadNotificationCount}
</span>
) : null}
<span className="truncate text-sm font-semibold text-[var(--foreground)]">#{req.id} · {req.title || "Без названия"}</span>
</span>
<span className="app-text-muted mt-1 block truncate text-[11px]">Отдел-заказчик: <span className="text-[var(--foreground)]">{getDeptName(req)}</span></span>
</span>
<span className="flex shrink-0 flex-col items-end gap-1">
<span className={`app-status-pill ${status.cls}`}>{status.label}</span>
{!isViewed ? <span className={`text-[10px] font-medium ${urgency.cls}`}>{urgency.label} срочность</span> : null}
</span>
</span>
<span className="app-text-muted mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px]">
<span>{getRequestListDateLabel(resolvedDetail, String(req.status || "").toLowerCase())}</span>
<span className="font-semibold text-[var(--foreground)] tabular-nums">{amount ? money(amount) : "—"}</span>
</span>
{!isViewed ? (
<>
<span className="app-text-muted mt-2 grid grid-cols-2 gap-x-2 gap-y-1 text-[11px]">
<span className="min-w-0 truncate">Заявитель: <span className="text-[var(--foreground)]">{requestorName}</span></span>
<span className="min-w-0 truncate">Исполнитель: <span className="text-[var(--foreground)]">{executorName}</span></span>
</span>
<span className="mt-2 flex flex-wrap gap-1">
{fulfillmentStatusDisplay ? <span className={`${getFulfillmentStatusClass(fulfillmentStatus)} inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium`}>{fulfillmentStatusDisplay}</span> : null}
{nextExpectedDeliveryDate ? <span className={`${getExpectedDeliveryDateBadgeClass(nextExpectedDeliveryDate, isRequestFullyReceived)} inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium`}><CalendarDays size={10} /> {fmt(nextExpectedDeliveryDate)}</span> : null}
{itemsTotalCount > 0 ? <span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium"><Package size={10} /> {itemsTotalCount} поз.</span> : null}
{itemsProblemCount > 0 ? <span className="app-feedback-warning inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium"><AlertTriangle size={10} /> Проблемы {itemsProblemCount}/{itemsTotalCount}</span> : null}
{itemsTotalCount > 0 ? <span className={`${getReceivedProgressClass(itemsReceivedCount, itemsTotalCount)} inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium`}>Получено {itemsReceivedCount}/{itemsTotalCount} поз.</span> : null}
{totalRequestedQuantity > 0 ? <span className={`${getOrderedProgressClass(totalOrderedQuantity, totalRequestedQuantity)} inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium`}>Заказано {totalOrderedQuantity}/{totalRequestedQuantity} {quantityUnitLabel}</span> : null}
{totalRequestedQuantity > 0 ? <span className={`${getReceivedProgressClass(totalReceivedQuantity, totalRequestedQuantity)} inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium`}>Получено {totalReceivedQuantity}/{totalRequestedQuantity} {quantityUnitLabel}</span> : null}
</span>
</>
) : null}
</button>
<button
type="button"
title={isViewed ? "Отметить непросмотренной" : "Отметить просмотренной"}
aria-label={isViewed ? "Отметить заявку непросмотренной" : "Отметить заявку просмотренной"}
disabled={busyKey === `viewed-${req.id}`}
onClick={() => void handleToggleViewed(req.id, !isViewed)}
className={`${isViewed ? "app-selected app-accent-text" : "app-action-secondary"} absolute bottom-2 right-2 z-[1] inline-flex h-8 w-8 items-center justify-center rounded-lg shadow-sm disabled:cursor-not-allowed disabled:opacity-60`}
>
{isViewed ? <Eye size={15} /> : <EyeOff size={15} />}
</button>
</article>
);
})}
</div>
<InfiniteRequestLoader
hasMore={Boolean(nextPage)}
loading={loadingMore}
onLoadMore={handleLoadMore}
/>
</aside>
<section className="app-surface-muted min-h-0 overflow-y-auto rounded-xl border border-[var(--border-subtle)] p-4" aria-label="Просмотр выбранной заявки">
{detailModalLoading ? (
<div className="flex h-full min-h-64 flex-col items-center justify-center text-center">
<Loader2 size={24} className="mb-3 animate-spin text-sky-500" />
<p className="app-text-muted text-sm">Загружаем заявку...</p>
</div>
) : detailModalError ? (
<div className="app-feedback-danger rounded-xl px-4 py-3 text-sm">{detailModalError}</div>
) : selectedRequest ? (
<div className="space-y-4">
<div className="app-surface rounded-xl p-4">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h2 className="mr-auto min-w-0 text-lg font-semibold text-[var(--foreground)]">#{selectedRequest.id} · {selectedRequest.title || "Без названия"}</h2>
<span className={`app-status-pill shrink-0 ${getStatusMeta(selectedRequest.status).cls}`}>{getStatusMeta(selectedRequest.status).label}</span>
<span className={`shrink-0 text-[11px] font-medium ${urgencyMeta[selectedRequest.urgency]?.cls ?? urgencyMeta.medium.cls}`}>
{(urgencyMeta[selectedRequest.urgency]?.label ?? urgencyMeta.medium.label)} срочность
</span>
</div>
<div className="app-text-muted mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
<span>Отдел-заказчик: <span className="font-medium text-[var(--foreground)]">{getDeptName(selectedRequest)}</span></span>
{selectedRequest.processing_department_name ? <span>Отдел-исполнитель: <span className="font-medium text-[var(--foreground)]">{selectedRequest.processing_department_name}</span></span> : null}
<span>Заявитель: <span className="font-medium text-[var(--foreground)]">{displayUserName(selectedRequest.requestor, selectedRequest.requestor_name, selectedRequest.requestor_email)}</span></span>
<span>Исполнитель: <span className="font-medium text-[var(--foreground)]">{selectedRequest.executor || selectedRequest.executor_name ? displayUserName(selectedRequest.executor, selectedRequest.executor_name || undefined) : "не назначен"}</span></span>
{getRequestAmount(selectedRequest) ? <span>Сумма: <span className="font-semibold text-[var(--foreground)]">{money(getRequestAmount(selectedRequest))}</span></span> : null}
</div>
<p className="app-text-wrap mt-2 text-sm text-[var(--foreground)]">
<span className="app-text-muted">Описание: </span>{selectedRequest.description || "—"}
</p>
<div className="app-text-muted mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs">
<span>Создана: <span className="text-[var(--foreground)]">{fmt(selectedRequest.created_at) || "—"}</span></span>
<span>Отправлена: <span className="text-[var(--foreground)]">{fmt(selectedRequest.submitted_at) || "—"}</span></span>
<span>Взята в работу: <span className="text-[var(--foreground)]">{fmt(selectedRequest.started_at) || "—"}</span></span>
<span>Завершена: <span className="text-[var(--foreground)]">{fmt(selectedRequest.completed_at) || "—"}</span></span>
{selectedRequest.actual_cost ? <span>Фактическая сумма: <span className="font-medium text-[var(--foreground)]">{money(selectedRequest.actual_cost)}</span></span> : null}
</div>
<div className="mt-3 flex flex-wrap items-center justify-end gap-2 border-t border-[var(--border-subtle)] pt-3">
<button type="button" onClick={() => setTaskLinkRequest(selectedRequest)} className="app-action-secondary inline-flex h-9 items-center gap-1.5 rounded-lg px-3 text-xs font-medium">
<Link2 size={14} /> Связать с задачей
</button>
<ProcurementRequestActionButtons
request={selectedRequest}
busyKey={busyKey}
canManage={canManage}
canSubmitForApproval={canCurrentUserSubmitRequest(selectedRequest)}
isAuthor={Boolean(resolveUserId(selectedRequest.requestor) && user?.id && resolveUserId(selectedRequest.requestor) === user.id)}
isExecutor={Boolean(resolveUserId(selectedRequest.executor) && user?.id && resolveUserId(selectedRequest.executor) === user.id)}
isFinal={isFinal}
showLabels
onSubmit={(id) => void openSubmitApprovalDialog(id)}
onEdit={openEdit}
onDelete={(id) => openRequestActionDialog("delete", id)}
onApprove={(id) => openRequestActionDialog("approve", id)}
onReject={(id) => openRequestActionDialog("reject", id)}
onStart={handleStart}
onComplete={handleComplete}
onMarkAllReceived={handleMarkAllReceived}
onNotifyArrival={handleNotifyArrival}
onCancel={(id) => openRequestActionDialog("cancel", id)}
/>
</div>
</div>
<ProcurementRequestDetailContent
currentUserId={user?.id}
request={selectedRequest}
displayUserName={displayUserName}
canProcessItems={Boolean(selectedRequest.can_current_user_process_items)}
busyKey={busyKey}
canDeleteAnyComment={Boolean(user?.auth?.is_staff || user?.auth?.is_superuser)}
onUpdateItem={handleUpdateItem}
onReportItemIssue={handleReportItemIssue}
onCancelItemIssue={handleCancelItemIssue}
onConfirmItemOrdered={handleConfirmItemOrdered}
onConfirmItemReceived={handleConfirmItemReceived}
onCancelItemReceived={handleCancelItemReceived}
onMarkAllReceived={handleMarkAllReceived}
itemCommentsMap={itemCommentsMap}
itemCommentDrafts={itemCommentDrafts}
itemCommentFiles={itemCommentFiles}
expandedItemComments={expandedItemComments}
unreadItemCommentCounts={unreadProcurementItemCommentCounts}
onToggleItemComments={toggleItemComments}
onItemCommentDraftChange={(itemId, value) => setItemCommentDrafts((previous) => ({ ...previous, [itemId]: value }))}
onItemCommentFilesChange={handleItemCommentFilesChange}
onAddItemComment={handleAddItemComment}
onDeleteItemComment={handleDeleteItemComment}
onDownloadItemAttachment={handleDownloadItemAttachment}
onUploadItemAttachments={handleUploadItemAttachments}
onDeleteItemAttachment={handleDeleteItemAttachment}
wideLayout
footer={(
<div className="app-surface rounded-xl p-4">
<p className="app-card-caption">Комментарии</p>
<div className="mt-3">
<CommentComposer
value={commentDrafts[selectedRequest.id] || ""}
onChange={(value) => setCommentDrafts((previous) => ({ ...previous, [selectedRequest.id]: value }))}
onSubmit={() => handleAddComment(selectedRequest.id)}
disabled={busyKey === `comment-${selectedRequest.id}`}
/>
</div>
<div className="mt-3 space-y-2">
{(commentsMap[selectedRequest.id] || []).length === 0 ? (
<p className="app-text-muted text-xs">Комментариев пока нет</p>
) : newestCommentsFirst(commentsMap[selectedRequest.id] || []).map((comment) => {
const canDeleteCommentThis = Boolean(comment.author?.id && (user?.id === comment.author.id || user?.auth?.is_staff || user?.auth?.is_superuser));
return (
<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">{fmt(comment.created_at)}</span>
{canDeleteCommentThis ? <CommentDeleteButton onClick={() => handleDeleteComment(selectedRequest.id, comment.id)} /> : null}
</div>
</div>
<p className="app-text-wrap text-[var(--foreground)]">{comment.text}</p>
</div>
);
})}
</div>
</div>
)}
/>
</div>
) : (
<div className="flex h-full min-h-64 flex-col items-center justify-center text-center">
<ShoppingCart size={28} className="app-text-muted mb-3" />
<p className="text-sm font-medium text-[var(--foreground)]">Выберите заявку</p>
<p className="app-text-muted mt-1 text-xs">Полная информация появится в этой области.</p>
</div>
)}
</section>
</div>
{/* ══════════ Request cards ══════════ */}
<div className={`space-y-3 ${desktopWideMode ? "lg:hidden" : ""}`}>
{filteredRequests.length === 0 ? (
<div className="app-surface-muted rounded-xl p-8 text-center">
<ShoppingCart size={22} className="app-text-muted mx-auto mb-2" />
<p className="app-text-muted text-sm">Заявок на закупку не найдено</p>
</div>
) : filteredRequests.map((req) => {
const st = String(req.status || "").toLowerCase();
const sMeta = getStatusMeta(st);
const urg = urgencyMeta[req.urgency] ?? urgencyMeta.medium;
const isAuthor = Boolean(resolveUserId(req.requestor) && user?.id && resolveUserId(req.requestor) === user.id);
const isExecutor = Boolean(resolveUserId(req.executor) && user?.id && resolveUserId(req.executor) === user.id);
const isEditableStatus = (st === "draft" || st === "waiting") && !req.executor;
const detail = detailsCache[req.id];
const resolvedDetail = detail || req;
const canApproveThis = Boolean((resolvedDetail.can_current_user_approve ?? req.can_current_user_approve));
const canSubmitThis = canCurrentUserSubmitRequest(resolvedDetail);
const canEditThis = Boolean(isEditableStatus && isAuthor);
const canDeleteThis = Boolean(isEditableStatus && (isAuthor || canManage));
const canCancelThis = Boolean(isAuthor && !isFinal(st) && st !== "draft");
const canConfirmAllReceivedThis = canConfirmAllReceived(resolvedDetail, isAuthor);
const hasInlineRequestActions = Boolean(
canSubmitThis ||
canApproveThis ||
(resolvedDetail.can_current_user_start_work ?? req.can_current_user_start_work) ||
(st === "in_progress" && isExecutor) ||
canConfirmAllReceivedThis
);
const expanded = expandedIds.has(req.id);
const comments = commentsMap[req.id] || [];
const commentsOpen = Boolean(expandedComments[req.id]);
const commentsTotal = resolvedDetail.comments_count ?? req.comments_count ?? comments.length;
const isViewed = Boolean(resolvedDetail.is_viewed ?? req.is_viewed);
const unreadNotificationCount = unreadProcurementRequestCounts[req.id] || 0;
const rowExpanded = !isViewed && expanded;
const rowCommentsOpen = !isViewed && commentsOpen;
const showInlineRequestActions = (
(!isViewed && hasInlineRequestActions) ||
canConfirmAllReceivedThis
);
const showRequestMenu = true;
const requestMenuOpen = showRequestMenu && procurementMenuOpenId === req.id;
const requestorName = displayUserName(req.requestor, req.requestor_name, req.requestor_email);
const executorName = req.executor || req.executor_name ? displayUserName(req.executor, req.executor_name || undefined) : "";
const requestorLink = userLink(req.requestor);
const executorLink = userLink(req.executor);
const fulfillmentStatus = resolvedDetail.fulfillment_status ?? req.fulfillment_status;
const fulfillmentStatusDisplay = resolvedDetail.fulfillment_status_display ?? req.fulfillment_status_display;
const itemsCount = resolvedDetail.items?.length ?? resolvedDetail.items_count ?? req.items_count ?? 0;
const detailItems = resolvedDetail.items || req.items || [];
const itemsTotalCount = resolvedDetail.items_total_count ?? req.items_total_count ?? itemsCount;
const itemsReceivedCount = resolvedDetail.items_received_count ?? req.items_received_count ?? detailItems.filter((item) => item.execution_status === "received").length;
const itemsProblemCount = resolvedDetail.items_problem_count ?? req.items_problem_count ?? detailItems.filter((item) => ["rejected", "completed_with_issue", "edited", "defective"].includes(String(item.execution_status || ""))).length;
const itemsPendingCount = resolvedDetail.items_pending_count ?? req.items_pending_count ?? detailItems.filter((item) => !item.execution_status || item.execution_status === "pending").length;
const totalRequestedQuantity = resolvedDetail.total_requested_quantity ?? req.total_requested_quantity ?? detailItems.reduce((sum, item) => sum + Number(item.quantity || 0), 0);
const totalOrderedQuantity = resolvedDetail.total_ordered_quantity ?? req.total_ordered_quantity ?? detailItems.reduce((sum, item) => sum + Number(item.ordered_quantity || 0), 0);
const totalReceivedQuantity = resolvedDetail.total_received_quantity ?? req.total_received_quantity ?? detailItems.reduce((sum, item) => sum + Number(item.received_quantity || 0), 0);
const isRequestFullyReceived = totalRequestedQuantity > 0 && totalReceivedQuantity >= totalRequestedQuantity;
const quantityUnitLabel = getProcurementQuantityUnitLabel(
detailItems,
resolvedDetail.quantity_unit_label ?? req.quantity_unit_label,
);
const nextExpectedDeliveryDate = resolvedDetail.next_expected_delivery_date ?? req.next_expected_delivery_date ?? null;
const lastArrivalNoticeAt = resolvedDetail.last_arrival_notice_at ?? req.last_arrival_notice_at ?? null;
const approvalsCount = resolvedDetail.approvals?.length ?? 0;
const requestDateLabel = getRequestListDateLabel(resolvedDetail, st);
const linkedTasks = resolvedDetail.linked_tasks ?? req.linked_tasks ?? [];
return (
<article key={req.id} className={`app-surface-muted rounded-xl transition hover:border-[var(--border-strong)] ${requestMenuOpen ? "relative z-20 overflow-visible" : "overflow-hidden"}`}>
<div className={isViewed ? "px-4 py-2.5" : "px-4 py-3"}>
<div className="flex items-start gap-3">
<div className={`flex shrink-0 flex-col items-center ${isViewed ? "gap-0 pt-0.5" : "gap-3 pt-0.5"}`}>
{!isViewed ? (
<>
<button
type="button"
onClick={() => toggleExpand(req.id)}
aria-label={rowExpanded ? "Свернуть детали" : "Развернуть детали"}
className="app-action-secondary inline-flex h-8 w-8 items-center justify-center rounded-lg"
>
<ChevronDown size={15} className={`transition ${rowExpanded ? "rotate-180" : ""}`} />
</button>
<button
type="button"
title={`Комментарии (${commentsTotal})`}
onClick={() => void toggleComments(req.id)}
className="app-action-secondary relative inline-flex h-8 w-8 items-center justify-center rounded-lg"
>
<MessageSquare size={15} />
{commentsTotal > 0 && (
<span className="app-counter absolute -right-1 -top-1 inline-flex min-w-4 items-center justify-center px-1 py-0.5 text-[10px] font-bold text-white">{commentsTotal}</span>
)}
</button>
</>
) : null}
<button
type="button"
title={isViewed ? "Отметить непросмотренной" : "Отметить просмотренной"}
aria-label={isViewed ? "Отметить заявку непросмотренной" : "Отметить заявку просмотренной"}
disabled={busyKey === `viewed-${req.id}`}
onClick={() => {
setProcurementMenuOpenId(null);
void handleToggleViewed(req.id, !isViewed);
}}
className={`${isViewed ? "app-selected app-accent-text" : "app-action-secondary"} inline-flex h-8 w-8 items-center justify-center rounded-lg disabled:cursor-not-allowed disabled:opacity-60`}
>
{isViewed ? <Eye size={15} /> : <EyeOff size={15} />}
</button>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{unreadNotificationCount > 0 ? (
<span
className="app-counter inline-flex h-5 min-w-5 shrink-0 items-center justify-center px-1 text-[10px] font-bold text-white"
title={`Новых уведомлений: ${unreadNotificationCount}`}
aria-label={`Новых уведомлений: ${unreadNotificationCount}`}
>
{unreadNotificationCount > 99 ? "99+" : unreadNotificationCount}
</span>
) : null}
<button
type="button"
onClick={() => void openDetailModal(req.id)}
className="truncate text-left text-sm font-semibold text-[var(--foreground)] transition hover:text-[var(--accent-primary-strong)]"
title="Открыть карточку заявки"
>
{req.title || "Без названия"}
</button>
</div>
<div className="app-text-muted mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span>Отдел-заказчик: <span className="font-medium text-[var(--foreground)]">{getDeptName(req)}</span></span>
<span>{requestDateLabel}</span>
{getRequestAmount(req) && <span className="font-medium text-[var(--foreground)]">{money(getRequestAmount(req))}</span>}
</div>
{linkedTasks.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{linkedTasks.slice(0, 3).map((task) => (
<TaskLinkPill
key={task.link_id || task.id}
task={task}
maxTitleClassName="max-w-40"
/>
))}
{linkedTasks.length > 3 ? (
<span className="app-badge inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium">
+{linkedTasks.length - 3}
</span>
) : null}
</div>
) : null}
</div>
<div
ref={requestMenuOpen ? procurementMenuRef : null}
className="flex shrink-0 flex-col items-end gap-2"
>
<div className="flex items-center gap-2">
<span className={`app-status-pill shrink-0 ${sMeta.cls}`}>{sMeta.label}</span>
{showRequestMenu ? (
<div className="relative">
<button
type="button"
onClick={() => setProcurementMenuOpenId((prev) => (prev === req.id ? null : req.id))}
className="app-action-ghost flex h-8 w-8 items-center justify-center rounded-md"
title="Действия с заявкой"
aria-label="Действия с заявкой"
aria-expanded={requestMenuOpen}
aria-haspopup="menu"
>
<ChevronRight
size={15}
className={`transition-transform duration-200 ${requestMenuOpen ? "rotate-90" : ""}`}
/>
</button>
{requestMenuOpen ? (
<div className="app-menu absolute right-0 top-full z-20 mt-2 w-52 rounded-xl py-1.5">
<button
type="button"
onClick={() => {
setProcurementMenuOpenId(null);
setTaskLinkRequest(resolvedDetail);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<Link2 size={14} className="app-text-muted" />
Связать с задачей
</button>
{canCancelThis ? (
<button
type="button"
disabled={busyKey === `cancel-${req.id}`}
onClick={() => {
setProcurementMenuOpenId(null);
openRequestActionDialog("cancel", req.id);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)] disabled:opacity-50"
>
<XCircle size={14} className="app-text-muted" />
Отменить
</button>
) : null}
{canEditThis ? (
<button
type="button"
onClick={() => {
setProcurementMenuOpenId(null);
openEdit(req);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<Pencil size={14} className="app-text-muted" />
Редактировать
</button>
) : null}
{canDeleteThis ? (
<button
type="button"
disabled={busyKey === `delete-${req.id}`}
onClick={() => {
setProcurementMenuOpenId(null);
openRequestActionDialog("delete", req.id);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--danger-foreground)] transition hover:bg-[var(--danger-soft)] disabled:opacity-50"
>
<Trash2 size={13} className="text-[var(--danger-foreground)]" />
Удалить
</button>
) : null}
</div>
) : null}
</div>
) : null}
</div>
{!isViewed ? (
<span className={`text-[11px] font-medium ${urg.cls}`}>{urg.label} срочность</span>
) : null}
</div>
</div>
{!isViewed ? (
<div className="app-text-muted mt-2 grid grid-cols-1 gap-x-3 gap-y-1 text-xs sm:grid-cols-2">
<div className="min-w-0">
<span>Заявитель:</span>{" "}
{requestorLink
? <Link href={requestorLink} className="font-medium text-[var(--accent-primary-strong)] hover:text-[var(--accent-primary)]">{requestorName}</Link>
: <span className="font-medium text-[var(--foreground)]">{requestorName}</span>}
</div>
<div className="min-w-0">
<span>Исполнитель:</span>{" "}
{req.executor ? (
executorLink
? <Link href={executorLink} className="font-medium text-[var(--accent-primary-strong)] hover:text-[var(--accent-primary)]">{executorName}</Link>
: <span className="font-medium text-[var(--foreground)]">{executorName}</span>
) : (
<span>не назначен</span>
)}
</div>
{(fulfillmentStatusDisplay || itemsTotalCount > 0 || approvalsCount > 0 || nextExpectedDeliveryDate || lastArrivalNoticeAt) && (
<div className="col-span-2 flex flex-wrap items-center gap-2 pt-0.5">
{fulfillmentStatusDisplay ? (
<span className={`${getFulfillmentStatusClass(fulfillmentStatus)} inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium`}>
{fulfillmentStatusDisplay}
</span>
) : null}
{nextExpectedDeliveryDate ? (
<span className={`${getExpectedDeliveryDateBadgeClass(nextExpectedDeliveryDate, isRequestFullyReceived)} inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium`}>
<CalendarDays size={11} /> {fmt(nextExpectedDeliveryDate)}
</span>
) : null}
{lastArrivalNoticeAt ? (
<span className="app-feedback-notify inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
Уведомлён {fmtDateTime(lastArrivalNoticeAt)}
</span>
) : null}
{itemsTotalCount > 0 && (
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
<Package size={11} /> {itemsTotalCount} поз.
</span>
)}
{itemsTotalCount > 0 && (
<span className={`${getReceivedProgressClass(itemsReceivedCount, itemsTotalCount)} inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium`}>
<Check size={11} /> Получено {itemsReceivedCount}/{itemsTotalCount} поз.
</span>
)}
{itemsProblemCount > 0 && (
<span className="app-feedback-warning inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
<AlertTriangle size={11} /> Проблемы {itemsProblemCount}/{itemsTotalCount} поз.
</span>
)}
{totalRequestedQuantity > 0 && (
<span className={`${getOrderedProgressClass(totalOrderedQuantity, totalRequestedQuantity)} inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium`}>
Заказано {totalOrderedQuantity}/{totalRequestedQuantity} {quantityUnitLabel}
</span>
)}
{totalRequestedQuantity > 0 && (
<span className={`${getReceivedProgressClass(totalReceivedQuantity, totalRequestedQuantity)} inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium`}>
Получено {totalReceivedQuantity}/{totalRequestedQuantity} {quantityUnitLabel}
</span>
)}
{itemsPendingCount > 0 && (
<span className="app-feedback-warning inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
Не обработано {itemsPendingCount}
</span>
)}
{approvalsCount > 0 && (
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
<Check size={11} /> {approvalsCount} соглас.
</span>
)}
</div>
)}
</div>
) : null}
{showInlineRequestActions ? (
<div className="mt-3 flex justify-end">
<ProcurementRequestActionButtons
request={resolvedDetail}
busyKey={busyKey}
canManage={canManage}
canSubmitForApproval={canSubmitThis}
isAuthor={isAuthor}
isExecutor={isExecutor}
isFinal={isFinal}
showSecondaryActions={false}
onSubmit={(id) => void openSubmitApprovalDialog(id)}
onEdit={openEdit}
onDelete={handleDelete}
onApprove={(id) => openRequestActionDialog("approve", id)}
onReject={(id) => openRequestActionDialog("reject", id)}
onStart={handleStart}
onComplete={handleComplete}
onMarkAllReceived={handleMarkAllReceived}
onNotifyArrival={handleNotifyArrival}
onCancel={handleCancel}
/>
</div>
) : null}
</div>
</div>
</div>
{(rowExpanded || rowCommentsOpen) && (
<div className="mt-4 space-y-3 px-4 pb-4">
{rowCommentsOpen ? (
<div className={rowExpanded ? "app-surface rounded-xl p-3" : "app-surface-elevated rounded-xl p-3"}>
<CommentComposer
value={commentDrafts[req.id] || ""}
onChange={(value) => setCommentDrafts((previous) => ({ ...previous, [req.id]: value }))}
onSubmit={() => handleAddComment(req.id)}
disabled={busyKey === `comment-${req.id}`}
/>
<div className="mt-2 space-y-2">
{comments.length === 0 ? (
<p className="app-text-muted text-xs">Комментариев пока нет</p>
) : (
newestCommentsFirst(comments).map((comment) => {
const canDeleteCommentThis = Boolean(comment.author?.id && (user?.id === comment.author.id || user?.auth?.is_staff || user?.auth?.is_superuser));
return (
<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">{fmt(comment.created_at)}</span>
{canDeleteCommentThis ? (
<CommentDeleteButton
onClick={() => handleDeleteComment(req.id, comment.id)}
/>
) : null}
</div>
</div>
<p className="app-text-wrap text-[var(--foreground)]">{comment.text}</p>
</div>
);
})
)}
</div>
</div>
) : null}
{rowExpanded ? (
<ProcurementRequestDetailContent
currentUserId={user?.id}
request={resolvedDetail}
displayUserName={displayUserName}
canProcessItems={Boolean(resolvedDetail.can_current_user_process_items)}
busyKey={busyKey}
canDeleteAnyComment={Boolean(user?.auth?.is_staff || user?.auth?.is_superuser)}
onUpdateItem={handleUpdateItem}
onReportItemIssue={handleReportItemIssue}
onCancelItemIssue={handleCancelItemIssue}
onConfirmItemOrdered={handleConfirmItemOrdered}
onConfirmItemReceived={handleConfirmItemReceived}
onCancelItemReceived={handleCancelItemReceived}
onMarkAllReceived={handleMarkAllReceived}
itemCommentsMap={itemCommentsMap}
itemCommentDrafts={itemCommentDrafts}
itemCommentFiles={itemCommentFiles}
expandedItemComments={expandedItemComments}
unreadItemCommentCounts={unreadProcurementItemCommentCounts}
onToggleItemComments={toggleItemComments}
onItemCommentDraftChange={(itemId, value) => setItemCommentDrafts((previous) => ({ ...previous, [itemId]: value }))}
onItemCommentFilesChange={handleItemCommentFilesChange}
onAddItemComment={handleAddItemComment}
onDeleteItemComment={handleDeleteItemComment}
onDownloadItemAttachment={handleDownloadItemAttachment}
onUploadItemAttachments={handleUploadItemAttachments}
onDeleteItemAttachment={handleDeleteItemAttachment}
footer={(
<ProcurementRequestActionButtons
request={resolvedDetail}
busyKey={busyKey}
canManage={canManage}
canSubmitForApproval={canSubmitThis}
isAuthor={isAuthor}
isExecutor={isExecutor}
isFinal={isFinal}
showSecondaryActions={false}
showLabels
onSubmit={(id) => void openSubmitApprovalDialog(id)}
onEdit={openEdit}
onDelete={handleDelete}
onApprove={handleApprove}
onReject={handleReject}
onStart={handleStart}
onComplete={handleComplete}
onMarkAllReceived={handleMarkAllReceived}
onNotifyArrival={handleNotifyArrival}
onCancel={handleCancel}
/>
)}
/>
) : null}
</div>
)}
</article>
);
})}
</div>
{/* ── load more ── */}
<div className={desktopWideMode ? "lg:hidden" : ""}>
<InfiniteRequestLoader
hasMore={Boolean(nextPage)}
loading={loadingMore}
onLoadMore={handleLoadMore}
/>
</div>
</>
)}
</section>
)}
<Modal
isOpen={detailModalId !== null && detailDialogOpen}
onClose={closeDetailModal}
title={selectedRequest?.title || "Карточка заявки"}
size="lg"
closeOnClickOutside
footer={selectedRequest ? (
<div className="flex justify-end">
<ProcurementRequestActionButtons
request={selectedRequest}
busyKey={busyKey}
canManage={canManage}
canSubmitForApproval={canCurrentUserSubmitRequest(selectedRequest)}
isAuthor={Boolean(resolveUserId(selectedRequest.requestor) && user?.id && resolveUserId(selectedRequest.requestor) === user.id)}
isExecutor={Boolean(resolveUserId(selectedRequest.executor) && user?.id && resolveUserId(selectedRequest.executor) === user.id)}
isFinal={isFinal}
showLabels
onSubmit={(id) => void openSubmitApprovalDialog(id)}
onEdit={openEdit}
onDelete={(id) => openRequestActionDialog("delete", id)}
onApprove={(id) => openRequestActionDialog("approve", id)}
onReject={(id) => openRequestActionDialog("reject", id)}
onStart={handleStart}
onComplete={handleComplete}
onMarkAllReceived={handleMarkAllReceived}
onNotifyArrival={handleNotifyArrival}
onCancel={(id) => openRequestActionDialog("cancel", id)}
/>
</div>
) : null}
>
{detailModalLoading ? (
<div className="py-12 text-center">
<Loader2 size={24} className="mx-auto mb-3 animate-spin text-sky-500" />
<p className="app-text-muted text-sm">Загружаем карточку заявки...</p>
</div>
) : detailModalError ? (
<div className="app-feedback-danger rounded-xl px-4 py-3 text-sm">
{detailModalError}
</div>
) : selectedRequest ? (
<div className="space-y-4 pb-1">
<div className="app-surface-muted rounded-xl px-4 py-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className={`app-status-pill ${getStatusMeta(selectedRequest.status).cls}`}>
{getStatusMeta(selectedRequest.status).label}
</span>
<span className={`text-[11px] font-medium ${urgencyMeta[selectedRequest.urgency]?.cls ?? urgencyMeta.medium.cls}`}>
{(urgencyMeta[selectedRequest.urgency]?.label ?? urgencyMeta.medium.label)} срочность
</span>
</div>
<div className="app-text-muted mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span>Отдел-заказчик: <span className="font-medium text-[var(--foreground)]">{getDeptName(selectedRequest)}</span></span>
{selectedRequest.processing_department_name ? (
<span>Отдел-исполнитель: <span className="font-medium text-[var(--foreground)]">{selectedRequest.processing_department_name}</span></span>
) : null}
<span>{fmt(selectedRequest.created_at)}</span>
{getRequestAmount(selectedRequest) ? (
<span className="font-medium text-[var(--foreground)]">{money(getRequestAmount(selectedRequest))}</span>
) : null}
{selectedRequest.fulfillment_status_display ? (
<span className={`${getFulfillmentStatusClass(selectedRequest.fulfillment_status)} rounded-full px-2 py-0.5 text-[11px] font-medium`}>
{selectedRequest.fulfillment_status_display}
</span>
) : null}
</div>
<div className="app-text-muted mt-2 grid grid-cols-1 gap-x-3 gap-y-1 text-xs sm:grid-cols-2">
<div className="min-w-0">
<span>Заявитель:</span>{" "}
<span className="font-medium text-[var(--foreground)]">
{displayUserName(selectedRequest.requestor, selectedRequest.requestor_name, selectedRequest.requestor_email)}
</span>
</div>
<div className="min-w-0">
<span>Исполнитель:</span>{" "}
<span className="font-medium text-[var(--foreground)]">
{selectedRequest.executor || selectedRequest.executor_name
? displayUserName(selectedRequest.executor, selectedRequest.executor_name || undefined)
: "не назначен"}
</span>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
{(selectedRequest.linked_tasks || []).slice(0, 4).map((task) => (
<TaskLinkPill
key={task.link_id || task.id}
task={task}
maxTitleClassName="max-w-48"
/>
))}
{(selectedRequest.linked_tasks || []).length > 4 ? (
<span className="app-badge inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium">
+{(selectedRequest.linked_tasks || []).length - 4}
</span>
) : null}
<button
type="button"
onClick={() => setTaskLinkRequest(selectedRequest)}
className="app-action-secondary inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium"
>
<Link2 size={12} />
Связать с задачей
</button>
</div>
</div>
</div>
{actionError ? (
<div className="app-feedback-danger rounded-xl px-4 py-3 text-sm">
{actionError}
</div>
) : null}
<ProcurementRequestDetailContent
currentUserId={user?.id}
request={selectedRequest}
displayUserName={displayUserName}
canProcessItems={Boolean(selectedRequest.can_current_user_process_items)}
busyKey={busyKey}
canDeleteAnyComment={Boolean(user?.auth?.is_staff || user?.auth?.is_superuser)}
onUpdateItem={handleUpdateItem}
onReportItemIssue={handleReportItemIssue}
onCancelItemIssue={handleCancelItemIssue}
onConfirmItemOrdered={handleConfirmItemOrdered}
onConfirmItemReceived={handleConfirmItemReceived}
onCancelItemReceived={handleCancelItemReceived}
onMarkAllReceived={handleMarkAllReceived}
itemCommentsMap={itemCommentsMap}
itemCommentDrafts={itemCommentDrafts}
itemCommentFiles={itemCommentFiles}
expandedItemComments={expandedItemComments}
unreadItemCommentCounts={unreadProcurementItemCommentCounts}
onToggleItemComments={toggleItemComments}
onItemCommentDraftChange={(itemId, value) => setItemCommentDrafts((previous) => ({ ...previous, [itemId]: value }))}
onItemCommentFilesChange={handleItemCommentFilesChange}
onAddItemComment={handleAddItemComment}
onDeleteItemComment={handleDeleteItemComment}
onDownloadItemAttachment={handleDownloadItemAttachment}
onUploadItemAttachments={handleUploadItemAttachments}
onDeleteItemAttachment={handleDeleteItemAttachment}
/>
<div className="app-surface rounded-xl p-4">
<p className="app-card-caption">Комментарии</p>
<div className="mt-3">
<CommentComposer
value={commentDrafts[selectedRequest.id] || ""}
onChange={(value) => setCommentDrafts((previous) => ({ ...previous, [selectedRequest.id]: value }))}
onSubmit={() => handleAddComment(selectedRequest.id)}
disabled={busyKey === `comment-${selectedRequest.id}`}
/>
</div>
<div className="mt-3 space-y-2">
{(commentsMap[selectedRequest.id] || []).length === 0 ? (
<p className="app-text-muted text-xs">Комментариев пока нет</p>
) : (
newestCommentsFirst(commentsMap[selectedRequest.id] || []).map((comment) => {
const canDeleteCommentThis = Boolean(comment.author?.id && (user?.id === comment.author.id || user?.auth?.is_staff || user?.auth?.is_superuser));
return (
<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">{fmt(comment.created_at)}</span>
{canDeleteCommentThis ? (
<CommentDeleteButton
onClick={() => handleDeleteComment(selectedRequest.id, comment.id)}
/>
) : null}
</div>
</div>
<p className="app-text-wrap text-[var(--foreground)]">{comment.text}</p>
</div>
);
})
)}
</div>
</div>
</div>
) : (
<div className="app-surface-muted rounded-xl px-4 py-6 text-center">
<p className="app-text-muted text-sm">Заявка не найдена.</p>
</div>
)}
</Modal>
{taskLinkRequest ? (
<ProcurementTaskLinks
request={taskLinkRequest}
variant="dialog"
open
onClose={() => setTaskLinkRequest(null)}
onLinked={() => void handleTaskLinked(taskLinkRequest.id)}
/>
) : null}
<Modal
isOpen={submitApprovalRequestId !== null}
onClose={closeSubmitApprovalDialog}
title="Отправить на согласование"
size="lg"
closeOnClickOutside
>
<div className="space-y-4 pb-1">
{selectedSubmitRequest ? (
<div className="app-surface-muted rounded-xl px-4 py-3">
<p className="text-sm font-semibold text-[var(--foreground)]">{selectedSubmitRequest.title || "Без названия"}</p>
<p className="app-text-muted mt-1 text-xs">
{getDeptName(selectedSubmitRequest)}{getRequestAmount(selectedSubmitRequest) ? ` • ${money(getRequestAmount(selectedSubmitRequest))}` : ""}
</p>
</div>
) : null}
{submitApprovalError || actionError ? (
<div className="app-feedback-danger rounded-xl px-4 py-3 text-sm">
{submitApprovalError || actionError}
</div>
) : null}
{submitApprovalLoading ? (
<div className="app-surface-muted rounded-xl px-4 py-8 text-center">
<Loader2 size={22} className="mx-auto mb-2 animate-spin text-sky-500" />
<p className="app-text-muted text-sm">Загрузка согласующих...</p>
</div>
) : submitApprovalOptions ? (
<>
<div className="app-surface-muted inline-flex rounded-xl p-1">
<button
type="button"
onClick={() => setSubmitApprovalMode("auto")}
className={`rounded-lg px-3 py-2 text-xs font-medium transition ${
submitApprovalMode === "auto" ? "app-pill-active" : "app-text-muted hover:bg-[var(--surface-tertiary)]"
}`}
>
По сумме
</button>
<button
type="button"
onClick={() => setSubmitApprovalMode("manual")}
className={`rounded-lg px-3 py-2 text-xs font-medium transition ${
submitApprovalMode === "manual" ? "app-pill-active" : "app-text-muted hover:bg-[var(--surface-tertiary)]"
}`}
>
Ручной выбор
</button>
</div>
{submitApprovalMode === "auto" ? (
<div className="space-y-3">
<div className="app-surface-muted rounded-xl px-4 py-3">
<p className="app-card-caption">Автоматический маршрут</p>
<p className="app-text-muted mt-1 text-xs">
Этапы рассчитаны по текущей сумме заявки: {money(submitApprovalOptions.total_cost)}
</p>
</div>
{submitApprovalOptions.auto_steps.length > 0 ? (
<div className="space-y-2">
{submitApprovalOptions.auto_steps.map((option) => (
<div key={option.priority} className="app-surface rounded-xl px-4 py-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-[var(--foreground)]">{option.step_name}</p>
<p className="app-text-muted mt-1 text-xs">
Приоритет {option.priority} • {getApprovalRouteAmountLabel(option)}
</p>
</div>
<span className="app-pill-active rounded-full px-2 py-1 text-[11px] font-medium">
По сумме
</span>
</div>
<p className="mt-2 text-sm text-[var(--foreground)]">
{displayUserName(option.approver)}
</p>
</div>
))}
</div>
) : (
<div className="app-feedback-warning rounded-xl px-4 py-3 text-sm">
Для текущей суммы не найден автоматический маршрут.
</div>
)}
{submitApprovalOptions.missing_auto_steps.length > 0 ? (
<div className="app-feedback-warning rounded-xl px-4 py-3 text-sm">
Не все автоматические этапы можно заполнить. Проверьте настройки маршрутов или используйте ручной выбор.
</div>
) : null}
</div>
) : (
<div className="space-y-3">
<div className="app-surface-muted rounded-xl px-4 py-3">
<p className="app-card-caption">Ручной маршрут</p>
<p className="app-text-muted mt-1 text-xs">
Выберите нужные этапы и сотрудников. Этот список будет использован вместо автоматического расчёта по сумме.
</p>
</div>
<div className="max-h-[52vh] space-y-2 overflow-y-auto pr-1">
{submitApprovalOptions.available_steps.map((option) => {
const selected = manualApprovalPriorities.includes(option.priority);
return (
<div key={option.priority} className={`rounded-xl border px-4 py-3 transition ${
selected ? "border-[var(--accent-primary)] bg-[var(--surface-secondary)]" : "border-[var(--border)] bg-[var(--surface-primary)]"
}`}>
<label className="flex cursor-pointer items-start gap-3">
<input
type="checkbox"
checked={selected}
onChange={() => toggleManualApprovalPriority(option.priority)}
className="mt-1 h-4 w-4 rounded border-[var(--border-strong)] accent-[var(--accent-primary)]"
/>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-[var(--foreground)]">{option.step_name}</span>
<span className="app-badge rounded-full px-2 py-0.5 text-[11px]">
Приоритет {option.priority}
</span>
{option.is_amount_applicable ? (
<span className="app-pill-active rounded-full px-2 py-0.5 text-[11px]">
По сумме
</span>
) : null}
</span>
<span className="app-text-muted mt-1 block text-xs">
{getApprovalRouteAmountLabel(option)}
{option.approver ? ` • по умолчанию: ${displayUserName(option.approver)}` : ""}
</span>
</span>
</label>
{selected ? (
<div className="mt-3">
<SearchableSelectSingle
label="Согласующий"
placeholder="Выберите сотрудника..."
items={employees.map((employee) => ({ id: employee.id, name: displayUserName(employee) }))}
selectedId={manualApproverByPriority[option.priority] ?? null}
onSelect={(id) => setManualApproverByPriority((current) => ({
...current,
[option.priority]: id,
}))}
/>
</div>
) : null}
</div>
);
})}
</div>
</div>
)}
</>
) : (
<div className="app-surface-muted rounded-xl px-4 py-6 text-center">
<p className="app-text-muted text-sm">Согласующие не загружены.</p>
</div>
)}
<div className="flex justify-end gap-2">
<button type="button" onClick={closeSubmitApprovalDialog} className="app-action-secondary rounded-xl px-4 py-2.5 text-sm font-medium">
Отмена
</button>
<button
type="button"
onClick={() => void submitApprovalDialog()}
disabled={submitApprovalLoading || !submitApprovalOptions || (submitApprovalRequestId !== null && busyKey === `submit-${submitApprovalRequestId}`)}
className="app-action-primary rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-60"
>
Отправить
</button>
</div>
</div>
</Modal>
<Modal
isOpen={requestActionDialog !== null}
onClose={closeRequestActionDialog}
title={
requestActionDialog?.kind === "approve" ? "Одобрить заявку"
: requestActionDialog?.kind === "reject" ? "Отклонить заявку"
: requestActionDialog?.kind === "cancel" ? "Отменить заявку"
: "Удалить заявку"
}
size="md"
closeOnClickOutside
>
<div className="space-y-4 pb-1">
{selectedActionRequest ? (
<div className="app-surface-muted rounded-xl px-4 py-3">
<p className="text-sm font-semibold text-[var(--foreground)]">{selectedActionRequest.title || "Без названия"}</p>
<p className="app-text-muted mt-1 text-xs">
{getDeptName(selectedActionRequest)}{getRequestAmount(selectedActionRequest) ? ` • ${money(getRequestAmount(selectedActionRequest))}` : ""}
</p>
</div>
) : null}
{actionError ? (
<div className="app-feedback-danger rounded-xl px-4 py-3 text-sm">
{actionError}
</div>
) : null}
{requestActionDialog?.kind === "delete" ? (
<p className="app-text-muted text-sm">
Удалить эту заявку? Действие доступно только для черновиков и будет необратимым.
</p>
) : (
<div className="space-y-2">
<label className="app-card-caption">
{requestActionDialog?.kind === "approve"
? "Комментарий к одобрению"
: requestActionDialog?.kind === "reject"
? "Комментарий к отклонению"
: "Причина отмены"}
</label>
<textarea
value={requestActionComment}
onChange={(event) => setRequestActionComment(event.target.value)}
rows={4}
placeholder={
requestActionDialog?.kind === "approve"
? "Необязательно"
: requestActionDialog?.kind === "reject"
? "Необязательно, но желательно"
: "Необязательно"
}
className="app-input app-text-wrap min-h-28 w-full rounded-xl px-3 py-2.5 text-sm"
/>
</div>
)}
<div className="flex justify-end gap-2">
<button type="button" onClick={closeRequestActionDialog} className="app-action-secondary rounded-xl px-4 py-2.5 text-sm font-medium">
Отмена
</button>
<button
type="button"
onClick={() => void submitRequestActionDialog()}
disabled={
requestActionDialog?.kind === "approve" ? busyKey === `approve-${requestActionDialog.requestId}`
: requestActionDialog?.kind === "reject" ? busyKey === `reject-${requestActionDialog.requestId}`
: requestActionDialog?.kind === "cancel" ? busyKey === `cancel-${requestActionDialog.requestId}`
: requestActionDialog?.kind === "delete" ? busyKey === `delete-${requestActionDialog.requestId}`
: false
}
className={`rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-60 ${
requestActionDialog?.kind === "delete" || requestActionDialog?.kind === "reject"
? "app-action-danger"
: "app-action-primary"
}`}
>
{requestActionDialog?.kind === "approve"
? "Одобрить"
: requestActionDialog?.kind === "reject"
? "Отклонить"
: requestActionDialog?.kind === "cancel"
? "Отменить заявку"
: "Удалить"}
</button>
</div>
</div>
</Modal>
{/* ══════════ Create / Edit modal ══════════ */}
<Modal isOpen={isModalOpen} onClose={closeModal} title={modalMode === "create" ? "Новая заявка на закупку" : "Редактировать заявку"} size="lg" footer={
<div className="flex flex-wrap items-center justify-end gap-2">
<button type="button" onClick={closeModal} className="app-action-secondary rounded-lg px-3 py-2 text-sm font-medium">Отмена</button>
<button
type="button"
onClick={handleSave}
disabled={busyKey === "save" || busyKey === "save-submit"}
className="app-action-primary rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-60"
>
{modalMode === "create" ? "Создать заявку" : "Сохранить"}
</button>
</div>
}>
{actionError && <p className="mb-3 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{actionError}</p>}
<div className="flex flex-col gap-3">
{/* Название */}
<div>
<label className="app-text-muted mb-1 block text-xs font-medium">Название заявки *</label>
<input
value={form.title}
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
placeholder="Закупка офисной техники..."
className="app-input w-full rounded-lg px-3 py-2 text-sm"
/>
</div>
{/* Описание */}
<div>
<label className="app-text-muted mb-1 block text-xs font-medium">
Описание и обоснование (необязательно)
</label>
<textarea
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
placeholder="Можно оставить пустым и описать детали в позициях..."
rows={3}
className="app-input w-full rounded-lg px-3 py-2 text-sm"
/>
</div>
{/* Отдел + Срочность */}
<div className="grid grid-cols-2 gap-3">
<SearchableSelectSingle
label="Отдел-заказчик *"
placeholder="Выберите отдел, для которого нужна закупка..."
items={departments.map((d) => ({ id: d.id, name: d.name }))}
selectedId={form.department}
onSelect={(id) => setForm((f) => ({ ...f, department: id }))}
disabled={modalMode === "edit"}
/>
<div>
<label className="app-text-muted mb-1 block text-xs font-medium">Срочность</label>
<select
value={form.urgency}
onChange={(e) => setForm((f) => ({ ...f, urgency: e.target.value as UrgencyLevel }))}
className="app-select w-full rounded-lg px-3 py-2 text-sm"
>
<option value="low">Низкая</option>
<option value="medium">Средняя</option>
<option value="high">Высокая</option>
<option value="critical">Критическая</option>
</select>
</div>
</div>
<SearchableSelectSingle
label="Отдел-исполнитель *"
placeholder="Выберите отдел, который обработает заявку..."
items={processingDepartmentOptions.map((d) => ({ id: d.id, name: d.name }))}
selectedId={form.processing_department}
onSelect={(id) => setForm((f) => ({ ...f, processing_department: id }))}
/>
{/* ── Items ── */}
<div>
<div className="mb-2 flex items-center justify-between">
<label className="app-text-muted text-xs font-medium">Позиции *</label>
<button type="button" onClick={addItemRow} className="app-link-accent inline-flex items-center gap-1 text-xs font-medium">
<Plus size={13} /> Добавить
</button>
</div>
<div className="space-y-3">
{form.items.map((it, idx) => (
<div key={idx} className="app-surface-muted rounded-xl p-3">
<div className="grid gap-2">
<div className="flex items-center gap-2">
<input
value={it.name}
onChange={(e) => updateItemRow(idx, { name: e.target.value })}
placeholder="Название позиции *"
className="app-input min-w-0 flex-1 rounded-lg px-3 py-2 text-sm"
/>
{form.items.length > 1 && (
<button
type="button"
onClick={() => removeItemRow(idx)}
className="app-action-secondary inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg"
title="Удалить позицию"
>
<X size={14} />
</button>
)}
</div>
<textarea
value={it.initial_comment}
onChange={(e) => updateItemRow(idx, { initial_comment: e.target.value })}
placeholder={modalMode === "edit" ? "Новый комментарий к позиции" : "Комментарий к позиции"}
rows={2}
className="app-input app-text-wrap w-full rounded-lg px-3 py-2 text-sm"
/>
{modalMode === "edit" && it.id && (itemCommentsMap[it.id] || []).length > 0 ? (
<div className="app-surface rounded-lg px-3 py-2.5">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="app-card-caption text-xs">Комментарии позиции</p>
<span className="app-badge px-2 py-0.5 text-[10px] font-semibold">
{(itemCommentsMap[it.id] || []).length}
</span>
</div>
<div className="max-h-40 space-y-2 overflow-y-auto pr-1">
{(itemCommentsMap[it.id] || []).map((comment) => (
<div
key={comment.id}
className="border-b border-[var(--border-subtle)] pb-2 text-xs last:border-b-0 last:pb-0"
>
<div className="mb-1 flex flex-wrap items-center gap-x-2 gap-y-0.5">
<span className="font-medium text-[var(--foreground)]">
{displayUserName(comment.author)}
</span>
<span className="app-text-muted text-[11px]">{fmt(comment.created_at)}</span>
</div>
<p className="app-text-wrap whitespace-pre-line leading-5 text-[var(--foreground)]">
{comment.text}
</p>
</div>
))}
</div>
</div>
) : null}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<input
type="number"
value={it.quantity}
onChange={(e) => updateItemRow(idx, { quantity: e.target.value })}
placeholder="Кол-во"
min={1}
className="app-input rounded-lg px-3 py-2 text-sm"
/>
<select
value={it.unit}
onChange={(e) => updateItemRow(idx, { unit: e.target.value })}
aria-label="Единица измерения"
className="app-select min-w-0 rounded-lg px-3 py-2 text-sm"
>
{getProcurementUnitOptions(it.unit).map((unit) => (
<option key={unit.value} value={unit.value}>
{unit.label}
</option>
))}
</select>
<input
type="number"
step="0.01"
value={it.estimated_unit_price}
onChange={(e) => updateItemRow(idx, { estimated_unit_price: e.target.value })}
placeholder="Цена/ед. (необяз.)"
className="app-input rounded-lg px-3 py-2 text-sm"
/>
<div className="flex items-center text-xs text-gray-500">
{it.quantity && it.estimated_unit_price ? money(Number(it.quantity) * Number(it.estimated_unit_price)) : "—"}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="app-text-muted text-xs font-medium">Ссылки</span>
<button
type="button"
onClick={() => updateItemRow(idx, { links: ["", ...it.links] })}
className="app-link-accent inline-flex items-center gap-1 text-xs font-medium"
>
<Plus size={12} /> Добавить ссылку
</button>
</div>
{it.links.map((link, linkIndex) => (
<div key={linkIndex} className="flex items-center gap-2">
<input
value={link}
onChange={(e) => updateItemRow(idx, {
links: it.links.map((currentLink, currentIndex) => (
currentIndex === linkIndex ? e.target.value : currentLink
)),
})}
placeholder="https://example.ru/item"
className="app-input min-w-0 flex-1 rounded-lg px-3 py-2 text-sm"
/>
{it.links.length > 1 ? (
<button
type="button"
onClick={() => updateItemRow(idx, {
links: it.links.filter((_, currentIndex) => currentIndex !== linkIndex),
})}
className="app-action-secondary inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg"
title="Удалить ссылку"
>
<X size={13} />
</button>
) : null}
</div>
))}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="app-text-muted text-xs font-medium">Файлы</span>
<label className="app-link-accent inline-flex cursor-pointer items-center gap-1 text-xs font-medium">
<Paperclip size={12} /> Прикрепить
<input
type="file"
multiple
className="hidden"
onChange={(event) => {
const files = Array.from(event.target.files || []);
if (files.length > 0) {
updateItemRow(idx, { pending_files: [...it.pending_files, ...files] });
}
event.target.value = "";
}}
/>
</label>
</div>
{it.attachments.length > 0 || it.pending_files.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{it.attachments.map((attachment) => (
<span
key={`attachment-${attachment.id}`}
className="app-badge inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 text-[11px]"
>
<button
type="button"
className="inline-flex min-w-0 items-center gap-1"
onClick={() => void handleDownloadItemAttachment(it.id!, attachment)}
>
<Paperclip size={11} className="shrink-0" />
<span className="max-w-48 truncate">{attachment.file_name}</span>
</button>
<button
type="button"
title="Удалить файл"
onClick={() => updateItemRow(idx, {
attachments: it.attachments.filter((current) => current.id !== attachment.id),
removed_attachment_ids: [...it.removed_attachment_ids, attachment.id],
})}
className="app-action-ghost inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full"
>
<X size={10} />
</button>
</span>
))}
{it.pending_files.map((file, fileIndex) => (
<span
key={`pending-${file.name}-${file.lastModified}-${fileIndex}`}
className="app-selected inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 text-[11px]"
>
<Paperclip size={11} className="shrink-0" />
<span className="max-w-48 truncate">{file.name}</span>
<button
type="button"
title="Убрать файл"
onClick={() => updateItemRow(idx, {
pending_files: it.pending_files.filter((_, currentIndex) => currentIndex !== fileIndex),
})}
className="app-action-ghost inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full"
>
<X size={10} />
</button>
</span>
))}
</div>
) : (
<p className="app-text-muted text-[11px]">Файлы не прикреплены</p>
)}
</div>
</div>
</div>
))}
</div>
</div>
</div>
</Modal>
</AppShell>
);
}