improve responsive finance and procurement layouts

This commit is contained in:
lizerk 2026-08-07 12:08:50 +03:00
parent 9dfca72e4e
commit a8926987e2
6 changed files with 835 additions and 109 deletions

View File

@ -534,6 +534,113 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
container-type: inline-size;
}
.payroll-admin-records-responsive {
container-type: inline-size;
}
.procurement-detail-layout {
container-type: inline-size;
}
.procurement-detail-layout-grid {
display: grid;
grid-auto-flow: dense;
align-items: start;
gap: 0.75rem;
}
.procurement-detail-layout-full {
grid-column: 1 / -1;
}
.procurement-items-table-row {
display: grid;
grid-template-columns:
minmax(0, 1.5fr)
minmax(0, 0.55fr)
minmax(0, 0.9fr)
minmax(0, 0.9fr)
minmax(0, 1fr)
minmax(0, 0.9fr)
minmax(0, 1.15fr);
}
.procurement-items-table-row > * {
min-width: 0;
border-right: 1px solid var(--border-subtle);
}
.procurement-items-table-row > :last-child {
border-right: 0;
}
@container (min-width: 44rem) {
.procurement-detail-layout-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@container (min-width: 74rem) {
.procurement-detail-layout-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.payroll-admin-toolbar-responsive {
container-type: inline-size;
}
.payroll-admin-toolbar-layout {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.5rem;
}
.payroll-admin-toolbar-search,
.payroll-admin-toolbar-status,
.payroll-admin-toolbar-actions {
width: 100%;
}
@container (min-width: 42rem) {
.payroll-admin-toolbar-layout {
flex-direction: row;
}
.payroll-admin-toolbar-search {
width: auto;
flex: 1 1 0%;
}
.payroll-admin-toolbar-status {
width: 11rem;
}
.payroll-admin-toolbar-actions {
width: auto;
flex-shrink: 0;
}
.payroll-admin-toolbar-actions > .app-action-primary {
flex: none;
}
}
.payroll-admin-records-table {
display: none;
}
@container (min-width: 58rem) {
.payroll-admin-records-compact {
display: none;
}
.payroll-admin-records-table {
display: block;
}
}
.document-card-detail-action {
display: none;
}

View File

@ -32,7 +32,9 @@ import {
Filter,
Link2,
Loader2,
Maximize2,
MessageSquare,
Minimize2,
Package,
Paperclip,
Pencil,
@ -55,6 +57,7 @@ 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
@ -370,12 +373,56 @@ function ProcurementRequestActionButtons({
);
}
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,
@ -520,8 +567,10 @@ export default function ProcurementPage() {
}, [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);
@ -577,6 +626,7 @@ export default function ProcurementPage() {
}, [detailsCache, requests, submitApprovalRequestId]);
const closeDetailModal = useCallback(() => {
setDetailDialogOpen(false);
setDetailModalId(null);
setDetailModalError(null);
syncRequestUrl(null);
@ -749,21 +799,30 @@ export default function ProcurementPage() {
const openDetailModal = useCallback(async (requestId: number) => {
setDetailModalLoading(true);
setDetailModalError(null);
setDetailModalId(requestId);
setDetailDialogOpen(!desktopWideLayout);
syncRequestUrl(requestId);
try {
await ensureRequestDetail(requestId);
await markRequestNotificationsRead(requestId);
setDetailModalId(requestId);
syncRequestUrl(requestId);
} catch (detailError) {
setDetailModalError(getReadableError(detailError, "Не удалось открыть заявку"));
} finally {
setDetailModalLoading(false);
}
}, [ensureRequestDetail, markRequestNotificationsRead, syncRequestUrl]);
}, [desktopWideLayout, ensureRequestDetail, markRequestNotificationsRead, syncRequestUrl]);
useEffect(() => {
if (!desktopWideLayout || filteredRequests.length === 0) return;
if (detailModalId && filteredRequests.some((request) => request.id === detailModalId)) return;
void openDetailModal(filteredRequests[0].id);
}, [desktopWideLayout, detailModalId, filteredRequests, openDetailModal]);
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;
@ -847,7 +906,10 @@ export default function ProcurementPage() {
══════════════════════════════════════════════════════ */
return (
<AppShell>
<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" />
@ -858,7 +920,7 @@ export default function ProcurementPage() {
<p className="text-sm text-red-800">{error}</p>
</div>
) : (
<section className="app-surface rounded-2xl p-4">
<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>
@ -875,11 +937,23 @@ export default function ProcurementPage() {
</button>
</div> */}
</div>
{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} /> Создать заявку
<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 ── */}
@ -1137,8 +1211,184 @@ export default function ProcurementPage() {
</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)] p-2" aria-label="Список заявок на закупку">
<div className="app-divider sticky top-0 z-10 mb-2 flex items-center justify-between gap-2 border-b bg-[var(--surface-secondary)] px-2 py-2">
<span className="text-xs font-semibold uppercase tracking-wide text-[var(--muted-foreground)]">Заявки</span>
<span className="app-counter inline-flex h-5 min-w-5 px-1 text-[10px]">{filteredRequests.length}</span>
</div>
<div className="space-y-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 hasUnreadNotifications = (unreadProcurementRequestCounts[req.id] || 0) > 0;
return (
<button
key={req.id}
type="button"
onClick={() => void openDetailModal(req.id)}
className={`${active ? "app-selected border-[var(--accent-primary)]" : "app-surface hover:border-[var(--border-strong)]"} block w-full rounded-xl border p-3 text-left transition`}
aria-current={active ? "true" : undefined}
>
<span className="flex min-w-0 items-start gap-2">
{hasUnreadNotifications ? <span className="app-dot-accent mt-1.5 h-2 w-2 shrink-0 rounded-full" aria-label="Есть новые уведомления" /> : null}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-[var(--foreground)]">#{req.id} · {req.title || "Без названия"}</span>
<span className="app-text-muted mt-1 block truncate text-xs">{getDeptName(req)}</span>
</span>
<ChevronRight size={15} className="app-text-muted mt-0.5 shrink-0" />
</span>
<span className="mt-2 flex min-w-0 items-center justify-between gap-2">
<span className="app-text-muted min-w-0 truncate text-xs">{getRequestListDateLabel(req, String(req.status || "").toLowerCase())}</span>
<span className={`app-status-pill shrink-0 ${status.cls}`}>{status.label}</span>
</span>
<span className="mt-2 flex min-w-0 items-center justify-between gap-2 text-xs">
<span className={`truncate font-medium ${urgency.cls}`}>{urgency.label} срочность</span>
<span className="shrink-0 font-semibold text-[var(--foreground)] tabular-nums">{amount ? money(amount) : "—"}</span>
</span>
</button>
);
})}
</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 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>
<span>{fmt(selectedRequest.created_at)}</span>
{getRequestAmount(selectedRequest) ? <span className="font-semibold text-[var(--foreground)]">{money(getRequestAmount(selectedRequest))}</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}
expandedItemComments={expandedItemComments}
onToggleItemComments={toggleItemComments}
onItemCommentDraftChange={(itemId, value) => setItemCommentDrafts((previous) => ({ ...previous, [itemId]: value }))}
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 space-y-2">
{(commentsMap[selectedRequest.id] || []).length === 0 ? (
<p className="app-text-muted text-xs">Комментариев пока нет</p>
) : (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 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>
)}
/>
</div>
) : (
<div className="flex 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">
<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" />
@ -1581,20 +1831,20 @@ export default function ProcurementPage() {
</div>
{/* ── load more ── */}
{nextPage && (
<div className="mt-4 flex justify-center">
<button type="button" onClick={handleLoadMore} disabled={loadingMore} className="app-action-secondary rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-60">
{loadingMore ? "Загружаем..." : "Загрузить ещё"}
</button>
</div>
)}
<div className={desktopWideMode ? "lg:hidden" : ""}>
<InfiniteRequestLoader
hasMore={Boolean(nextPage)}
loading={loadingMore}
onLoadMore={handleLoadMore}
/>
</div>
</>
)}
</section>
)}
<Modal
isOpen={detailModalId !== null}
isOpen={detailModalId !== null && detailDialogOpen}
onClose={closeDetailModal}
title={selectedRequest?.title || "Карточка заявки"}
size="lg"

