Date filter feature (#24)

* feat: add date sorting support to journal entries API

Add sort_date query parameter (asc/desc) that orders results by
entry_date instead of the default voucher_series + voucher_number.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add date sort toggle to journal entry list

Add ascending/descending sort button next to the missing attachment
filter. Defaults to newest first, toggles between nyast/äldst först.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add date range filter inputs to journal entry list

Add Från/Till text inputs (YYYY-MM-DD) for filtering entries by date
range. Applies on blur or Enter, with an X button to clear both fields.
Both fields are optional and work independently of the sort toggle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-03-13 17:05:32 +01:00
committed by GitHub
parent 2ad8731dc9
commit a438c50e9c
2 changed files with 102 additions and 20 deletions
+15 -3
View File
@@ -22,14 +22,26 @@ export async function GET(request: Request) {
const offset = parseInt(searchParams.get('offset') || '0')
const dateFrom = searchParams.get('date_from')
const dateTo = searchParams.get('date_to')
const sortDate = searchParams.get('sort_date') // 'asc' | 'desc'
const dateAscending = sortDate === 'asc'
let query = supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)', { count: 'exact' })
.eq('user_id', user.id)
.order('voucher_series', { ascending: true })
.order('voucher_number', { ascending: true })
.range(offset, offset + limit - 1)
if (sortDate === 'asc' || sortDate === 'desc') {
query = query
.order('entry_date', { ascending: dateAscending })
.order('voucher_number', { ascending: dateAscending })
} else {
query = query
.order('voucher_series', { ascending: true })
.order('voucher_number', { ascending: true })
}
query = query.range(offset, offset + limit - 1)
if (periodId) {
query = query.eq('fiscal_period_id', periodId)
+87 -17
View File
@@ -6,7 +6,8 @@ import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { ChevronDown, ChevronRight, Paperclip, AlertTriangle } from 'lucide-react'
import { ArrowDownNarrowWide, ArrowUpNarrowWide, ChevronDown, ChevronRight, Paperclip, AlertTriangle, X } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { AccountNumber } from '@/components/ui/account-number'
import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments'
import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog'
@@ -34,8 +35,15 @@ export default function JournalEntryList({ periodId }: Props) {
const [attachmentCounts, setAttachmentCounts] = useState<Record<string, number>>({})
const [showMissingOnly, setShowMissingOnly] = useState(false)
const [correctionEntry, setCorrectionEntry] = useState<JournalEntry | null>(null)
const [dateSortDir, setDateSortDir] = useState<'desc' | 'asc'>('desc')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [dateFromInput, setDateFromInput] = useState('')
const [dateToInput, setDateToInput] = useState('')
const pageSize = 20
const isValidDate = (v: string) => /^\d{4}-\d{2}-\d{2}$/.test(v) && !isNaN(Date.parse(v))
const fetchAttachmentCounts = useCallback(async (entryIds: string[]) => {
if (entryIds.length === 0) return
try {
@@ -54,8 +62,11 @@ export default function JournalEntryList({ periodId }: Props) {
const params = new URLSearchParams({
limit: String(pageSize),
offset: String(page * pageSize),
sort_date: dateSortDir,
})
if (periodId) params.set('period_id', periodId)
if (dateFrom) params.set('date_from', dateFrom)
if (dateTo) params.set('date_to', dateTo)
const res = await fetch(`/api/bookkeeping/journal-entries?${params}`)
const { data, count: total } = await res.json()
@@ -71,7 +82,7 @@ export default function JournalEntryList({ periodId }: Props) {
useEffect(() => {
fetchEntries()
}, [periodId, page])
}, [periodId, page, dateSortDir, dateFrom, dateTo])
const handleAttachmentCountChange = useCallback((entryId: string, count: number) => {
setAttachmentCounts((prev) => ({ ...prev, [entryId]: count }))
@@ -112,21 +123,80 @@ export default function JournalEntryList({ periodId }: Props) {
return (
<div className="space-y-4">
{/* Missing attachment filter */}
<div className="flex items-center gap-2">
<Switch
id="missing-attachments"
checked={showMissingOnly}
onCheckedChange={setShowMissingOnly}
/>
<Label htmlFor="missing-attachments" className="text-sm cursor-pointer">
Visa saknade underlag
</Label>
{showMissingOnly && (
<Badge variant="secondary" className="text-xs">
{filteredEntries.length}
</Badge>
)}
{/* Filters and sorting */}
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
<Switch
id="missing-attachments"
checked={showMissingOnly}
onCheckedChange={setShowMissingOnly}
/>
<Label htmlFor="missing-attachments" className="text-sm cursor-pointer">
Visa saknade underlag
</Label>
{showMissingOnly && (
<Badge variant="secondary" className="text-xs">
{filteredEntries.length}
</Badge>
)}
</div>
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5"
onClick={() => { setDateSortDir(dateSortDir === 'desc' ? 'asc' : 'desc'); setPage(0) }}
>
{dateSortDir === 'desc' ? (
<ArrowDownNarrowWide className="h-4 w-4" />
) : (
<ArrowUpNarrowWide className="h-4 w-4" />
)}
<span className="text-xs">Datum {dateSortDir === 'desc' ? 'nyast först' : 'äldst först'}</span>
</Button>
<div className="flex items-center gap-1.5">
<Input
type="text"
placeholder="Från YYYY-MM-DD"
value={dateFromInput}
onChange={(e) => setDateFromInput(e.target.value)}
onBlur={() => {
const v = dateFromInput.trim()
const next = v === '' ? '' : isValidDate(v) ? v : dateFrom
setDateFromInput(next)
if (next !== dateFrom) { setDateFrom(next); setPage(0) }
}}
onKeyDown={(e) => {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur()
}}
className="h-8 w-[145px] text-xs"
/>
<Input
type="text"
placeholder="Till YYYY-MM-DD"
value={dateToInput}
onChange={(e) => setDateToInput(e.target.value)}
onBlur={() => {
const v = dateToInput.trim()
const next = v === '' ? '' : isValidDate(v) ? v : dateTo
setDateToInput(next)
if (next !== dateTo) { setDateTo(next); setPage(0) }
}}
onKeyDown={(e) => {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur()
}}
className="h-8 w-[145px] text-xs"
/>
{(dateFrom || dateTo) && (
<button
type="button"
onClick={() => { setDateFrom(''); setDateTo(''); setDateFromInput(''); setDateToInput(''); setPage(0) }}
className="p-1 rounded-sm hover:bg-muted text-muted-foreground"
title="Rensa datumfilter"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
<div className="space-y-2">