From a8926987e2d82ddf93b568533f64d08e8c1e978a Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 7 Aug 2026 12:08:50 +0300 Subject: [PATCH] improve responsive finance and procurement layouts --- frontend/src/app/globals.css | 107 +++++++ frontend/src/app/procurement/page.tsx | 286 ++++++++++++++++-- .../finance/admin/PayrollAdminTables.tsx | 218 ++++++++++--- .../finance/admin/PayrollAdminWorkspace.tsx | 12 +- .../ProcurementRequestDetailContent.tsx | 262 +++++++++++++--- .../hooks/useProcurementDesktopWideMode.ts | 59 ++++ 6 files changed, 835 insertions(+), 109 deletions(-) create mode 100644 frontend/src/hooks/useProcurementDesktopWideMode.ts diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 64816d3e..a9ee0306 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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; } diff --git a/frontend/src/app/procurement/page.tsx b/frontend/src/app/procurement/page.tsx index be08ee83..120a1ead 100644 --- a/frontend/src/app/procurement/page.tsx +++ b/frontend/src/app/procurement/page.tsx @@ -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; +}) { + const sentinelRef = useRef(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 ( +
+ {loading ? ( + + Загружаем ещё заявки… + + ) : null} +
+ ); +} + /* ══════════════════════════════════════════════════════ 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(null); + const [detailDialogOpen, setDetailDialogOpen] = useState(false); const [detailModalLoading, setDetailModalLoading] = useState(false); const [detailModalError, setDetailModalError] = useState(null); + const initialRequestParamHandledRef = useRef(false); const procurementMenuRef = useRef(null); const statusFilterRef = useRef(null); const [procurementMenuOpenId, setProcurementMenuOpenId] = useState(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 ( - + {loading ? (
@@ -858,7 +920,7 @@ export default function ProcurementPage() {

{error}

) : ( -
+
{/* ── header ── */}
@@ -875,11 +937,23 @@ export default function ProcurementPage() {
*/}
- {activeSection === "requests" && ( - - )} + {activeSection === "requests" && ( + + )} + {/* ── alerts ── */} @@ -1137,8 +1211,184 @@ export default function ProcurementPage() { )} + {/* ══════════ Wide master-detail workspace ══════════ */} +
+ + +
+ {detailModalLoading ? ( +
+ +

Загружаем заявку...

+
+ ) : detailModalError ? ( +
{detailModalError}
+ ) : selectedRequest ? ( +
+
+
+

#{selectedRequest.id} · {selectedRequest.title || "Без названия"}

+ {getStatusMeta(selectedRequest.status).label} + + {(urgencyMeta[selectedRequest.urgency]?.label ?? urgencyMeta.medium.label)} срочность + +
+
+ Отдел-заказчик: {getDeptName(selectedRequest)} + {selectedRequest.processing_department_name ? Отдел-исполнитель: {selectedRequest.processing_department_name} : null} + Заявитель: {displayUserName(selectedRequest.requestor, selectedRequest.requestor_name, selectedRequest.requestor_email)} + Исполнитель: {selectedRequest.executor || selectedRequest.executor_name ? displayUserName(selectedRequest.executor, selectedRequest.executor_name || undefined) : "не назначен"} + {fmt(selectedRequest.created_at)} + {getRequestAmount(selectedRequest) ? {money(getRequestAmount(selectedRequest))} : null} +
+
+ + 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)} + /> +
+
+ + setItemCommentDrafts((previous) => ({ ...previous, [itemId]: value }))} + onAddItemComment={handleAddItemComment} + onDeleteItemComment={handleDeleteItemComment} + onDownloadItemAttachment={handleDownloadItemAttachment} + onUploadItemAttachments={handleUploadItemAttachments} + onDeleteItemAttachment={handleDeleteItemAttachment} + wideLayout + footer={( +
+

Комментарии

+
+ {(commentsMap[selectedRequest.id] || []).length === 0 ? ( +

Комментариев пока нет

+ ) : (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 ( +
+
+ {displayUserName(comment.author)} +
+ {fmt(comment.created_at)} + {canDeleteCommentThis ? handleDeleteComment(selectedRequest.id, comment.id)} /> : null} +
+
+

{comment.text}

+
+ ); + })} +
+
+ setCommentDrafts((previous) => ({ ...previous, [selectedRequest.id]: value }))} + onSubmit={() => handleAddComment(selectedRequest.id)} + disabled={busyKey === `comment-${selectedRequest.id}`} + /> +
+
+ )} + /> +
+ ) : ( +
+ +

Выберите заявку

+

Полная информация появится в этой области.

+
+ )} +
+
+ {/* ══════════ Request cards ══════════ */} -
+
{filteredRequests.length === 0 ? (
@@ -1581,20 +1831,20 @@ export default function ProcurementPage() {
{/* ── load more ── */} - {nextPage && ( -
- -
- )} +
+ +
)}
)} {meta.label}; } +function CompactRecordCard({ + title, + subtitle, + summary, + status, + children, +}: { + title: string; + subtitle: string; + summary: ReactNode; + status: PayrollApprovalStatus; + children: ReactNode; +}) { + const [open, setOpen] = useState(false); + + return ( +
+ + {open ? ( +
+ {children} +
+ ) : null} +
+ ); +} + +function CompactField({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + 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 ( -
-
-
- СотрудникОкладЦена сверх нормыДействует сСтатусДействия -
-
+
+
{records.map((record) => ( -
+ +
+ {formatPayrollMoney(record.amount, record.currency)} + + {record.point_rate === null ? "Как в норме" : formatPayrollMoney(record.point_rate, record.currency)} + + {formatPayrollDate(record.effective_from)} +
+
+ onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} /> +
+
+ ))} +
+ +
+
+
+ СотрудникОкладЦена сверх нормыДействует сСтатусДействия +
+
+ {records.map((record) => ( +

{record.employee.display_name}

{record.employee.position || record.employee.department || "Сотрудник"}

-
Оклад{formatPayrollMoney(record.amount, record.currency)}
-
Цена сверх нормы{record.point_rate === null ? "Как в норме" : formatPayrollMoney(record.point_rate, record.currency)}
-
С{formatPayrollDate(record.effective_from)}
+
{formatPayrollMoney(record.amount, record.currency)}
+
{record.point_rate === null ? "Как в норме" : formatPayrollMoney(record.point_rate, record.currency)}
+
{formatPayrollDate(record.effective_from)}
onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} />
))} -
+
+
); @@ -178,16 +255,51 @@ export function PayrollWorkRecordsTable({ if (loading || error || records.length === 0) return state; return ( -
-
-
- СотрудникНормаФактКонтрольСтатусДействия -
-
+
+
{records.map((record) => { const fromAttendance = isAttendancePayrollWorkRecord(record.source); return ( -
+ + {formatPayrollWorkMetric(record.actual_points, record.source)} / {formatPayrollWorkMetric(record.target_points, record.source)} + {fromAttendance ? ( + + Из посещаемости + + ) : null} + + )} + status={record.status} + > +
+ {formatPayrollWorkMetric(record.target_points, record.source)} + {formatPayrollWorkMetric(record.actual_points, record.source)} + {record.expected_payable ? formatPayrollMoney(record.expected_payable) : "—"} + {fromAttendance ? "Посещаемость" : "Введено вручную"} +
+
+ onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} /> +
+
+ ); + })} +
+ +
+
+
+ СотрудникНормаФактКонтрольСтатусДействия +
+
+ {records.map((record) => { + const fromAttendance = isAttendancePayrollWorkRecord(record.source); + return ( +

{record.employee.display_name}

{record.employee.position || record.employee.department || "Сотрудник"}

@@ -197,15 +309,16 @@ export function PayrollWorkRecordsTable({ ) : null}
-
Норма{formatPayrollWorkMetric(record.target_points, record.source)}
-
Факт{formatPayrollWorkMetric(record.actual_points, record.source)}
-
Контроль{record.expected_payable ? formatPayrollMoney(record.expected_payable) : "—"}
+
{formatPayrollWorkMetric(record.target_points, record.source)}
+
{formatPayrollWorkMetric(record.actual_points, record.source)}
+
{record.expected_payable ? formatPayrollMoney(record.expected_payable) : "—"}
onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} /> -
- ); - })} -
+
+ ); + })} +
+
); @@ -238,14 +351,46 @@ export function PayrollInputLinesTable({ if (loading || error || records.length === 0) return state; return ( -
-
-
- СотрудникОперацияСуммаСтатусДействия -
-
+
+
{records.map((record) => ( -
+ + {record.component.name} + {formatPayrollMoney(record.amount)} + + )} + status={record.status} + > +
+ {record.component.name} + {formatPayrollMoney(record.amount)} + {record.relates_to_period ? ( + + {record.relates_to_period.name || record.relates_to_period.code} + + ) : null} + {record.reason ? {record.reason} : null} +
+
+ onEdit(record)} onApprove={() => onApprove(record)} /> +
+
+ ))} +
+ +
+
+
+ СотрудникОперацияСуммаСтатусДействия +
+
+ {records.map((record) => ( +

{record.employee.display_name}

{record.employee.position || record.employee.department || "Сотрудник"}

{record.component.name}

@@ -259,12 +404,13 @@ export function PayrollInputLinesTable({ ) : null} {record.reason ?

{record.reason}

: null}
-
Сумма{formatPayrollMoney(record.amount)}
+
{formatPayrollMoney(record.amount)}
onEdit(record)} onApprove={() => onApprove(record)} />
))} -
+
+
); diff --git a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx index d2110eea..f383737f 100644 --- a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx +++ b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx @@ -264,17 +264,18 @@ function TableToolbar({ }, [addMenuOpen]); return ( -
-