diff --git a/CHANGELOG.md b/CHANGELOG.md index e8fc1b4..4313d5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ Versioning policy: see [CONTRIBUTING.md](CONTRIBUTING.md#versioning). ## [Unreleased] +## [2.4.3] — 2026-06-30 + +### Fixed +- **Любой пользователь теперь может менять порядок колонок реестра под себя (issue #16).** Раньше перетаскивание колонок было доступно только администратору (порядок хранился общим на всю организацию), а редактор/наблюдатель могли лишь включать/выключать колонки. Теперь **порядок колонок — личная настройка** (как и видимость): каждый перетаскивает колонки «под себя» — стрелками ↑/↓ или мышью — и порядок сохраняется лично в браузере. Никто не меняет вид другим. + +### Changed +- **Закрепление колонки и переименование колонки** остаются **только у администратора** (это общая для всех настройка-«по умолчанию»). Перетаскивание колонок администратором по-прежнему задаёт общий порядок по умолчанию для тех, кто ещё не настроил свой; «По умолчанию» в панели сбрасывает личный порядок. + +> Только фронтенд: бэкенд, схема БД и публичный контракт не менялись (личный порядок хранится в localStorage, как видимость колонок). PATCH согласно [политике версионирования](CONTRIBUTING.md#versioning) — устранение дефекта доступа. + ## [2.4.2] — 2026-06-30 ### Fixed diff --git a/web/package.json b/web/package.json index 72856c4..c2f88db 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "@lotsman/web", - "version": "2.4.2", + "version": "2.4.3", "license": "BUSL-1.1", "private": true, "type": "module", diff --git a/web/src/pages/registry/RegistryPage.tsx b/web/src/pages/registry/RegistryPage.tsx index 95a1d5a..9aff840 100644 --- a/web/src/pages/registry/RegistryPage.tsx +++ b/web/src/pages/registry/RegistryPage.tsx @@ -115,6 +115,9 @@ import { ImportXlsxDialog } from "./ImportXlsxDialog"; // ── Constants ───────────────────────────────────────────────────────────────── const COLUMN_VISIBILITY_STORAGE_KEY_PREFIX = "lotsman_column_visibility_"; +// Personal column ORDER override (issue #16) — like visibility, the arrangement +// is per-user (localStorage). The tenant-wide admin order is the default seed. +const COLUMN_ORDER_STORAGE_KEY_PREFIX = "lotsman_column_order_"; // Base row height at font-scale 100 (the historical fixed value). The effective // height is derived from the user's font scale so rows grow with the text // instead of clipping/mis-centering it (see deriveRowHeight + useFontScale). @@ -223,6 +226,38 @@ export function RegistryPage() { [storageKey], ); + // ── Personal column order (localStorage per user — issue #16) ─────────────── + // Reordering columns is a personal view preference: every role arranges their + // own columns. The admin's tenant-wide order (server) is the default seed used + // when a user has no personal order. + const orderStorageKey = `${COLUMN_ORDER_STORAGE_KEY_PREFIX}${userId}`; + const [personalColumnOrder, setPersonalColumnOrder] = React.useState(() => { + try { + const stored = localStorage.getItem(orderStorageKey); + if (stored) { + const parsed = JSON.parse(stored) as unknown; + if (Array.isArray(parsed) && parsed.every((x) => typeof x === "string")) { + return parsed as string[]; + } + } + } catch { + // localStorage blocked / parse failed — fall back to the tenant-wide order + } + return null; + }); + const persistPersonalColumnOrder = React.useCallback( + (next: string[] | null) => { + setPersonalColumnOrder(next); + try { + if (next === null) localStorage.removeItem(orderStorageKey); + else localStorage.setItem(orderStorageKey, JSON.stringify(next)); + } catch { + // private browsing — no crash + } + }, + [orderStorageKey], + ); + // ── Tenant-wide column order (US-N) ──────────────────────────────────────── const { data: columnOrderResp } = useColumnOrder(); const updateColumnOrder = useUpdateColumnOrder(); @@ -577,7 +612,9 @@ export function RegistryPage() { // second (pin-left invariant — sticky positioning depends on this). const effectiveColumnOrder = React.useMemo(() => { const knownColIds = new Set(columns.map((c) => c.id ?? "").filter(Boolean)); - const stored = (columnOrderResp?.order ?? []).filter((id) => knownColIds.has(id)); + // Personal order (this user) wins; the tenant-wide admin order is the seed. + const source = personalColumnOrder ?? columnOrderResp?.order ?? []; + const stored = source.filter((id) => knownColIds.has(id)); const fallback = stored.length > 0 ? stored : DEFAULT_COLUMN_ORDER; const seen = new Set(fallback); const tail = Array.from(knownColIds).filter((id) => !seen.has(id)); @@ -590,7 +627,7 @@ export function RegistryPage() { merged.splice(1, 0, effectivePinnedId); } return merged; - }, [columnOrderResp, columns, effectivePinnedId]); + }, [personalColumnOrder, columnOrderResp, columns, effectivePinnedId]); // ── Table instance ───────────────────────────────────────────────────────── const table = useReactTable({ @@ -963,6 +1000,9 @@ export function RegistryPage() { order: [...DEFAULT_COLUMN_ORDER], pinned_column_id: "asset_name", }); + } else { + // Drop this user's personal order → fall back to the default. + persistPersonalColumnOrder(null); } }} onShowAll={() => { @@ -975,14 +1015,21 @@ export function RegistryPage() { } persistColumnVisibility(all); }} - canReorder={isAdmin} + canReorder={true} + canManageLayout={isAdmin} pinnedColumnId={effectivePinnedId} - onReorder={(nextOrder) => - updateColumnOrder.mutate({ - order: nextOrder, - pinned_column_id: effectivePinnedId, - }) - } + onReorder={(nextOrder) => { + if (isAdmin) { + // Admin reorder sets the tenant-wide default for everyone. + updateColumnOrder.mutate({ + order: nextOrder, + pinned_column_id: effectivePinnedId, + }); + } else { + // Editors/viewers arrange their own view (personal, localStorage). + persistPersonalColumnOrder(nextOrder); + } + }} onEditColumn={ isAdmin ? (colId) => { @@ -1600,6 +1647,7 @@ function ColumnVisibilityPanel({ onReset, onShowAll, canReorder, + canManageLayout, pinnedColumnId, onReorder, onChangePinned, @@ -1612,8 +1660,12 @@ function ColumnVisibilityPanel({ /** Show every column (including custom-field cf_* ones) — useful right * after an import when the user wants the registry to mirror the xlsx. */ onShowAll: () => void; - /** True for admin role — gates reorder gestures and arrow buttons. */ + /** Gates reorder gestures (drag handle + ↑/↓). Available to every role — + * reordering is a personal view preference (issue #16). */ canReorder: boolean; + /** Gates tenant-wide layout controls (pin column, rename column) — admin + * only, since these change the shared default for everyone. */ + canManageLayout: boolean; /** Currently pinned (sticky-left) column id. */ pinnedColumnId: string; /** Called with the new full column order (incl. `select`) when the user @@ -1747,6 +1799,7 @@ function ColumnVisibilityPanel({ index={idx} lastIndex={visibleColumns.length - 1} canReorder={canReorder} + canManageLayout={canManageLayout} pinnedColumnId={pinnedColumnId} onPin={() => onChangePinned(col.id)} onMoveUp={() => { @@ -1794,6 +1847,7 @@ function SortableColumnRow({ index, lastIndex, canReorder, + canManageLayout, pinnedColumnId, onPin, onMoveUp, @@ -1804,6 +1858,7 @@ function SortableColumnRow({ index: number; lastIndex: number; canReorder: boolean; + canManageLayout: boolean; pinnedColumnId: string; onPin: () => void; onMoveUp: () => void; @@ -1890,7 +1945,7 @@ function SortableColumnRow({ {canReorder && ( - {onEdit && ( + {canManageLayout && onEdit && ( )} - {!isPinned && ( + {canManageLayout && !isPinned && (