2667 lines
140 KiB
TypeScript
2667 lines
140 KiB
TypeScript
"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>
|
||
);
|
||
}
|