improve responsive finance and procurement layouts
This commit is contained in:
parent
9dfca72e4e
commit
a8926987e2
@ -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;
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
59
frontend/src/hooks/useProcurementDesktopWideMode.ts
Normal file
59
frontend/src/hooks/useProcurementDesktopWideMode.ts
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user