feat(ui): shift-click range selection on list row checkboxes (#2117)
* feat(ui): shift-click range selection on list row checkboxes Click one checkbox, shift-click another, and every row between them takes the clicked row's new state, the way mail clients work. Turns a 20-row bulk selection into two clicks. New useRangeSelect hook (lib/hooks/use-range-select.ts) keeps the anchor and applies the range over the rows as currently rendered, so it follows filtering, sorting and paging rather than the underlying data order. A shift-click with no valid anchor (first click, or the anchor filtered away) degrades to a plain toggle. Select-all and clear reset the anchor. Wired into the 8 selection surfaces: transaction inbox and skattekonto inbox (separate ranges, since the two row types book through different endpoints), journal entry list, invoices, supplier invoices, orders, pending operations, invoice inbox workspace. Radix' onCheckedChange carries no mouse event, so each row records shiftKey from the click that precedes it; the checkbox cells get select-none so shift-clicking does not smear a text selection. The pure range rule is unit tested (10 cases: both directions, range unselect, anchor invalidation, rendered-order independence). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015n8vUx9Nukr8mHC7CVNF7y * fix(ui): void the range anchor on an empty selection, keep placeholders out Review findings from CodeRabbit and the skeptic pass, all in the new range-selection feature: - Clearing a selection left the anchor behind, so the next shift-click extended from a row the user could no longer see selected (click a row, press "Rensa markering", shift-click 30 rows down, get 30 rows). The explicit resetAnchor() calls only covered the clear paths that were wired by hand; several others (period change, filter change, post-bulk success, "Avmarkera") were not. An empty selection now counts as having no anchor, which covers every clear path including ones added later. - The invoice inbox passed optimistic upload placeholders into visibleIds even though they render no checkbox. Safe today only because placeholders are always prepended; filtering them out makes the invariant local instead of depending on insert order elsewhere. - pending: "Godkänn alla" pre-selects a non-empty set, so it resets the anchor explicitly. Two existing tests used a fixture the UI cannot reach (an anchor with an empty selection); they now start from the state a real anchor implies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015n8vUx9Nukr8mHC7CVNF7y --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
237bdd0366
commit
43341aa55c
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { useCompanySettings } from '@/lib/reference-data/hooks'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Link from 'next/link'
|
||||
@@ -18,6 +18,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Dialog, DialogContent, DialogTitle, DialogVeil } from '@/components/ui/dialog'
|
||||
import { DataListEmpty } from '@/components/ui/data-list'
|
||||
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS, CHECKBOX_REVEAL_CLASS } from '@/components/ui/dry-table'
|
||||
import { useRangeSelect } from '@/lib/hooks/use-range-select'
|
||||
import { FyPicker } from '@/components/common/FyPicker'
|
||||
import { ContextPicker } from '@/components/common/ContextPicker'
|
||||
import { SplitButton, type SplitButtonOption } from '@/components/ui/split-button'
|
||||
@@ -204,6 +205,9 @@ export default function InvoicesPage() {
|
||||
const accountingMethod: string = companySettings?.accounting_method ?? 'accrual'
|
||||
const deferInvoiceBooking: boolean = companySettings?.defer_invoice_booking ?? false
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
// Radix' onCheckedChange carries no mouse event: the preceding click records
|
||||
// whether shift was held, for range selection.
|
||||
const shiftHeld = useRef(false)
|
||||
const [showBulkBookConfirm, setShowBulkBookConfirm] = useState(false)
|
||||
const [isBulkBooking, setIsBulkBooking] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@@ -419,13 +423,16 @@ export default function InvoicesPage() {
|
||||
const allSelectableSelected =
|
||||
selectableInvoices.length > 0 && selectableInvoices.every((inv) => selectedIds.has(inv.id))
|
||||
|
||||
function toggleSelect(id: string) {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
// Ranges walk the selectable rows that are actually rendered: the list is
|
||||
// sorted and cut at visibleCount, so rows below the fold are not in range.
|
||||
const range = useRangeSelect({
|
||||
visibleIds: visibleInvoices.filter(isBulkSelectable).map((inv) => inv.id),
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
})
|
||||
|
||||
function toggleSelect(id: string, extend?: boolean) {
|
||||
range.toggle(id, extend)
|
||||
}
|
||||
|
||||
const selectedInvoices = invoices.filter((inv) => selectedIds.has(inv.id))
|
||||
@@ -642,7 +649,10 @@ export default function InvoicesPage() {
|
||||
<button
|
||||
type="button"
|
||||
className={QUIET_LINK_CLASS}
|
||||
onClick={() => setSelectedIds(new Set(selectableInvoices.map((inv) => inv.id)))}
|
||||
onClick={() => {
|
||||
setSelectedIds(new Set(selectableInvoices.map((inv) => inv.id)))
|
||||
range.resetAnchor()
|
||||
}}
|
||||
>
|
||||
{t('bulk_select_all', { count: selectableInvoices.length })}
|
||||
</button>
|
||||
@@ -650,7 +660,10 @@ export default function InvoicesPage() {
|
||||
<button
|
||||
type="button"
|
||||
className={QUIET_LINK_CLASS}
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
onClick={() => {
|
||||
setSelectedIds(new Set())
|
||||
range.resetAnchor()
|
||||
}}
|
||||
>
|
||||
{t('bulk_clear')}
|
||||
</button>
|
||||
@@ -780,13 +793,16 @@ export default function InvoicesPage() {
|
||||
{/* Hover-revealed selection checkbox (supplier-invoices shape). */}
|
||||
{showSelection && (
|
||||
<td
|
||||
className={cn(TD_CLASS, 'w-[26px] !pl-1 py-[9px]')}
|
||||
className={cn(TD_CLASS, 'w-[26px] !pl-1 py-[9px] select-none')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{isBulkSelectable(invoice) && (
|
||||
<Checkbox
|
||||
checked={selectedIds.has(invoice.id)}
|
||||
onCheckedChange={() => toggleSelect(invoice.id)}
|
||||
onClick={(e) => {
|
||||
shiftHeld.current = e.shiftKey
|
||||
}}
|
||||
onCheckedChange={() => toggleSelect(invoice.id, shiftHeld.current)}
|
||||
aria-label={t('bulk_select_row')}
|
||||
className={cn(
|
||||
'border-foreground duration-150',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Link from 'next/link'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
@@ -20,6 +20,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { ContextPicker } from '@/components/common/ContextPicker'
|
||||
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS, CHECKBOX_REVEAL_CLASS } from '@/components/ui/dry-table'
|
||||
import { useRangeSelect } from '@/lib/hooks/use-range-select'
|
||||
import { cn, formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
@@ -199,14 +200,11 @@ export default function OrdersPage() {
|
||||
.filter((o) => isBulkBookable(o, canWrite))
|
||||
.map((o) => o.id)
|
||||
|
||||
const toggleSelect = useCallback((id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
const range = useRangeSelect({ visibleIds: selectableIds, selectedIds, setSelectedIds })
|
||||
const toggleSelect = useCallback(
|
||||
(id: string, extend?: boolean) => range.toggle(id, extend),
|
||||
[range],
|
||||
)
|
||||
|
||||
const openBulkBooking = useCallback(() => {
|
||||
setBulkOrders(rows.filter((o) => selectedIds.has(o.id)))
|
||||
@@ -302,7 +300,10 @@ export default function OrdersPage() {
|
||||
<button
|
||||
type="button"
|
||||
className={QUIET_LINK_CLASS}
|
||||
onClick={() => setSelectedIds(new Set(selectableIds))}
|
||||
onClick={() => {
|
||||
setSelectedIds(new Set(selectableIds))
|
||||
range.resetAnchor()
|
||||
}}
|
||||
>
|
||||
{t('bulk_select_all', { count: selectableIds.length })}
|
||||
</button>
|
||||
@@ -310,7 +311,10 @@ export default function OrdersPage() {
|
||||
<button
|
||||
type="button"
|
||||
className={QUIET_LINK_CLASS}
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
onClick={() => {
|
||||
setSelectedIds(new Set())
|
||||
range.resetAnchor()
|
||||
}}
|
||||
>
|
||||
{t('bulk_clear')}
|
||||
</button>
|
||||
@@ -470,13 +474,16 @@ function OrderRow({
|
||||
canWrite: boolean
|
||||
selectable: boolean
|
||||
isSelected: boolean
|
||||
onToggleSelect: (id: string) => void
|
||||
onToggleSelect: (id: string, extend?: boolean) => void
|
||||
onBook: () => void
|
||||
onInvoice: () => void
|
||||
onMarkBooked: () => void
|
||||
onUnmark: () => void
|
||||
t: ReturnType<typeof useTranslations<'webshop_orders'>>
|
||||
}) {
|
||||
// Radix' onCheckedChange carries no mouse event: the preceding click records
|
||||
// whether shift was held, for range selection.
|
||||
const shiftHeld = useRef(false)
|
||||
const isRefund = order.row_type === 'refund'
|
||||
const booked = order.journal_entry_id !== null
|
||||
const invoiced = order.invoice_id !== null
|
||||
@@ -503,11 +510,14 @@ function OrderRow({
|
||||
{/* Hover-revealed selection checkbox (transactions-page pattern):
|
||||
zero-width cell, the checkbox hangs in the left page margin so the
|
||||
date column stays where it was. Selected rows keep it visible. */}
|
||||
<td className={cn(TD_CLASS, 'relative w-0 !p-0')}>
|
||||
<td className={cn(TD_CLASS, 'relative w-0 !p-0 select-none')}>
|
||||
{selectable && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => onToggleSelect(order.id)}
|
||||
onClick={(e) => {
|
||||
shiftHeld.current = e.shiftKey
|
||||
}}
|
||||
onCheckedChange={() => onToggleSelect(order.id, shiftHeld.current)}
|
||||
aria-label={t('select_order_aria', { number: order.order_number })}
|
||||
className={cn(
|
||||
'absolute -left-5 top-1/2 -translate-y-1/2 border-foreground duration-150 md:-left-6',
|
||||
|
||||
@@ -11,6 +11,7 @@ import { DataListEmpty, DataListLoading } from '@/components/ui/data-list'
|
||||
import { ContextPicker } from '@/components/common/ContextPicker'
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import { CHECKBOX_REVEAL_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
|
||||
import { useRangeSelect } from '@/lib/hooks/use-range-select'
|
||||
import {
|
||||
SlideOver,
|
||||
SlideOverContent,
|
||||
@@ -277,6 +278,9 @@ export default function PendingOperationsPage() {
|
||||
const [showCommitDialog, setShowCommitDialog] = useState(false)
|
||||
const [isCommitting, setIsCommitting] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
// Radix' onCheckedChange carries no mouse event: the preceding click records
|
||||
// whether shift was held, for range selection.
|
||||
const shiftHeld = useRef(false)
|
||||
const [showBulkDialog, setShowBulkDialog] = useState(false)
|
||||
const [isBulkCommitting, setIsBulkCommitting] = useState(false)
|
||||
// Reject dialog state: separate from the generic destructive-confirm so we
|
||||
@@ -643,13 +647,15 @@ export default function PendingOperationsPage() {
|
||||
const pendingTotal = filteredOperations.filter((op) => op.status === 'pending').length
|
||||
const excludedFromBulk = pendingTotal - bulkEligible.length
|
||||
|
||||
function toggleSelected(id: string) {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
// Ranges walk the bulk-eligible operations in rendered order.
|
||||
const range = useRangeSelect({
|
||||
visibleIds: bulkEligibleIds,
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
})
|
||||
|
||||
function toggleSelected(id: string, extend?: boolean) {
|
||||
range.toggle(id, extend)
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
@@ -658,6 +664,7 @@ export default function PendingOperationsPage() {
|
||||
} else {
|
||||
setSelectedIds(new Set(bulkEligibleIds))
|
||||
}
|
||||
range.resetAnchor()
|
||||
}
|
||||
|
||||
// "Approve all of this type": find ops with the same operation_type that are bulk-eligible
|
||||
@@ -666,6 +673,7 @@ export default function PendingOperationsPage() {
|
||||
.filter((op) => op.operation_type === operationType)
|
||||
.map((op) => op.id)
|
||||
setSelectedIds(new Set(ids))
|
||||
range.resetAnchor()
|
||||
}
|
||||
|
||||
// Group counts for type-quick-action buttons (only show if 2+ of same type pending)
|
||||
@@ -725,6 +733,7 @@ export default function PendingOperationsPage() {
|
||||
disabled={isBulkCommitting || isRejecting}
|
||||
onClick={() => {
|
||||
setSelectedIds(new Set(bulkEligibleIds))
|
||||
range.resetAnchor()
|
||||
setShowBulkDialog(true)
|
||||
}}
|
||||
>
|
||||
@@ -999,13 +1008,16 @@ export default function PendingOperationsPage() {
|
||||
>
|
||||
{/* Always-visible selection checkbox (concept .cb) */}
|
||||
<span
|
||||
className="w-[18px] shrink-0 pt-1.5"
|
||||
className="w-[18px] shrink-0 select-none pt-1.5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{canBulkSelect && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleSelected(op.id)}
|
||||
onClick={(e) => {
|
||||
shiftHeld.current = e.shiftKey
|
||||
}}
|
||||
onCheckedChange={() => toggleSelected(op.id, shiftHeld.current)}
|
||||
aria-label={t('select_operation_aria')}
|
||||
className={cn(
|
||||
'border-foreground duration-150',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
@@ -11,6 +11,7 @@ import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { ToolbarSearch } from '@/components/ui/toolbar-search'
|
||||
import { DataListEmpty } from '@/components/ui/data-list'
|
||||
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS, CHECKBOX_REVEAL_CLASS } from '@/components/ui/dry-table'
|
||||
import { useRangeSelect } from '@/lib/hooks/use-range-select'
|
||||
import { FyPicker } from '@/components/common/FyPicker'
|
||||
import { ContextPicker } from '@/components/common/ContextPicker'
|
||||
import { HelpPopover } from '@/components/ui/help-popover'
|
||||
@@ -163,6 +164,9 @@ export default function SupplierInvoicesPage() {
|
||||
const [approvingId, setApprovingId] = useState<string | null>(null)
|
||||
// Payment-file bulk selection + the "already in an active betalfil" chip map.
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
// Radix' onCheckedChange carries no mouse event: the preceding click records
|
||||
// whether shift was held, for range selection.
|
||||
const shiftHeld = useRef(false)
|
||||
const [activeBatchInvoiceIds, setActiveBatchInvoiceIds] = useState<Set<string>>(new Set())
|
||||
const [showPaymentDialog, setShowPaymentDialog] = useState(false)
|
||||
|
||||
@@ -294,13 +298,15 @@ export default function SupplierInvoicesPage() {
|
||||
const allSelectableSelected =
|
||||
selectableInvoices.length > 0 && selectableInvoices.every((inv) => selectedIds.has(inv.id))
|
||||
|
||||
function toggleSelect(id: string) {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
// Ranges walk the selectable rows in rendered (sorted) order.
|
||||
const range = useRangeSelect({
|
||||
visibleIds: sortedInvoices.filter(isBatchSelectable).map((inv) => inv.id),
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
})
|
||||
|
||||
function toggleSelect(id: string, extend?: boolean) {
|
||||
range.toggle(id, extend)
|
||||
}
|
||||
|
||||
// Labels the excluded rows in the payment dialog ("Derome CD3014794407"),
|
||||
@@ -440,9 +446,10 @@ export default function SupplierInvoicesPage() {
|
||||
<button
|
||||
type="button"
|
||||
className={QUIET_LINK_CLASS}
|
||||
onClick={() =>
|
||||
onClick={() => {
|
||||
setSelectedIds(new Set(selectableInvoices.map((inv) => inv.id)))
|
||||
}
|
||||
range.resetAnchor()
|
||||
}}
|
||||
>
|
||||
{t('bulk_select_all', { count: selectableInvoices.length })}
|
||||
</button>
|
||||
@@ -450,7 +457,10 @@ export default function SupplierInvoicesPage() {
|
||||
<button
|
||||
type="button"
|
||||
className={QUIET_LINK_CLASS}
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
onClick={() => {
|
||||
setSelectedIds(new Set())
|
||||
range.resetAnchor()
|
||||
}}
|
||||
>
|
||||
{t('bulk_clear')}
|
||||
</button>
|
||||
@@ -579,13 +589,16 @@ export default function SupplierInvoicesPage() {
|
||||
{/* Hover-revealed selection checkbox (JournalEntryList shape). */}
|
||||
{canWrite && (
|
||||
<td
|
||||
className={cn(TD_CLASS, 'w-[26px] !pl-1 py-[9px]')}
|
||||
className={cn(TD_CLASS, 'w-[26px] !pl-1 py-[9px] select-none')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{selectable && (
|
||||
<Checkbox
|
||||
checked={selectedIds.has(inv.id)}
|
||||
onCheckedChange={() => toggleSelect(inv.id)}
|
||||
onClick={(e) => {
|
||||
shiftHeld.current = e.shiftKey
|
||||
}}
|
||||
onCheckedChange={() => toggleSelect(inv.id, shiftHeld.current)}
|
||||
aria-label={t('bulk_select_row')}
|
||||
className={cn(
|
||||
'border-foreground duration-150',
|
||||
|
||||
@@ -71,6 +71,7 @@ import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { useCashAccounts } from '@/lib/reference-data/hooks'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { useRealtimeSupabase } from '@/lib/hooks/use-realtime-supabase'
|
||||
import { useRangeSelect } from '@/lib/hooks/use-range-select'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { cn, formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { roundOre } from '@/lib/money'
|
||||
@@ -845,6 +846,20 @@ export default function TransactionsPage() {
|
||||
[exitingIds, inboxItems],
|
||||
)
|
||||
|
||||
// Shift-click ranges run per selection set: bank rows and skattekonto rows
|
||||
// are interleaved in one table but are booked through different endpoints,
|
||||
// so each range walks only its own selectable ids in rendered order.
|
||||
const bankRange = useRangeSelect({
|
||||
visibleIds: selectableInboxIds,
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
})
|
||||
const skvRange = useRangeSelect({
|
||||
visibleIds: selectableSkvIds,
|
||||
selectedIds: skvSelectedIds,
|
||||
setSelectedIds: setSkvSelectedIds,
|
||||
})
|
||||
|
||||
const skvSelectedRows = useMemo(
|
||||
() => skvRows.filter((r) => skvSelectedIds.has(r.id)),
|
||||
[skvRows, skvSelectedIds],
|
||||
@@ -2734,13 +2749,8 @@ export default function TransactionsPage() {
|
||||
})
|
||||
}
|
||||
|
||||
function toggleSkvSelect(id: string) {
|
||||
setSkvSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
function toggleSkvSelect(id: string, extend?: boolean) {
|
||||
skvRange.toggle(id, extend)
|
||||
}
|
||||
|
||||
async function handleSkvUnignore(id: string) {
|
||||
@@ -3041,18 +3051,15 @@ export default function TransactionsPage() {
|
||||
// The bulkbar counter ticks per completed row.
|
||||
const BATCH_CONCURRENCY = 5
|
||||
|
||||
function toggleBatchSelect(id: string) {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
function toggleBatchSelect(id: string, extend?: boolean) {
|
||||
bankRange.toggle(id, extend)
|
||||
}
|
||||
|
||||
function exitBatchMode() {
|
||||
setSelectedIds(new Set())
|
||||
setSkvSelectedIds(new Set())
|
||||
bankRange.resetAnchor()
|
||||
skvRange.resetAnchor()
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
@@ -3810,6 +3817,8 @@ export default function TransactionsPage() {
|
||||
onClick={() => {
|
||||
setSelectedIds(new Set(selectableInboxIds))
|
||||
setSkvSelectedIds(new Set(selectableSkvIds))
|
||||
bankRange.resetAnchor()
|
||||
skvRange.resetAnchor()
|
||||
}}
|
||||
>
|
||||
{t('batch_select_all', {
|
||||
|
||||
@@ -60,6 +60,7 @@ import JournalEntryStatusBadge from '@/components/bookkeeping/JournalEntryStatus
|
||||
import AttachmentPreviewSheet from '@/components/bookkeeping/AttachmentPreviewSheet'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { useRangeSelect } from '@/lib/hooks/use-range-select'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { useCompanyOptional } from '@/contexts/CompanyContext'
|
||||
import { listContextKey, writeListContext } from '@/lib/navigation/list-context'
|
||||
@@ -257,6 +258,9 @@ export default function JournalEntryList({
|
||||
const [noDocRequired, setNoDocRequired] = useState<Map<string, string | null>>(new Map())
|
||||
const [showMissingOnly, setShowMissingOnly] = useState(initialShowMissingOnly)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
// Radix' onCheckedChange carries no mouse event: the click that precedes it
|
||||
// records whether shift was held, for range selection.
|
||||
const shiftHeld = useRef(false)
|
||||
const [batchReason, setBatchReason] = useState('')
|
||||
const [batchSubmitting, setBatchSubmitting] = useState(false)
|
||||
const [bulkOpen, setBulkOpen] = useState(false)
|
||||
@@ -810,15 +814,6 @@ export default function JournalEntryList({
|
||||
[attachmentCounts, noDocRequired],
|
||||
)
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleBatchExempt = async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
if (ids.length === 0) return
|
||||
@@ -1048,6 +1043,13 @@ export default function JournalEntryList({
|
||||
const eligibleEntries = canWrite ? filteredEntries.filter(isEligibleForExempt) : []
|
||||
const allEligibleSelected =
|
||||
eligibleEntries.length > 0 && eligibleEntries.every((e) => selectedIds.has(e.id))
|
||||
// Ranges walk the selectable rows of the current page, in rendered order.
|
||||
const range = useRangeSelect({
|
||||
visibleIds: eligibleEntries.map((e) => e.id),
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
})
|
||||
const toggleSelect = (id: string, extend?: boolean) => range.toggle(id, extend)
|
||||
const toggleSelectAll = () => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
@@ -1058,6 +1060,7 @@ export default function JournalEntryList({
|
||||
}
|
||||
return next
|
||||
})
|
||||
range.resetAnchor()
|
||||
}
|
||||
|
||||
// Pristine, untouched ledger: nothing posted in ANY year, no drafts, no
|
||||
@@ -1537,13 +1540,16 @@ export default function JournalEntryList({
|
||||
>
|
||||
{/* Hover-revealed selection checkbox (concept .cb) */}
|
||||
<td
|
||||
className={cn(TD_CLASS, 'w-[26px] !pl-1 py-[9px]')}
|
||||
className={cn(TD_CLASS, 'w-[26px] !pl-1 py-[9px] select-none')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{selectable && (
|
||||
<Checkbox
|
||||
checked={selectedIds.has(entry.id)}
|
||||
onCheckedChange={() => toggleSelect(entry.id)}
|
||||
onClick={(e) => {
|
||||
shiftHeld.current = e.shiftKey
|
||||
}}
|
||||
onCheckedChange={() => toggleSelect(entry.id, shiftHeld.current)}
|
||||
aria-label={t('batch_select_row')}
|
||||
className={cn(
|
||||
'border-foreground duration-150',
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
import Link from 'next/link'
|
||||
import { cn, formatCurrency, formatDate, formatDateLong } from '@/lib/utils'
|
||||
import { QUIET_LINK_CLASS, CHECKBOX_REVEAL_CLASS } from '@/components/ui/dry-table'
|
||||
import { useRangeSelect } from '@/lib/hooks/use-range-select'
|
||||
import { GoogleMark, MicrosoftMark } from '@/components/ui/provider-marks'
|
||||
import { StartCard } from '@/components/dashboard/StartCard'
|
||||
import EditKonteringDialog from '@/components/extensions/general/EditKonteringDialog'
|
||||
@@ -1130,16 +1131,23 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
}
|
||||
}, [fetchItems, selectedId, toast])
|
||||
|
||||
const toggleSelected = useCallback((id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
// Ranges walk the rendered inbox rows in order. Optimistic upload
|
||||
// placeholders render no checkbox, so they stay out of the range: their
|
||||
// temp-* ids are not server rows and must never reach a bulk action.
|
||||
const range = useRangeSelect({
|
||||
visibleIds: filteredItems.filter((item) => !item.isPlaceholder).map((item) => item.id),
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
})
|
||||
const toggleSelected = useCallback(
|
||||
(id: string, extend?: boolean) => range.toggle(id, extend),
|
||||
[range],
|
||||
)
|
||||
|
||||
const clearSelection = useCallback(() => setSelectedIds(new Set()), [])
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelectedIds(new Set())
|
||||
range.resetAnchor()
|
||||
}, [range])
|
||||
|
||||
// The selected rows, and how many of them can actually be bulk-booked
|
||||
// (matched to a transaction and not yet booked). Drives the "Bokför valda"
|
||||
@@ -1757,7 +1765,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
selected={item.id === selectedId}
|
||||
onClick={() => handleSelect(item.id)}
|
||||
isChecked={selectedIds.has(item.id)}
|
||||
onToggleChecked={() => toggleSelected(item.id)}
|
||||
onToggleChecked={(extend) => toggleSelected(item.id, extend)}
|
||||
anyChecked={selectedIds.size > 0}
|
||||
/>
|
||||
))}
|
||||
@@ -2124,12 +2132,15 @@ function InboxRow({
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
isChecked: boolean
|
||||
onToggleChecked: () => void
|
||||
onToggleChecked: (extend?: boolean) => void
|
||||
/** True when bulk-select mode is active anywhere in the list: keeps the
|
||||
checkbox visible (otherwise it's hover-only on desktop). */
|
||||
anyChecked: boolean
|
||||
}) {
|
||||
const t = useTranslations('inbox_workspace')
|
||||
// Radix' onCheckedChange carries no mouse event: the preceding click records
|
||||
// whether shift was held, for range selection.
|
||||
const shiftHeld = useRef(false)
|
||||
const amount = pickAmount(item)
|
||||
const supplierName = pickSupplierName(item)
|
||||
const invoiceDate = pickInvoiceDate(item)
|
||||
@@ -2167,7 +2178,7 @@ function InboxRow({
|
||||
{!isPlaceholder && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center pl-2.5 pr-1.5 transition-opacity',
|
||||
'flex select-none items-center pl-2.5 pr-1.5 transition-opacity',
|
||||
// Solid on touch (pointer-coarse) or when any selection is active;
|
||||
// otherwise muted-but-visible at rest. focus-within because this
|
||||
// wraps the checkbox rather than being it.
|
||||
@@ -2179,7 +2190,10 @@ function InboxRow({
|
||||
>
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
onCheckedChange={onToggleChecked}
|
||||
onClick={(e) => {
|
||||
shiftHeld.current = e.shiftKey
|
||||
}}
|
||||
onCheckedChange={() => onToggleChecked(shiftHeld.current)}
|
||||
aria-label="Markera post"
|
||||
className="h-3.5 w-3.5 border-foreground"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useRef } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -42,7 +43,7 @@ export default function SkattekontoInboxCard({
|
||||
processing: boolean
|
||||
selectable?: boolean
|
||||
isSelected?: boolean
|
||||
onToggleSelect?: (id: string) => void
|
||||
onToggleSelect?: (id: string, extend?: boolean) => void
|
||||
onBokfor: (row: StoredSkattekontoTransaction) => void
|
||||
onMatch: (row: StoredSkattekontoTransaction) => void
|
||||
/** Optional "Ignorera" affordance: hides the row from the work list without
|
||||
@@ -51,6 +52,9 @@ export default function SkattekontoInboxCard({
|
||||
onIgnore?: (row: StoredSkattekontoTransaction) => void
|
||||
}) {
|
||||
const t = useTranslations('tx_skattekonto_card')
|
||||
// See TransactionInboxCard: onCheckedChange has no event, so shift is
|
||||
// captured from the preceding click.
|
||||
const shiftHeld = useRef(false)
|
||||
const amount = Number(row.belopp_skatteverket)
|
||||
const isIncome = amount > 0
|
||||
|
||||
@@ -79,11 +83,14 @@ export default function SkattekontoInboxCard({
|
||||
{/* Always-visible selection checkbox (concept .cb) */}
|
||||
{/* Zero-width cell: the checkbox hangs in the left page margin so
|
||||
the date column can sit flush with the page edge. */}
|
||||
<td className={cn(TD_CLASS, 'relative w-0 !p-0')}>
|
||||
<td className={cn(TD_CLASS, 'relative w-0 !p-0 select-none')}>
|
||||
{selectable && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => onToggleSelect?.(row.id)}
|
||||
onClick={(e) => {
|
||||
shiftHeld.current = e.shiftKey
|
||||
}}
|
||||
onCheckedChange={() => onToggleSelect?.(row.id, shiftHeld.current)}
|
||||
aria-label={t('select_row')}
|
||||
className={cn(
|
||||
'absolute -left-5 top-1/2 -translate-y-1/2 border-foreground duration-150 md:-left-6',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useDocumentExtraction } from '@/lib/hooks/use-document-extraction'
|
||||
import ExtractionStatus from '@/components/ui/extraction-status'
|
||||
@@ -85,7 +85,7 @@ interface TransactionInboxCardProps {
|
||||
/** The company's enabled cash accounts (the page's ?enabled_only=true fetch):
|
||||
* gates the move action, which is pointless with a single account. */
|
||||
cashAccounts?: CashAccount[]
|
||||
onToggleSelect: (id: string) => void
|
||||
onToggleSelect: (id: string, extend?: boolean) => void
|
||||
/** End date of the company's completed SIE-import coverage. Rows on or
|
||||
* before it are pre-migration history: they most likely correspond to an
|
||||
* already-imported verifikat, so the row carries a quiet marker steering
|
||||
@@ -123,6 +123,10 @@ export default function TransactionInboxCard({
|
||||
}: TransactionInboxCardProps) {
|
||||
const t = useTranslations('tx_inbox_card')
|
||||
const tMethod = useTranslations('tx_method')
|
||||
// Radix' onCheckedChange carries no mouse event, so the shift state is
|
||||
// captured from the click that precedes it (Radix composes our onClick
|
||||
// before its own handler) and read back when the toggle fires.
|
||||
const shiftHeld = useRef(false)
|
||||
// Attaching underlag is a write: hide the affordance from viewers so they
|
||||
// don't dead-end on a 403 (mirrors the gate in TransactionHistoryList).
|
||||
const { canWrite } = useCanWrite()
|
||||
@@ -288,13 +292,16 @@ export default function TransactionInboxCard({
|
||||
{/* Zero-width cell: the checkbox hangs in the left page margin so
|
||||
the date column can sit flush with the page edge. */}
|
||||
<td
|
||||
className={cn(TD_CLASS, 'relative w-0 !p-0')}
|
||||
className={cn(TD_CLASS, 'relative w-0 !p-0 select-none')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{selectable && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => onToggleSelect(transaction.id)}
|
||||
onClick={(e) => {
|
||||
shiftHeld.current = e.shiftKey
|
||||
}}
|
||||
onCheckedChange={() => onToggleSelect(transaction.id, shiftHeld.current)}
|
||||
aria-label="Välj transaktion"
|
||||
className={cn(
|
||||
'absolute -left-5 top-1/2 -translate-y-1/2 border-foreground duration-150 md:-left-6',
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { applyRangeSelection } from '@/lib/hooks/use-range-select'
|
||||
|
||||
const VISIBLE = ['a', 'b', 'c', 'd', 'e']
|
||||
|
||||
function sorted(ids: Set<string>): string[] {
|
||||
return [...ids].sort()
|
||||
}
|
||||
|
||||
describe('applyRangeSelection', () => {
|
||||
it('toggles a single row when not extending', () => {
|
||||
const next = applyRangeSelection({
|
||||
selectedIds: new Set(),
|
||||
visibleIds: VISIBLE,
|
||||
anchorId: null,
|
||||
targetId: 'b',
|
||||
extend: false,
|
||||
})
|
||||
expect(sorted(next)).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('unselects a selected row when not extending', () => {
|
||||
const next = applyRangeSelection({
|
||||
selectedIds: new Set(['b']),
|
||||
visibleIds: VISIBLE,
|
||||
anchorId: 'b',
|
||||
targetId: 'b',
|
||||
extend: false,
|
||||
})
|
||||
expect(sorted(next)).toEqual([])
|
||||
})
|
||||
|
||||
it('selects the range downwards from the anchor', () => {
|
||||
const next = applyRangeSelection({
|
||||
selectedIds: new Set(['b']),
|
||||
visibleIds: VISIBLE,
|
||||
anchorId: 'b',
|
||||
targetId: 'd',
|
||||
extend: true,
|
||||
})
|
||||
expect(sorted(next)).toEqual(['b', 'c', 'd'])
|
||||
})
|
||||
|
||||
it('selects the range upwards from the anchor', () => {
|
||||
const next = applyRangeSelection({
|
||||
selectedIds: new Set(['d']),
|
||||
visibleIds: VISIBLE,
|
||||
anchorId: 'd',
|
||||
targetId: 'b',
|
||||
extend: true,
|
||||
})
|
||||
expect(sorted(next)).toEqual(['b', 'c', 'd'])
|
||||
})
|
||||
|
||||
it('unselects the whole range when the target was selected', () => {
|
||||
const next = applyRangeSelection({
|
||||
selectedIds: new Set(['a', 'b', 'c', 'd']),
|
||||
visibleIds: VISIBLE,
|
||||
anchorId: 'a',
|
||||
targetId: 'c',
|
||||
extend: true,
|
||||
})
|
||||
expect(sorted(next)).toEqual(['d'])
|
||||
})
|
||||
|
||||
it('keeps selections outside the range untouched', () => {
|
||||
const next = applyRangeSelection({
|
||||
selectedIds: new Set(['a', 'b']),
|
||||
visibleIds: VISIBLE,
|
||||
anchorId: 'b',
|
||||
targetId: 'd',
|
||||
extend: true,
|
||||
})
|
||||
expect(sorted(next)).toEqual(['a', 'b', 'c', 'd'])
|
||||
})
|
||||
|
||||
it('degrades to a plain toggle when there is no anchor yet', () => {
|
||||
const next = applyRangeSelection({
|
||||
selectedIds: new Set(),
|
||||
visibleIds: VISIBLE,
|
||||
anchorId: null,
|
||||
targetId: 'd',
|
||||
extend: true,
|
||||
})
|
||||
expect(sorted(next)).toEqual(['d'])
|
||||
})
|
||||
|
||||
it('degrades to a plain toggle when the selection was cleared', () => {
|
||||
// The anchor row is still on screen, but the user cleared the selection
|
||||
// (clear button, filter change, finished bulk action). Extending from it
|
||||
// would sweep in rows they never picked.
|
||||
const next = applyRangeSelection({
|
||||
selectedIds: new Set(),
|
||||
visibleIds: VISIBLE,
|
||||
anchorId: 'a',
|
||||
targetId: 'e',
|
||||
extend: true,
|
||||
})
|
||||
expect(sorted(next)).toEqual(['e'])
|
||||
})
|
||||
|
||||
it('degrades to a plain toggle when the anchor is no longer visible', () => {
|
||||
// The anchor row was filtered away or is on another page.
|
||||
const next = applyRangeSelection({
|
||||
selectedIds: new Set(['z']),
|
||||
visibleIds: VISIBLE,
|
||||
anchorId: 'z',
|
||||
targetId: 'c',
|
||||
extend: true,
|
||||
})
|
||||
expect(sorted(next)).toEqual(['c', 'z'])
|
||||
})
|
||||
|
||||
it('follows the rendered order, not the id order', () => {
|
||||
// The anchor is always a row the user just clicked, so it is selected.
|
||||
const next = applyRangeSelection({
|
||||
selectedIds: new Set(['e']),
|
||||
visibleIds: ['e', 'd', 'c', 'b', 'a'],
|
||||
anchorId: 'e',
|
||||
targetId: 'c',
|
||||
extend: true,
|
||||
})
|
||||
expect(sorted(next)).toEqual(['c', 'd', 'e'])
|
||||
})
|
||||
|
||||
it('unselects just the row when anchor and target are the same', () => {
|
||||
const next = applyRangeSelection({
|
||||
selectedIds: new Set(['c']),
|
||||
visibleIds: VISIBLE,
|
||||
anchorId: 'c',
|
||||
targetId: 'c',
|
||||
extend: true,
|
||||
})
|
||||
expect(sorted(next)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Gmail-style shift-click range selection for list rows.
|
||||
*
|
||||
* Plain click toggles one row and becomes the anchor. Shift-click applies the
|
||||
* clicked row's NEW state to every row between the anchor and the target, in
|
||||
* the order the rows are currently rendered (so the range follows what the
|
||||
* user sees after filtering, sorting and paging, not the underlying data
|
||||
* order).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Pure range rule, extracted from the hook so it is testable without a
|
||||
* browser. Returns the next selection.
|
||||
*
|
||||
* `visibleIds` must be the rendered order. When the anchor is missing (first
|
||||
* click, or the anchor scrolled out of the current filter/page) a shift-click
|
||||
* degrades to a plain toggle, which is what every mail client does.
|
||||
*
|
||||
* An EMPTY selection also counts as having no anchor: every list clears the
|
||||
* selection from several places (a clear button, a filter change, a finished
|
||||
* bulk action), and a range measured from a row the user can no longer see
|
||||
* selected would sweep in dozens of rows they never picked. Anchoring on the
|
||||
* selection rather than on the clear call sites keeps that true for clear
|
||||
* paths nobody remembered to wire up.
|
||||
*/
|
||||
export function applyRangeSelection({
|
||||
selectedIds,
|
||||
visibleIds,
|
||||
anchorId,
|
||||
targetId,
|
||||
extend,
|
||||
}: {
|
||||
selectedIds: Set<string>
|
||||
visibleIds: string[]
|
||||
anchorId: string | null
|
||||
targetId: string
|
||||
extend: boolean
|
||||
}): Set<string> {
|
||||
const next = new Set(selectedIds)
|
||||
const shouldSelect = !selectedIds.has(targetId)
|
||||
|
||||
const anchorIndex =
|
||||
anchorId === null || selectedIds.size === 0 ? -1 : visibleIds.indexOf(anchorId)
|
||||
const targetIndex = visibleIds.indexOf(targetId)
|
||||
|
||||
if (!extend || anchorIndex === -1 || targetIndex === -1) {
|
||||
if (shouldSelect) next.add(targetId)
|
||||
else next.delete(targetId)
|
||||
return next
|
||||
}
|
||||
|
||||
const from = Math.min(anchorIndex, targetIndex)
|
||||
const to = Math.max(anchorIndex, targetIndex)
|
||||
for (let i = from; i <= to; i++) {
|
||||
if (shouldSelect) next.add(visibleIds[i])
|
||||
else next.delete(visibleIds[i])
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export interface UseRangeSelectOptions {
|
||||
/** Row ids in the order they are rendered right now. */
|
||||
visibleIds: string[]
|
||||
selectedIds: Set<string>
|
||||
setSelectedIds: (next: Set<string>) => void
|
||||
}
|
||||
|
||||
export interface UseRangeSelect {
|
||||
/** Toggle one row; pass shiftKey from the click event to extend the range. */
|
||||
toggle: (id: string, extend?: boolean) => void
|
||||
/** Drop the anchor, e.g. after select-all or clear. */
|
||||
resetAnchor: () => void
|
||||
}
|
||||
|
||||
export function useRangeSelect({
|
||||
visibleIds,
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
}: UseRangeSelectOptions): UseRangeSelect {
|
||||
const anchorRef = useRef<string | null>(null)
|
||||
|
||||
const toggle = useCallback(
|
||||
(id: string, extend = false) => {
|
||||
setSelectedIds(
|
||||
applyRangeSelection({
|
||||
selectedIds,
|
||||
visibleIds,
|
||||
anchorId: anchorRef.current,
|
||||
targetId: id,
|
||||
extend,
|
||||
}),
|
||||
)
|
||||
anchorRef.current = id
|
||||
},
|
||||
[selectedIds, visibleIds, setSelectedIds],
|
||||
)
|
||||
|
||||
const resetAnchor = useCallback(() => {
|
||||
anchorRef.current = null
|
||||
}, [])
|
||||
|
||||
return { toggle, resetAnchor }
|
||||
}
|
||||
Reference in New Issue
Block a user