View File

@ -1,6 +1,7 @@
"use client";
import { CalendarCheck2, Check, Loader2, Pencil, RotateCcw } from "lucide-react";
import { CalendarCheck2, Check, ChevronDown, Loader2, Pencil, RotateCcw } from "lucide-react";
import { type ReactNode, useState } from "react";
import type {
PayrollAdminInputLine,
@ -20,6 +21,57 @@ function RecordStatus({ status }: { status: PayrollApprovalStatus }) {
return <span className={`app-status-pill ${meta.className}`}>{meta.label}</span>;
}
function CompactRecordCard({
title,
subtitle,
summary,
status,
children,
}: {
title: string;
subtitle: string;
summary: ReactNode;
status: PayrollApprovalStatus;
children: ReactNode;
}) {
const [open, setOpen] = useState(false);
return (
<article className="app-surface-muted overflow-hidden rounded-xl transition hover:border-[var(--border-strong)]">
<button
type="button"
className="flex w-full items-start gap-3 p-3 text-left"
onClick={() => setOpen((current) => !current)}
aria-expanded={open}
>
<span className="app-action-secondary mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
<ChevronDown size={15} className={`transition ${open ? "rotate-180" : ""}`} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-[var(--foreground)]">{title}</span>
<span className="app-text-muted mt-0.5 block truncate text-xs">{subtitle}</span>
<span className="mt-2 block text-sm font-medium text-[var(--foreground)]">{summary}</span>
</span>
<span className="shrink-0"><RecordStatus status={status} /></span>
</button>
{open ? (
<div className="border-t border-[var(--border-subtle)] bg-[var(--surface-primary)] p-3">
{children}
</div>
) : null}
</article>
);
}
function CompactField({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="grid grid-cols-[minmax(7rem,40%)_minmax(0,1fr)] items-start gap-3 py-1.5 text-sm">
<span className="app-text-muted">{label}</span>
<span className="min-w-0 text-right font-medium text-[var(--foreground)]">{children}</span>
</div>
);
}
function ListState({ loading, error, empty }: { loading: boolean; error: string | null; empty: boolean }) {
if (loading) {
return (
@ -42,7 +94,7 @@ function ActionButton({
tooltip,
}: {
label: string;
icon: React.ReactNode;
icon: ReactNode;
onClick: () => void;
tone?: "secondary" | "success";
disabled?: boolean;
@ -127,23 +179,48 @@ export function PayrollRatesTable({
if (loading || error || records.length === 0) return state;
return (
<div className="overflow-hidden rounded-xl border border-[var(--border-subtle)] md:overflow-x-auto">
<div className="md:min-w-[900px]">
<div className="app-surface-muted hidden grid-cols-[minmax(180px,1.5fr)_minmax(100px,.8fr)_minmax(105px,.8fr)_120px_115px_minmax(150px,1fr)] gap-3 border-0 border-b border-[var(--border-subtle)] px-4 py-2.5 text-xs font-medium text-[var(--muted-foreground)] md:grid">
<span>Сотрудник</span><span>Оклад</span><span>Цена сверх нормы</span><span>Действует с</span><span>Статус</span><span className="text-right">Действия</span>
</div>
<div className="divide-y divide-[var(--border-subtle)]">
<div className="payroll-admin-records-responsive">
<div className="payroll-admin-records-compact space-y-2">
{records.map((record) => (
<div key={record.id} className="grid gap-3 bg-[var(--surface-primary)] px-4 py-3 md:grid-cols-[minmax(180px,1.5fr)_minmax(100px,.8fr)_minmax(105px,.8fr)_120px_115px_minmax(150px,1fr)] md:items-center">
<CompactRecordCard
key={record.id}
title={record.employee.display_name}
subtitle={record.employee.position || record.employee.department || "Сотрудник"}
summary={formatPayrollMoney(record.amount, record.currency)}
status={record.status}
>
<div className="divide-y divide-[var(--border-subtle)]">
<CompactField label="Оклад">{formatPayrollMoney(record.amount, record.currency)}</CompactField>
<CompactField label="Цена сверх нормы">
{record.point_rate === null ? "Как в норме" : formatPayrollMoney(record.point_rate, record.currency)}
</CompactField>
<CompactField label="Действует с">{formatPayrollDate(record.effective_from)}</CompactField>
</div>
<div className="mt-3 border-t border-[var(--border-subtle)] pt-3">
<RecordActions status={record.status} isAuthor={record.created_by.id === currentUserId} canManage={canManage} canApprove={canApprove} canOverrideApproval={canOverrideApproval} fullAccess={fullAccess} onEdit={() => onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} />
</div>
</CompactRecordCard>
))}
</div>
<div className="payroll-admin-records-table overflow-x-auto rounded-xl border border-[var(--border-subtle)]">
<div className="min-w-[900px]">
<div className="app-surface-muted grid grid-cols-[minmax(180px,1.5fr)_minmax(100px,.8fr)_minmax(105px,.8fr)_120px_115px_minmax(150px,1fr)] gap-3 border-0 border-b border-[var(--border-subtle)] px-4 py-2.5 text-xs font-medium text-[var(--muted-foreground)]">
<span>Сотрудник</span><span>Оклад</span><span>Цена сверх нормы</span><span>Действует с</span><span>Статус</span><span className="text-right">Действия</span>
</div>
<div className="divide-y divide-[var(--border-subtle)]">
{records.map((record) => (
<div key={record.id} className="grid grid-cols-[minmax(180px,1.5fr)_minmax(100px,.8fr)_minmax(105px,.8fr)_120px_115px_minmax(150px,1fr)] items-center gap-3 bg-[var(--surface-primary)] px-4 py-3">
<div className="min-w-0"><p className="truncate text-sm font-semibold text-[var(--foreground)]">{record.employee.display_name}</p><p className="app-text-muted mt-0.5 truncate text-xs">{record.employee.position || record.employee.department || "Сотрудник"}</p></div>
<div><span className="app-text-muted mr-2 text-xs md:hidden">Оклад</span><span className="text-sm font-medium text-[var(--foreground)]">{formatPayrollMoney(record.amount, record.currency)}</span></div>
<div><span className="app-text-muted mr-2 text-xs md:hidden">Цена сверх нормы</span><span className={`text-sm ${record.point_rate === null ? "text-[var(--muted-foreground)]" : "text-[var(--foreground)]"}`}>{record.point_rate === null ? "Как в норме" : formatPayrollMoney(record.point_rate, record.currency)}</span></div>
<div><span className="app-text-muted mr-2 text-xs md:hidden">С</span><span className="text-sm text-[var(--foreground)]">{formatPayrollDate(record.effective_from)}</span></div>
<div><span className="text-sm font-medium text-[var(--foreground)]">{formatPayrollMoney(record.amount, record.currency)}</span></div>
<div><span className={`text-sm ${record.point_rate === null ? "text-[var(--muted-foreground)]" : "text-[var(--foreground)]"}`}>{record.point_rate === null ? "Как в норме" : formatPayrollMoney(record.point_rate, record.currency)}</span></div>
<div><span className="text-sm text-[var(--foreground)]">{formatPayrollDate(record.effective_from)}</span></div>
<RecordStatus status={record.status} />
<RecordActions status={record.status} isAuthor={record.created_by.id === currentUserId} canManage={canManage} canApprove={canApprove} canOverrideApproval={canOverrideApproval} fullAccess={fullAccess} onEdit={() => onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} />
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
@ -178,16 +255,51 @@ export function PayrollWorkRecordsTable({
if (loading || error || records.length === 0) return state;
return (
<div className="overflow-hidden rounded-xl border border-[var(--border-subtle)] md:overflow-x-auto">
<div className="md:min-w-[880px]">
<div className="app-surface-muted hidden grid-cols-[minmax(190px,1.5fr)_110px_110px_120px_115px_minmax(150px,1fr)] gap-3 border-0 border-b border-[var(--border-subtle)] px-4 py-2.5 text-xs font-medium text-[var(--muted-foreground)] md:grid">
<span>Сотрудник</span><span>Норма</span><span>Факт</span><span>Контроль</span><span>Статус</span><span className="text-right">Действия</span>
</div>
<div className="divide-y divide-[var(--border-subtle)]">
<div className="payroll-admin-records-responsive">
<div className="payroll-admin-records-compact space-y-2">
{records.map((record) => {
const fromAttendance = isAttendancePayrollWorkRecord(record.source);
return (
<div key={record.id} className="grid gap-3 bg-[var(--surface-primary)] px-4 py-3 md:grid-cols-[minmax(190px,1.5fr)_110px_110px_120px_115px_minmax(150px,1fr)] md:items-center">
<CompactRecordCard
key={record.id}
title={record.employee.display_name}
subtitle={record.employee.position || record.employee.department || "Сотрудник"}
summary={(
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
<span>{formatPayrollWorkMetric(record.actual_points, record.source)} / {formatPayrollWorkMetric(record.target_points, record.source)}</span>
{fromAttendance ? (
<span className="app-selected inline-flex items-center gap-1 rounded-full px-2 py-1 text-[10px] font-semibold leading-none">
<CalendarCheck2 size={11} /> Из посещаемости
</span>
) : null}
</span>
)}
status={record.status}
>
<div className="divide-y divide-[var(--border-subtle)]">
<CompactField label="Норма">{formatPayrollWorkMetric(record.target_points, record.source)}</CompactField>
<CompactField label="Факт">{formatPayrollWorkMetric(record.actual_points, record.source)}</CompactField>
<CompactField label="Контроль">{record.expected_payable ? formatPayrollMoney(record.expected_payable) : "—"}</CompactField>
<CompactField label="Источник">{fromAttendance ? "Посещаемость" : "Введено вручную"}</CompactField>
</div>
<div className="mt-3 border-t border-[var(--border-subtle)] pt-3">
<RecordActions status={record.status} isAuthor={record.created_by.id === currentUserId} canManage={canManage} canApprove={canApprove} canOverrideApproval={canOverrideApproval} fullAccess={fullAccess} onEdit={() => onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} />
</div>
</CompactRecordCard>
);
})}
</div>
<div className="payroll-admin-records-table overflow-x-auto rounded-xl border border-[var(--border-subtle)]">
<div className="min-w-[880px]">
<div className="app-surface-muted grid grid-cols-[minmax(190px,1.5fr)_110px_110px_120px_115px_minmax(150px,1fr)] gap-3 border-0 border-b border-[var(--border-subtle)] px-4 py-2.5 text-xs font-medium text-[var(--muted-foreground)]">
<span>Сотрудник</span><span>Норма</span><span>Факт</span><span>Контроль</span><span>Статус</span><span className="text-right">Действия</span>
</div>
<div className="divide-y divide-[var(--border-subtle)]">
{records.map((record) => {
const fromAttendance = isAttendancePayrollWorkRecord(record.source);
return (
<div key={record.id} className="grid grid-cols-[minmax(190px,1.5fr)_110px_110px_120px_115px_minmax(150px,1fr)] items-center gap-3 bg-[var(--surface-primary)] px-4 py-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-[var(--foreground)]">{record.employee.display_name}</p>
<p className="app-text-muted mt-0.5 truncate text-xs">{record.employee.position || record.employee.department || "Сотрудник"}</p>
@ -197,15 +309,16 @@ export function PayrollWorkRecordsTable({
</span>
) : null}
</div>
<div><span className="app-text-muted mr-2 text-xs md:hidden">Норма</span><span className="text-sm font-medium text-[var(--foreground)]">{formatPayrollWorkMetric(record.target_points, record.source)}</span></div>
<div><span className="app-text-muted mr-2 text-xs md:hidden">Факт</span><span className="text-sm font-medium text-[var(--foreground)]">{formatPayrollWorkMetric(record.actual_points, record.source)}</span></div>
<div><span className="app-text-muted mr-2 text-xs md:hidden">Контроль</span><span className="text-sm text-[var(--foreground)]">{record.expected_payable ? formatPayrollMoney(record.expected_payable) : "—"}</span></div>
<div><span className="text-sm font-medium text-[var(--foreground)]">{formatPayrollWorkMetric(record.target_points, record.source)}</span></div>
<div><span className="text-sm font-medium text-[var(--foreground)]">{formatPayrollWorkMetric(record.actual_points, record.source)}</span></div>
<div><span className="text-sm text-[var(--foreground)]">{record.expected_payable ? formatPayrollMoney(record.expected_payable) : "—"}</span></div>
<RecordStatus status={record.status} />
<RecordActions status={record.status} isAuthor={record.created_by.id === currentUserId} canManage={canManage} canApprove={canApprove} canOverrideApproval={canOverrideApproval} fullAccess={fullAccess} onEdit={() => onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} />
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
);
@ -238,14 +351,46 @@ export function PayrollInputLinesTable({
if (loading || error || records.length === 0) return state;
return (
<div className="overflow-hidden rounded-xl border border-[var(--border-subtle)] md:overflow-x-auto">
<div className="md:min-w-[800px]">
<div className="app-surface-muted hidden grid-cols-[minmax(170px,1.35fr)_minmax(150px,1.2fr)_120px_115px_minmax(150px,1fr)] gap-3 border-0 border-b border-[var(--border-subtle)] px-4 py-2.5 text-xs font-medium text-[var(--muted-foreground)] md:grid">
<span>Сотрудник</span><span>Операция</span><span>Сумма</span><span>Статус</span><span className="text-right">Действия</span>
</div>
<div className="divide-y divide-[var(--border-subtle)]">
<div className="payroll-admin-records-responsive">
<div className="payroll-admin-records-compact space-y-2">
{records.map((record) => (
<div key={record.id} className="grid gap-3 bg-[var(--surface-primary)] px-4 py-3 md:grid-cols-[minmax(170px,1.35fr)_minmax(150px,1.2fr)_120px_115px_minmax(150px,1fr)] md:items-center">
<CompactRecordCard
key={record.id}
title={record.employee.display_name}
subtitle={record.employee.position || record.employee.department || "Сотрудник"}
summary={(
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
<span>{record.component.name}</span>
<span className="app-accent-text">{formatPayrollMoney(record.amount)}</span>
</span>
)}
status={record.status}
>
<div className="divide-y divide-[var(--border-subtle)]">
<CompactField label="Операция">{record.component.name}</CompactField>
<CompactField label="Сумма">{formatPayrollMoney(record.amount)}</CompactField>
{record.relates_to_period ? (
<CompactField label={record.is_automatic_carryover ? "Автоперенос" : "Перерасчёт"}>
{record.relates_to_period.name || record.relates_to_period.code}
</CompactField>
) : null}
{record.reason ? <CompactField label="Причина">{record.reason}</CompactField> : null}
</div>
<div className="mt-3 border-t border-[var(--border-subtle)] pt-3">
<RecordActions status={record.status} isAuthor={record.created_by.id === currentUserId} canManage={canManage} canApprove={canApprove} canOverrideApproval={canOverrideApproval} fullAccess={fullAccess} onEdit={() => onEdit(record)} onApprove={() => onApprove(record)} />
</div>
</CompactRecordCard>
))}
</div>
<div className="payroll-admin-records-table overflow-x-auto rounded-xl border border-[var(--border-subtle)]">
<div className="min-w-[800px]">
<div className="app-surface-muted grid grid-cols-[minmax(170px,1.35fr)_minmax(150px,1.2fr)_120px_115px_minmax(150px,1fr)] gap-3 border-0 border-b border-[var(--border-subtle)] px-4 py-2.5 text-xs font-medium text-[var(--muted-foreground)]">
<span>Сотрудник</span><span>Операция</span><span>Сумма</span><span>Статус</span><span className="text-right">Действия</span>
</div>
<div className="divide-y divide-[var(--border-subtle)]">
{records.map((record) => (
<div key={record.id} className="grid grid-cols-[minmax(170px,1.35fr)_minmax(150px,1.2fr)_120px_115px_minmax(150px,1fr)] items-center gap-3 bg-[var(--surface-primary)] px-4 py-3">
<div className="min-w-0"><p className="truncate text-sm font-semibold text-[var(--foreground)]">{record.employee.display_name}</p><p className="app-text-muted mt-0.5 truncate text-xs">{record.employee.position || record.employee.department || "Сотрудник"}</p></div>
<div className="min-w-0">
<p className="truncate text-sm text-[var(--foreground)]">{record.component.name}</p>
@ -259,12 +404,13 @@ export function PayrollInputLinesTable({
) : null}
{record.reason ? <p className="app-text-muted mt-1 truncate text-xs" title={record.reason}>{record.reason}</p> : null}
</div>
<div><span className="app-text-muted mr-2 text-xs md:hidden">Сумма</span><span className="text-sm font-medium text-[var(--foreground)]">{formatPayrollMoney(record.amount)}</span></div>
<div><span className="text-sm font-medium text-[var(--foreground)]">{formatPayrollMoney(record.amount)}</span></div>
<RecordStatus status={record.status} />
<RecordActions status={record.status} isAuthor={record.created_by.id === currentUserId} canManage={canManage} canApprove={canApprove} canOverrideApproval={canOverrideApproval} fullAccess={fullAccess} onEdit={() => onEdit(record)} onApprove={() => onApprove(record)} />
</div>
))}
</div>
</div>
</div>
</div>
</div>
);

View File

@ -264,17 +264,18 @@ function TableToolbar({
}, [addMenuOpen]);
return (
<div className="mb-4 flex flex-col gap-2 sm:flex-row">
<label className="relative min-w-0 flex-1">
<div className="payroll-admin-toolbar-responsive mb-4">
<div className="payroll-admin-toolbar-layout">
<label className="payroll-admin-toolbar-search relative min-w-0">
<Search className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" size={15} />
<input className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm" value={search} placeholder="Поиск по сотруднику" onChange={(event) => onSearch(event.target.value)} />
</label>
<select className="app-select rounded-lg px-3 py-2.5 text-sm sm:w-44" value={status} onChange={(event) => onStatus(event.target.value as "" | PayrollApprovalStatus)}>
<select className="payroll-admin-toolbar-status app-select min-w-0 rounded-lg px-3 py-2.5 text-sm" value={status} onChange={(event) => onStatus(event.target.value as "" | PayrollApprovalStatus)}>
<option value="">Все статусы</option><option value="draft">Черновики</option><option value="approved">Утверждённые</option><option value="voided">Аннулированные</option>
</select>
{onAdd ? (
<div ref={addMenuRef} className="relative flex shrink-0 items-center gap-1">
<button type="button" className="app-action-primary inline-flex items-center justify-center gap-2 rounded-lg px-3.5 py-2.5 text-sm font-semibold" onClick={onAdd}>
<div ref={addMenuRef} className="payroll-admin-toolbar-actions relative flex min-w-0 items-center gap-1">
<button type="button" className="app-action-primary inline-flex min-w-0 flex-1 items-center justify-center gap-2 rounded-lg px-3.5 py-2.5 text-sm font-semibold" onClick={onAdd}>
<Plus size={16} /> {addLabel}
</button>
{hasAdditionalActions ? (
@ -348,6 +349,7 @@ function TableToolbar({
) : null}
</div>
) : null}
</div>
</div>
);
}

View File

@ -43,6 +43,7 @@ interface ProcurementRequestDetailContentProps {
onUploadItemAttachments?: (requestId: number, itemId: number, files: File[]) => void | Promise<unknown>;
onDeleteItemAttachment?: (requestId: number, itemId: number, attachmentId: number) => void | Promise<unknown>;
footer?: React.ReactNode;
wideLayout?: boolean;
}
type ItemProcessingDraft = {
@ -188,6 +189,7 @@ interface ProcurementItemCardProps {
onDownloadAttachment?: (itemId: number, attachment: ProcurementItemAttachment) => void | Promise<void>;
onUploadAttachments?: (requestId: number, itemId: number, files: File[]) => void | Promise<unknown>;
onDeleteAttachment?: (requestId: number, itemId: number, attachmentId: number) => void | Promise<unknown>;
wideLayout?: boolean;
}
function ProcurementItemCard({
@ -219,6 +221,7 @@ function ProcurementItemCard({
onDownloadAttachment,
onUploadAttachments,
onDeleteAttachment,
wideLayout = false,
}: ProcurementItemCardProps) {
const [draft, setDraft] = useState<ItemProcessingDraft>(() => normalizeItemDraft(item));
const [detailsOpen, setDetailsOpen] = useState(false);
@ -402,7 +405,94 @@ function ProcurementItemCard({
};
return (
<div className="app-surface-muted rounded-lg px-3 py-3 text-xs">
<div role={wideLayout ? "rowgroup" : undefined} className={wideLayout ? "border-b border-[var(--border-subtle)] text-xs last:border-b-0" : "app-surface-muted rounded-lg px-3 py-3 text-xs"}>
{wideLayout ? (
<div className="procurement-items-table-row items-center" role="row">
<div className="flex min-w-0 items-center gap-2 px-2 py-2.5" role="cell">
<button
type="button"
onClick={() => setDetailsOpen((open) => !open)}
aria-expanded={detailsOpen}
aria-controls={`procurement-item-${item.id}-details`}
className="app-icon-button inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg"
title={detailsOpen ? "Свернуть позицию" : "Раскрыть позицию"}
>
<ChevronRight size={14} className={`transition-transform duration-200 ${detailsOpen ? "rotate-90" : ""}`} />
</button>
<div className="min-w-0">
<button type="button" onClick={() => setDetailsOpen((open) => !open)} className="block w-full text-left">
<span className="app-text-wrap block font-semibold text-[var(--foreground)]">{item.name}</span>
</button>
{item.supplier_info ? <span className="app-text-muted mt-0.5 block truncate text-[10px]">{item.supplier_info}</span> : null}
</div>
</div>
<div className="px-2 py-2.5 font-medium text-[var(--foreground)]" role="cell">
{item.quantity} {itemUnitLabel}
</div>
<div className="space-y-1 px-2 py-2.5 tabular-nums" role="cell">
<div className="flex items-center gap-1.5">
{orderedQuantityMarkerClass ? <span aria-hidden="true" className={`h-2 w-2 shrink-0 rounded-full ${orderedQuantityMarkerClass}`} /> : null}
<span><span className="app-text-muted">Заказано</span> {orderedQuantity}/{requestedQuantity}</span>
</div>
<div><span className="app-text-muted">Получено</span> {receivedQuantity}/{requestedQuantity}</div>
</div>
<div className="px-2 py-2.5" role="cell">
{expectedDeliveryDates.length > 0 ? (
<div className="flex flex-wrap gap-1">
{expectedDeliveryDates.map((date, dateIndex) => (
<span key={`${item.id}-table-date-${dateIndex}`} className={`${getExpectedDeliveryDateBadgeClass(date, isItemFullyReceived)} rounded-full px-1.5 py-0.5 text-[10px] font-medium`}>
{formatDate(date)}
</span>
))}
</div>
) : <span className="app-text-muted">—</span>}
</div>
<div className="space-y-1 px-2 py-2.5 text-right tabular-nums" role="cell">
<div><span className="app-text-muted">Ориентир</span> {formatMoney(item.estimated_unit_price)}</div>
<div><span className="app-text-muted">Факт</span> {item.actual_unit_price ? formatMoney(item.actual_unit_price) : "—"}</div>
<div className="font-semibold text-[var(--foreground)]"><span className="app-text-muted font-normal">Расчёт</span> {formatMoney(item.total_price)}</div>
</div>
<div className="px-2 py-2.5" role="cell">
<span title={statusLabel} className={`app-status-pill inline-flex max-w-full truncate ${executionStatusBadgeClass(status)}`}>{statusLabel}</span>
</div>
<div className="flex flex-wrap items-center gap-1 px-2 py-2.5" role="cell" aria-label={`Действия для позиции ${item.name}`}>
<button type="button" onClick={() => void onToggleComments?.(item.id)} className="app-icon-button relative inline-flex h-7 w-7 items-center justify-center rounded-lg" title="Комментарии">
<MessageSquare size={13} />
{commentsTotal > 0 ? <span className="app-counter absolute -right-1 -top-1 inline-flex h-4 min-w-4 items-center justify-center px-1 text-[9px] text-white">{commentsTotal}</span> : null}
</button>
{canEditItemProcessing ? (
<button type="button" onClick={() => setProcessingOpen((open) => !open)} className="app-icon-button inline-flex h-7 w-7 items-center justify-center rounded-lg" title="Исполнение">
<SlidersHorizontal size={13} />
</button>
) : null}
{canConfirmOrdered ? (
<button type="button" onClick={openOrderModal} disabled={busyKey === `item-confirm-ordered-${item.id}`} className="app-icon-button inline-flex h-7 w-7 items-center justify-center rounded-lg text-sky-500 disabled:opacity-60" title="Подтвердить заказ">
<ShoppingCart size={13} />
</button>
) : null}
{canConfirmReceived ? (
<button type="button" onClick={handleConfirmReceiptClick} disabled={busyKey === `item-confirm-received-${item.id}`} className="app-icon-button inline-flex h-7 w-7 items-center justify-center rounded-lg text-emerald-500 disabled:opacity-60" title="Подтвердить получение">
<CheckCircle2 size={13} />
</button>
) : null}
{canCancelReceived ? (
<button type="button" onClick={handleCancelReceiptClick} disabled={busyKey === `item-cancel-received-${item.id}`} className="app-icon-button inline-flex h-7 w-7 items-center justify-center rounded-lg disabled:opacity-60" title="Отменить получение">
<RotateCcw size={13} />
</button>
) : null}
{canMarkIssue ? (
<button type="button" onClick={() => void onReportItemIssue?.(requestId, item.id)} disabled={busyKey === `item-issue-${item.id}`} className="app-icon-button inline-flex h-7 w-7 items-center justify-center rounded-lg text-amber-500 disabled:opacity-60" title="Отметить брак">
<AlertTriangle size={13} />
</button>
) : null}
{canCancelItemIssue ? (
<button type="button" onClick={() => void onCancelItemIssue?.(requestId, item.id)} disabled={busyKey === `item-cancel-issue-${item.id}`} className="app-icon-button inline-flex h-7 w-7 items-center justify-center rounded-lg text-amber-500 disabled:opacity-60" title="Отменить брак">
<RotateCcw size={13} />
</button>
) : null}
</div>
</div>
) : (
<div className="flex items-start gap-2">
<button
type="button"
@ -447,11 +537,57 @@ function ProcurementItemCard({
</div>
<span className={`app-status-pill shrink-0 ${executionStatusBadgeClass(status)}`}>{statusLabel}</span>
</div>
)}
<div
id={`procurement-item-${item.id}-details`}
className={detailsOpen ? "" : "hidden"}
>
{wideLayout ? (
<div className="border-t border-[var(--border-subtle)] bg-[var(--surface-primary)] px-3 py-3">
<div className="grid gap-3 lg:grid-cols-2">
<div className="min-w-0 space-y-1">
<p className="app-text-muted text-[10px] font-semibold uppercase tracking-wide">Описание</p>
<p className="app-text-wrap text-xs text-[var(--foreground)]">{item.description || "—"}</p>
{item.supplier_info ? <p className="app-text-wrap text-xs text-[var(--muted-foreground)]">Поставщик: {item.supplier_info}</p> : null}
</div>
<div className="min-w-0 space-y-2">
{links.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{links.map((link, linkIndex) => (
<a key={`${item.id}-table-link-${linkIndex}`} href={linkHref(link)} target="_blank" rel="noreferrer" className="app-badge inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium no-underline hover:bg-[var(--surface-tertiary)] hover:no-underline">
<ExternalLink size={11} /><span className="truncate">{link}</span>
</a>
))}
</div>
) : null}
<div className="flex flex-wrap items-center gap-1.5">
{attachments.map((attachment) => (
<span key={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" onClick={() => void onDownloadAttachment?.(item.id, attachment)} className="inline-flex min-w-0 items-center gap-1" title={`Скачать ${attachment.file_name}`}>
<Paperclip size={11} className="shrink-0" /><span className="max-w-48 truncate">{attachment.file_name}</span><Download size={10} className="shrink-0" />
</button>
{canEditItemProcessing && onDeleteAttachment ? (
<button type="button" title="Удалить файл" disabled={busyKey === `item-attachment-delete-${attachment.id}`} onClick={() => void onDeleteAttachment(requestId, item.id, attachment.id)} className="app-action-ghost inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full disabled:opacity-60"><Trash2 size={10} /></button>
) : null}
</span>
))}
{canEditItemProcessing && onUploadAttachments ? (
<label className="app-action-ghost inline-flex cursor-pointer items-center gap-1 rounded-lg px-2 py-1 text-[11px] font-medium">
<Plus size={11} /> Добавить файл
<input type="file" multiple className="hidden" onChange={(event) => {
const files = Array.from(event.target.files || []);
if (files.length > 0) void onUploadAttachments(requestId, item.id, files);
event.target.value = "";
}} />
</label>
) : null}
</div>
</div>
</div>
</div>
) : (
<>
<div className="mt-3 grid gap-x-6 gap-y-3 lg:grid-cols-[minmax(0,1fr)_minmax(190px,0.55fr)]">
<div className="min-w-0">
<p className="app-text-muted mb-1 text-[11px] font-semibold uppercase tracking-wide">Исполнение</p>
@ -596,7 +732,10 @@ function ProcurementItemCard({
)}
</div>
) : null}
</>
)}
{!wideLayout ? (
<div className="mt-3 flex flex-wrap items-center gap-2">
<div>
<button
@ -685,9 +824,10 @@ function ProcurementItemCard({
</button>
) : null}
</div>
) : null}
{commentsOpen ? (
<div className="mt-2 space-y-2">
<div className={`${wideLayout ? "border-t border-[var(--border-subtle)] bg-[var(--surface-primary)] px-3 py-3" : "mt-2"} space-y-2`}>
{comments.length === 0 ? (
<p className="app-text-muted text-xs">Комментариев по позиции пока нет</p>
) : (
@ -728,7 +868,7 @@ function ProcurementItemCard({
) : null}
{canEditItemProcessing && processingOpen ? (
<div className="mt-3 grid gap-2 rounded-lg bg-[var(--surface-primary)] p-3 sm:grid-cols-2">
<div className={`${wideLayout ? "border-t border-[var(--border-subtle)]" : "mt-3 rounded-lg"} grid gap-2 bg-[var(--surface-primary)] p-3 sm:grid-cols-2`}>
<div>
<label className="app-text-muted mb-1 block text-[11px] font-medium">Статус позиции</label>
<select
@ -1170,6 +1310,7 @@ export function ProcurementRequestDetailContent({
onUploadItemAttachments,
onDeleteItemAttachment,
footer,
wideLayout = false,
}: ProcurementRequestDetailContentProps) {
const canEditItemProcessing = Boolean(canProcessItems && onUpdateItem);
const requestStatus = String(request.status || "").toLowerCase();
@ -1210,9 +1351,52 @@ export function ProcurementRequestDetailContent({
canEditItemProcessing
),
);
const renderedItems = displayItems.map((item) => (
<ProcurementItemCard
key={[
item.id,
item.execution_status || "",
Array.isArray(item.expected_delivery_dates) ? item.expected_delivery_dates.join("|") : "",
item.actual_unit_price || "",
item.ordered_quantity ?? "",
item.received_quantity ?? "",
Array.isArray(item.links) ? item.links.join("|") : "",
].join(":")}
item={item}
requestId={request.id}
displayUserName={displayUserName}
canEditItemProcessing={canEditItemProcessing}
canReportIssue={canReportIssues}
canCancelIssue={canCancelIssues}
canConfirmOrder={canConfirmOrder}
canConfirmReceipt={canConfirmReceipt}
canCancelReceipt={canCancelReceipt}
currentUserId={currentUserId}
canDeleteAnyComment={canDeleteAnyComment}
busyKey={busyKey}
onUpdateItem={onUpdateItem}
onReportItemIssue={onReportItemIssue}
onCancelItemIssue={onCancelItemIssue}
onConfirmItemOrdered={onConfirmItemOrdered}
onConfirmItemReceived={onConfirmItemReceived}
onCancelItemReceived={onCancelItemReceived}
comments={itemCommentsMap[item.id] || []}
commentDraft={itemCommentDrafts[item.id] || ""}
commentsOpen={Boolean(expandedItemComments[item.id])}
onToggleComments={onToggleItemComments}
onCommentDraftChange={onItemCommentDraftChange}
onAddComment={onAddItemComment}
onDeleteComment={onDeleteItemComment}
onDownloadAttachment={onDownloadItemAttachment}
onUploadAttachments={onUploadItemAttachments}
onDeleteAttachment={onDeleteItemAttachment}
wideLayout={wideLayout}
/>
));
return (
<div className="space-y-3">
<div className={wideLayout ? "procurement-detail-layout" : ""}>
<div className={wideLayout ? "procurement-detail-layout-grid" : "space-y-3"}>
<div className="app-surface rounded-xl p-4">
<p className="text-sm font-semibold text-[var(--foreground)]">Описание</p>
<p className="app-text-wrap mt-2 whitespace-pre-line text-sm leading-6 text-[var(--foreground)]">
@ -1244,7 +1428,7 @@ export function ProcurementRequestDetailContent({
</div>
{displayItems.length > 0 ? (
<div className="app-surface rounded-xl p-4">
<div className={`${wideLayout ? "procurement-detail-layout-full" : ""} app-surface rounded-xl p-4`}>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold text-[var(--foreground)]">Позиции</p>
{canMarkAllReceived && onMarkAllReceived ? (
@ -1258,49 +1442,24 @@ export function ProcurementRequestDetailContent({
</button>
) : null}
</div>
<div className="space-y-2">
{displayItems.map((item) => (
<ProcurementItemCard
key={[
item.id,
item.execution_status || "",
Array.isArray(item.expected_delivery_dates) ? item.expected_delivery_dates.join("|") : "",
item.actual_unit_price || "",
item.ordered_quantity ?? "",
item.received_quantity ?? "",
Array.isArray(item.links) ? item.links.join("|") : "",
].join(":")}
item={item}
requestId={request.id}
displayUserName={displayUserName}
canEditItemProcessing={canEditItemProcessing}
canReportIssue={canReportIssues}
canCancelIssue={canCancelIssues}
canConfirmOrder={canConfirmOrder}
canConfirmReceipt={canConfirmReceipt}
canCancelReceipt={canCancelReceipt}
currentUserId={currentUserId}
canDeleteAnyComment={canDeleteAnyComment}
busyKey={busyKey}
onUpdateItem={onUpdateItem}
onReportItemIssue={onReportItemIssue}
onCancelItemIssue={onCancelItemIssue}
onConfirmItemOrdered={onConfirmItemOrdered}
onConfirmItemReceived={onConfirmItemReceived}
onCancelItemReceived={onCancelItemReceived}
comments={itemCommentsMap[item.id] || []}
commentDraft={itemCommentDrafts[item.id] || ""}
commentsOpen={Boolean(expandedItemComments[item.id])}
onToggleComments={onToggleItemComments}
onCommentDraftChange={onItemCommentDraftChange}
onAddComment={onAddItemComment}
onDeleteComment={onDeleteItemComment}
onDownloadAttachment={onDownloadItemAttachment}
onUploadAttachments={onUploadItemAttachments}
onDeleteAttachment={onDeleteItemAttachment}
/>
))}
</div>
{wideLayout ? (
<div className="overflow-x-auto rounded-lg border border-[var(--border-subtle)]" role="table" aria-label="Позиции заявки">
<div role="rowgroup">
<div className="procurement-items-table-row app-surface-muted border-b border-[var(--border-subtle)] text-[10px] font-semibold uppercase tracking-wide text-[var(--muted-foreground)]" role="row">
<div className="px-2 py-2" role="columnheader">Позиция</div>
<div className="px-2 py-2" role="columnheader">Количество</div>
<div className="px-2 py-2" role="columnheader">Исполнение</div>
<div className="px-2 py-2" role="columnheader">Ожидается</div>
<div className="px-2 py-2 text-right" role="columnheader">Стоимость</div>
<div className="px-2 py-2" role="columnheader">Статус</div>
<div className="px-2 py-2" role="columnheader">Действия</div>
</div>
</div>
<div>{renderedItems}</div>
</div>
) : (
<div className="space-y-2">{renderedItems}</div>
)}
</div>
) : null}
@ -1360,9 +1519,12 @@ export function ProcurementRequestDetailContent({
</div>
) : null}
<ProcurementRequestHistory request={request} displayUserName={displayUserName} />
<div className="min-w-0">
<ProcurementRequestHistory request={request} displayUserName={displayUserName} />
</div>
{footer}
{footer ? <div className="min-w-0">{footer}</div> : null}
</div>
</div>
);
}

View File

@ -0,0 +1,59 @@
"use client";
import { useCallback, useSyncExternalStore } from "react";
const STORAGE_PREFIX = "procurement.desktop-wide-mode";
const CHANGE_EVENT = "procurement-desktop-wide-mode-change";
function storageKey(userId: number): string {
return `${STORAGE_PREFIX}.${userId}`;
}
export function useProcurementDesktopWideMode(userId?: number | null) {
const subscribe = useCallback((notify: () => void) => {
if (!userId) return () => undefined;
const key = storageKey(userId);
const handleStorage = (event: StorageEvent) => {
if (event.key === key) notify();
};
window.addEventListener("storage", handleStorage);
window.addEventListener(CHANGE_EVENT, notify);
return () => {
window.removeEventListener("storage", handleStorage);
window.removeEventListener(CHANGE_EVENT, notify);
};
}, [userId]);
const getSnapshot = useCallback(() => (
userId ? window.localStorage.getItem(storageKey(userId)) === "1" : false
), [userId]);
const desktopWideMode = useSyncExternalStore(subscribe, getSnapshot, () => false);
const subscribeDesktopViewport = useCallback((notify: () => void) => {
const query = window.matchMedia("(min-width: 1024px)");
query.addEventListener("change", notify);
return () => query.removeEventListener("change", notify);
}, []);
const getDesktopViewportSnapshot = useCallback(
() => window.matchMedia("(min-width: 1024px)").matches,
[],
);
const desktopViewport = useSyncExternalStore(
subscribeDesktopViewport,
getDesktopViewportSnapshot,
() => false,
);
const changeDesktopWideMode = useCallback((enabled: boolean) => {
if (!userId) return;
window.localStorage.setItem(storageKey(userId), enabled ? "1" : "0");
window.dispatchEvent(new Event(CHANGE_EVENT));
}, [userId]);
return [
desktopWideMode,
changeDesktopWideMode,
desktopWideMode && desktopViewport,
] as const;
}