Add/transaction deletion (#695)
* feat(salary): add remove-employee button to draft salary runs
The DELETE /api/salary/runs/{id}/employees/{employeeId} endpoint already
existed (draft-only, cascades to the employee's line items) but had no UI
trigger, so a mistakenly added employee could only be cleared by deleting
the whole draft. Add a trash-icon action column to the "Anställda" table,
gated on draft status + write permission to match the endpoint's guard,
with a confirm prompt and success/error toast.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(settings): prevent horizontal overflow on mobile
The company settings invite form was a non-wrapping fixed-width flex row that overflowed narrow viewports, forcing the full-screen settings modal to scroll on the x-axis. Stack the form vertically on mobile (sm:flex-row at and above the sm breakpoint) and add the missing min-w-0 guard to the modal content pane.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(bookkeeping): move journal entry filters into a filter dialog
The ledger toolbar showed every filter inline (fiscal year, sort, series,
date range, missing-documents toggle), which felt cluttered. Keep only the
search field visible and move the rest into a "Filtrera" dialog with an
active-filter count badge.
- JournalEntryList now owns the fiscal-year scope, restored from the same
localStorage key FiscalYearSelector writes, so the page no longer renders
the selector separately.
- Filters apply live and the dialog stays open; "Rensa alla filter" clears them.
- Export STORAGE_KEY_PREFIX / ALL_YEARS_VALUE from FiscalYearSelector so the
list reuses the persisted selection without duplicating the key.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(transactions): implement imported transaction guard for deletion
- Added a guard to prevent deletion of transactions that are imported via bank sync or file uploads.
- Introduced `isImportedTransaction` utility to determine if a transaction is user-created or imported.
- Updated DELETE endpoint to return a 409 status for attempts to delete imported transactions.
- Enhanced transaction history and inbox components to reflect the new deletion rules.
- Added tests for transaction origin determination and deletion behavior.
- Updated UI components to include a confirmation dialog for clearing journal entry forms.
- Localized new strings for clearing form functionality in English and Swedish.
* feat(transactions): enhance transaction deletion guard and improve fiscal year visibility
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
679b154ad2
commit
64991eb3c9
+239
-138
@@ -40,6 +40,78 @@ function isMobile(): boolean {
|
||||
return /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)
|
||||
}
|
||||
|
||||
/**
|
||||
* sessionStorage key holding an in-flight BankID session across the mobile
|
||||
* return-redirect. On iOS the BankID app returns to the SAME Safari tab by
|
||||
* reloading it (see launchBankIdApp), which wipes React state — so we stash the
|
||||
* session here and resume polling on the next mount.
|
||||
*/
|
||||
const PENDING_KEY = 'bankid:pending'
|
||||
/** Ignore a stashed session older than this (BankID orders expire in ~3 min). */
|
||||
const PENDING_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
interface PendingBankId {
|
||||
session: BankIdSession
|
||||
mode: string
|
||||
ts: number
|
||||
}
|
||||
|
||||
function persistPending(session: BankIdSession, mode: string): void {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
PENDING_KEY,
|
||||
JSON.stringify({ session, mode, ts: Date.now() } satisfies PendingBankId)
|
||||
)
|
||||
} catch {
|
||||
// sessionStorage unavailable (private mode / quota) — auto-resume just won't
|
||||
// fire; the user can still switch back to the tab manually as before.
|
||||
}
|
||||
}
|
||||
|
||||
function readPending(mode: string): PendingBankId | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(PENDING_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as PendingBankId
|
||||
if (parsed?.mode !== mode) return null
|
||||
if (typeof parsed.ts !== 'number' || Date.now() - parsed.ts > PENDING_TTL_MS) return null
|
||||
if (!parsed.session?.sessionId) return null
|
||||
return parsed
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function clearPending(): void {
|
||||
try {
|
||||
sessionStorage.removeItem(PENDING_KEY)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the BankID app on the same (mobile) device.
|
||||
*
|
||||
* Uses the universal link https://app.bankid.com/ — NOT the bankid:/// custom
|
||||
* scheme. A custom-scheme launch has no association with the originating Safari
|
||||
* tab, so on iOS the post-auth redirect opens in a NEW tab (git history: commit
|
||||
* 3bc652cc reverted a redirect for exactly that reason). The universal link is
|
||||
* tied to the originating tab, so BankID returns the user to it.
|
||||
*
|
||||
* redirect:
|
||||
* • iOS → current URL, so the app navigates this tab back here on success
|
||||
* (the resume effect then completes the flow).
|
||||
* • Android → "null": the BankID app returns via the task stack, and a real
|
||||
* redirect URL would spawn a new tab / Chrome instance instead.
|
||||
*/
|
||||
function launchBankIdApp(autoStartToken: string): void {
|
||||
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : ''
|
||||
const isIOS = /iPad|iPhone|iPod/.test(ua)
|
||||
const redirect = isIOS ? encodeURIComponent(window.location.href) : 'null'
|
||||
window.location.href = `https://app.bankid.com/?autostarttoken=${autoStartToken}&redirect=${redirect}`
|
||||
}
|
||||
|
||||
/**
|
||||
* BankID authentication flow component.
|
||||
* Handles QR code display (desktop) or app deep link (mobile),
|
||||
@@ -72,6 +144,149 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) {
|
||||
|
||||
useEffect(() => cleanup, [cleanup])
|
||||
|
||||
// Poll an in-flight BankID session until it completes, fails, or the service
|
||||
// gives up. Extracted from startSession so the resume effect (mobile return)
|
||||
// can re-attach to a session that was started before the tab reloaded.
|
||||
const beginPolling = useCallback((session: BankIdSession) => {
|
||||
abortRef.current = new AbortController()
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const pollRes = await fetch(`${API_BASE}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: session.sessionId }),
|
||||
signal: abortRef.current?.signal,
|
||||
})
|
||||
|
||||
if (!pollRes.ok) {
|
||||
const pollErr = await pollRes.json().catch(() => ({}))
|
||||
if (pollErr.error === 'service_unavailable' || pollRes.status === 502 || pollRes.status === 503) {
|
||||
pollFailureCount.current++
|
||||
if (pollFailureCount.current >= MAX_POLL_FAILURES) {
|
||||
cleanup()
|
||||
clearPending()
|
||||
setStatus('service_unavailable')
|
||||
setErrorMessage('BankID-tjänsten är inte tillgänglig just nu')
|
||||
onCompleteRef.current({ error: 'service_unavailable' })
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Reset failure counter on successful poll
|
||||
pollFailureCount.current = 0
|
||||
|
||||
const pollJson = await pollRes.json()
|
||||
const pollData = pollJson.data
|
||||
|
||||
if (!pollData) {
|
||||
console.warn('[bankid] poll returned no data:', pollJson)
|
||||
return
|
||||
}
|
||||
|
||||
// Update hint message from TIC API
|
||||
if (pollData.message) {
|
||||
setHintMessage(pollData.message)
|
||||
}
|
||||
|
||||
// Handle token refresh (order regeneration ~25s)
|
||||
if (pollData.qrStartToken && pollData.qrStartSecret) {
|
||||
setSession((prev) =>
|
||||
prev
|
||||
? { ...prev, qrStartToken: pollData.qrStartToken, qrStartSecret: pollData.qrStartSecret }
|
||||
: prev
|
||||
)
|
||||
}
|
||||
|
||||
if (pollData.status === 'complete') {
|
||||
cleanup()
|
||||
clearPending()
|
||||
setStatus('complete')
|
||||
|
||||
if (mode === 'login') {
|
||||
// For login, call /complete to exchange for Supabase session
|
||||
try {
|
||||
const completeRes = await fetch(`${API_BASE}/complete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionId: session.sessionId,
|
||||
mode: 'login',
|
||||
}),
|
||||
})
|
||||
const completeJson = await completeRes.json()
|
||||
|
||||
if (!completeRes.ok) {
|
||||
const errorCode = completeJson.error === 'service_unavailable' || completeRes.status === 502 || completeRes.status === 503
|
||||
? 'service_unavailable' as const
|
||||
: completeJson.error
|
||||
if (errorCode === 'service_unavailable') {
|
||||
setStatus('service_unavailable')
|
||||
setErrorMessage('BankID-tjänsten är inte tillgänglig just nu')
|
||||
}
|
||||
onCompleteRef.current({
|
||||
error: errorCode,
|
||||
givenName: completeJson.givenName,
|
||||
surname: completeJson.surname,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
onCompleteRef.current({
|
||||
tokenHash: completeJson.data.tokenHash,
|
||||
type: completeJson.data.type,
|
||||
isNewUser: completeJson.data.isNewUser,
|
||||
})
|
||||
} catch {
|
||||
onCompleteRef.current({ error: 'session_invalid' })
|
||||
}
|
||||
} else if (mode === 'link') {
|
||||
// For link, call /link to associate BankID with current user
|
||||
try {
|
||||
const linkRes = await fetch(`${API_BASE}/link`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: session.sessionId }),
|
||||
})
|
||||
const linkJson = await linkRes.json()
|
||||
|
||||
if (!linkRes.ok) {
|
||||
onCompleteRef.current({ error: linkJson.error })
|
||||
return
|
||||
}
|
||||
|
||||
onCompleteRef.current({})
|
||||
} catch {
|
||||
onCompleteRef.current({ error: 'session_invalid' })
|
||||
}
|
||||
} else {
|
||||
// For signup, return user data + sessionId so parent can collect email
|
||||
onCompleteRef.current({
|
||||
givenName: pollData.user?.givenName,
|
||||
surname: pollData.user?.surname,
|
||||
sessionId: session.sessionId,
|
||||
})
|
||||
}
|
||||
} else if (pollData.status === 'failed' || pollData.status === 'cancelled') {
|
||||
cleanup()
|
||||
clearPending()
|
||||
setStatus('failed')
|
||||
setErrorMessage(pollData.message || 'BankID-identifieringen misslyckades')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') return
|
||||
pollFailureCount.current++
|
||||
if (pollFailureCount.current >= MAX_POLL_FAILURES) {
|
||||
cleanup()
|
||||
clearPending()
|
||||
setStatus('service_unavailable')
|
||||
setErrorMessage('BankID-tjänsten är inte tillgänglig just nu')
|
||||
onCompleteRef.current({ error: 'service_unavailable' })
|
||||
}
|
||||
}
|
||||
}, 2000)
|
||||
}, [cleanup, mode])
|
||||
|
||||
const startSession = useCallback(async () => {
|
||||
// Prevent rapid restarts (each start = billable TIC session)
|
||||
const now = Date.now()
|
||||
@@ -79,6 +294,7 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) {
|
||||
lastStartRef.current = now
|
||||
|
||||
cleanup()
|
||||
clearPending()
|
||||
setStatus('scanning')
|
||||
setHintMessage('Starta BankID-appen')
|
||||
setErrorMessage('')
|
||||
@@ -101,157 +317,42 @@ export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) {
|
||||
const newSession: BankIdSession = data
|
||||
setSession(newSession)
|
||||
|
||||
// On mobile, open BankID app — redirect=null so it doesn't open a new tab;
|
||||
// the polling in this tab detects completion when the user switches back.
|
||||
// On mobile, open the BankID app on this device.
|
||||
if (isMobile()) {
|
||||
window.location.href = `bankid:///?autostarttoken=${newSession.autoStartToken}&redirect=null`
|
||||
// Persist BEFORE launching: on iOS the BankID app returns by reloading
|
||||
// THIS tab (universal link → same tab), which wipes in-memory state.
|
||||
// The resume effect re-attaches polling on load. See launchBankIdApp.
|
||||
persistPending(newSession, mode)
|
||||
launchBankIdApp(newSession.autoStartToken)
|
||||
}
|
||||
|
||||
// Start polling
|
||||
abortRef.current = new AbortController()
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const pollRes = await fetch(`${API_BASE}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: newSession.sessionId }),
|
||||
signal: abortRef.current?.signal,
|
||||
})
|
||||
|
||||
if (!pollRes.ok) {
|
||||
const pollErr = await pollRes.json().catch(() => ({}))
|
||||
if (pollErr.error === 'service_unavailable' || pollRes.status === 502 || pollRes.status === 503) {
|
||||
pollFailureCount.current++
|
||||
if (pollFailureCount.current >= MAX_POLL_FAILURES) {
|
||||
cleanup()
|
||||
setStatus('service_unavailable')
|
||||
setErrorMessage('BankID-tjänsten är inte tillgänglig just nu')
|
||||
onCompleteRef.current({ error: 'service_unavailable' })
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Reset failure counter on successful poll
|
||||
pollFailureCount.current = 0
|
||||
|
||||
const pollJson = await pollRes.json()
|
||||
const pollData = pollJson.data
|
||||
|
||||
if (!pollData) {
|
||||
console.warn('[bankid] poll returned no data:', pollJson)
|
||||
return
|
||||
}
|
||||
|
||||
// Update hint message from TIC API
|
||||
if (pollData.message) {
|
||||
setHintMessage(pollData.message)
|
||||
}
|
||||
|
||||
// Handle token refresh (order regeneration ~25s)
|
||||
if (pollData.qrStartToken && pollData.qrStartSecret) {
|
||||
setSession((prev) =>
|
||||
prev
|
||||
? { ...prev, qrStartToken: pollData.qrStartToken, qrStartSecret: pollData.qrStartSecret }
|
||||
: prev
|
||||
)
|
||||
}
|
||||
|
||||
if (pollData.status === 'complete') {
|
||||
cleanup()
|
||||
setStatus('complete')
|
||||
|
||||
if (mode === 'login') {
|
||||
// For login, call /complete to exchange for Supabase session
|
||||
try {
|
||||
const completeRes = await fetch(`${API_BASE}/complete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionId: newSession.sessionId,
|
||||
mode: 'login',
|
||||
}),
|
||||
})
|
||||
const completeJson = await completeRes.json()
|
||||
|
||||
if (!completeRes.ok) {
|
||||
const errorCode = completeJson.error === 'service_unavailable' || completeRes.status === 502 || completeRes.status === 503
|
||||
? 'service_unavailable' as const
|
||||
: completeJson.error
|
||||
if (errorCode === 'service_unavailable') {
|
||||
setStatus('service_unavailable')
|
||||
setErrorMessage('BankID-tjänsten är inte tillgänglig just nu')
|
||||
}
|
||||
onCompleteRef.current({
|
||||
error: errorCode,
|
||||
givenName: completeJson.givenName,
|
||||
surname: completeJson.surname,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
onCompleteRef.current({
|
||||
tokenHash: completeJson.data.tokenHash,
|
||||
type: completeJson.data.type,
|
||||
isNewUser: completeJson.data.isNewUser,
|
||||
})
|
||||
} catch {
|
||||
onCompleteRef.current({ error: 'session_invalid' })
|
||||
}
|
||||
} else if (mode === 'link') {
|
||||
// For link, call /link to associate BankID with current user
|
||||
try {
|
||||
const linkRes = await fetch(`${API_BASE}/link`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: newSession.sessionId }),
|
||||
})
|
||||
const linkJson = await linkRes.json()
|
||||
|
||||
if (!linkRes.ok) {
|
||||
onCompleteRef.current({ error: linkJson.error })
|
||||
return
|
||||
}
|
||||
|
||||
onCompleteRef.current({})
|
||||
} catch {
|
||||
onCompleteRef.current({ error: 'session_invalid' })
|
||||
}
|
||||
} else {
|
||||
// For signup, return user data + sessionId so parent can collect email
|
||||
onCompleteRef.current({
|
||||
givenName: pollData.user?.givenName,
|
||||
surname: pollData.user?.surname,
|
||||
sessionId: newSession.sessionId,
|
||||
})
|
||||
}
|
||||
} else if (pollData.status === 'failed' || pollData.status === 'cancelled') {
|
||||
cleanup()
|
||||
setStatus('failed')
|
||||
setErrorMessage(pollData.message || 'BankID-identifieringen misslyckades')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') return
|
||||
pollFailureCount.current++
|
||||
if (pollFailureCount.current >= MAX_POLL_FAILURES) {
|
||||
cleanup()
|
||||
setStatus('service_unavailable')
|
||||
setErrorMessage('BankID-tjänsten är inte tillgänglig just nu')
|
||||
onCompleteRef.current({ error: 'service_unavailable' })
|
||||
}
|
||||
}
|
||||
}, 2000)
|
||||
beginPolling(newSession)
|
||||
} catch (error) {
|
||||
setStatus('failed')
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Ett oväntat fel uppstod')
|
||||
}
|
||||
}, [cleanup, mode])
|
||||
}, [cleanup, mode, beginPolling])
|
||||
|
||||
// After returning from the BankID app on mobile, iOS reloads this tab. Pick up
|
||||
// the session we persisted before launching and resume polling so the flow
|
||||
// completes without the user having to tap "Logga in med BankID" again.
|
||||
useEffect(() => {
|
||||
const pending = readPending(mode)
|
||||
if (!pending) return
|
||||
setSession(pending.session)
|
||||
setStatus('scanning')
|
||||
setHintMessage('Slutför BankID-verifieringen...')
|
||||
beginPolling(pending.session)
|
||||
// Run once on mount: we're recovering state that the return-reload destroyed.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const handleCancel = useCallback(async () => {
|
||||
if (session) {
|
||||
fetch(`${API_BASE}/${session.sessionId}`, { method: 'DELETE' }).catch(() => {})
|
||||
}
|
||||
cleanup()
|
||||
clearPending()
|
||||
setStatus('idle')
|
||||
setSession(null)
|
||||
}, [session, cleanup])
|
||||
|
||||
@@ -8,8 +8,9 @@ import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Plus, Trash2, AlertTriangle, Loader2, Lock, CalendarPlus } from 'lucide-react'
|
||||
import { Plus, Trash2, AlertTriangle, Loader2, Lock, CalendarPlus, Eraser } from 'lucide-react'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
|
||||
import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntryReviewContent'
|
||||
@@ -106,6 +107,7 @@ export default function JournalEntryForm({
|
||||
const [foreignAmount, setForeignAmount] = useState('')
|
||||
const [periodMismatch, setPeriodMismatch] = useState<'no_period' | 'wrong_period' | null>(null)
|
||||
const [showCreatePeriod, setShowCreatePeriod] = useState(false)
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false)
|
||||
// Month (YYYY-MM) of the most recently posted voucher this session. Used to
|
||||
// flag, at the review step, when the user is about to book into a different
|
||||
// month — guards against accidentally posting to the wrong month.
|
||||
@@ -457,6 +459,20 @@ export default function JournalEntryForm({
|
||||
if (!description) setDescription(templateDescription)
|
||||
}
|
||||
|
||||
// Wipe the form back to a blank entry. Mirrors the post-submit reset: it
|
||||
// clears the data the user typed (lines, description, note, attachments,
|
||||
// currency) but keeps the contextual defaults (period, date, series) so the
|
||||
// form is immediately ready for the next entry.
|
||||
const handleClearAll = () => {
|
||||
setDescription('')
|
||||
setNotes('')
|
||||
setUploadedFiles([])
|
||||
setLines([{ ...BLANK_LINE }, { ...BLANK_LINE }])
|
||||
setEntryCurrency('SEK')
|
||||
setExchangeRate('')
|
||||
setForeignAmount('')
|
||||
}
|
||||
|
||||
const handleOpenCreateAccount = (lineIndex: number, prefill: string) => {
|
||||
setCreatingAccountForLine(lineIndex)
|
||||
setCreateAccountPrefill(prefill)
|
||||
@@ -1067,6 +1083,17 @@ export default function JournalEntryForm({
|
||||
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<div className="flex gap-2">
|
||||
{!embedded && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setShowClearConfirm(true)}
|
||||
disabled={!hasContent || isSubmitting || isSavingDraft}
|
||||
title={t('clear_all_tooltip')}
|
||||
>
|
||||
<Eraser className="mr-2 h-4 w-4" />
|
||||
{t('clear_all')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSaveDraft}
|
||||
@@ -1195,6 +1222,31 @@ export default function JournalEntryForm({
|
||||
periods={periods}
|
||||
onCreated={fetchPeriods}
|
||||
/>
|
||||
|
||||
{/* Clear-all confirmation */}
|
||||
<Dialog open={showClearConfirm} onOpenChange={setShowClearConfirm}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('clear_all_confirm_title')}</DialogTitle>
|
||||
<DialogDescription>{t('clear_all_confirm_body')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowClearConfirm(false)}>
|
||||
{t('clear_all_cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
handleClearAll()
|
||||
setShowClearConfirm(false)
|
||||
}}
|
||||
>
|
||||
<Eraser className="mr-2 h-4 w-4" />
|
||||
{t('clear_all_confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@@ -4,13 +4,27 @@ import { useState, useEffect, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { ChevronDown, ChevronRight, Paperclip, AlertTriangle, CircleSlash, Loader2, BookOpen, X, Copy, Lock } from 'lucide-react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
FiscalYearSelector,
|
||||
STORAGE_KEY_PREFIX as FISCAL_YEAR_STORAGE_KEY_PREFIX,
|
||||
ALL_YEARS_VALUE as FISCAL_YEAR_ALL_VALUE,
|
||||
} from '@/components/common/FiscalYearSelector'
|
||||
import { ChevronDown, ChevronRight, Paperclip, AlertTriangle, CircleSlash, Loader2, BookOpen, X, Copy, Lock, Search, SlidersHorizontal } from 'lucide-react'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -24,7 +38,8 @@ import AttachmentPreviewSheet from '@/components/bookkeeping/AttachmentPreviewSh
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import type { JournalEntry, JournalEntryLine } from '@/types'
|
||||
import { useCompanyOptional } from '@/contexts/CompanyContext'
|
||||
import type { FiscalPeriod, JournalEntry, JournalEntryLine } from '@/types'
|
||||
|
||||
const NEEDS_ATTACHMENT = new Set([
|
||||
'manual',
|
||||
@@ -35,14 +50,18 @@ const NEEDS_ATTACHMENT = new Set([
|
||||
'import',
|
||||
])
|
||||
|
||||
interface Props {
|
||||
periodId?: string
|
||||
}
|
||||
type SortBy = 'date_desc' | 'date_asc' | 'voucher_asc' | 'voucher_desc'
|
||||
|
||||
export default function JournalEntryList({ periodId }: Props) {
|
||||
// Per-company persistence of the sort dropdown. Mirrors the localStorage
|
||||
// convention used by FiscalYearSelector ('Accounted:fiscal-year:<companyId>').
|
||||
const SORT_STORAGE_KEY_PREFIX = 'Accounted:journal-sort:'
|
||||
const SORT_VALUES = new Set<SortBy>(['date_desc', 'date_asc', 'voucher_asc', 'voucher_desc'])
|
||||
|
||||
export default function JournalEntryList() {
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const { canWrite } = useCanWrite()
|
||||
const company = useCompanyOptional()?.company ?? null
|
||||
const t = useTranslations('journal_list')
|
||||
const [entries, setEntries] = useState<JournalEntry[]>([])
|
||||
const [committingId, setCommittingId] = useState<string | null>(null)
|
||||
@@ -55,12 +74,19 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
const [showMissingOnly, setShowMissingOnly] = useState(false)
|
||||
const [correctionEntry, setCorrectionEntry] = useState<JournalEntry | null>(null)
|
||||
const [previewEntryId, setPreviewEntryId] = useState<string | null>(null)
|
||||
const [sortBy, setSortBy] = useState<'date_desc' | 'date_asc' | 'voucher_asc' | 'voucher_desc'>('date_desc')
|
||||
const [sortBy, setSortBy] = useState<SortBy>('date_desc')
|
||||
const [sortHydrated, setSortHydrated] = useState(false)
|
||||
const [periodId, setPeriodId] = useState<string | null>(null)
|
||||
const [periodHydrated, setPeriodHydrated] = useState(false)
|
||||
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
|
||||
const [filterOpen, setFilterOpen] = useState(false)
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const [dateFromInput, setDateFromInput] = useState('')
|
||||
const [dateToInput, setDateToInput] = useState('')
|
||||
const [seriesFilter, setSeriesFilter] = useState<string>('all')
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const pageSize = 20
|
||||
|
||||
const normalizeDate = (v: string): string | null => {
|
||||
@@ -136,6 +162,72 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
fetchNoDocRequired()
|
||||
}, [fetchNoDocRequired])
|
||||
|
||||
// Restore the persisted sort order (per company). Read in an effect rather
|
||||
// than the useState initializer to avoid an SSR/client hydration mismatch.
|
||||
// sortHydrated gates the first fetch so the list is fetched once, already in
|
||||
// the saved order — no flash of the default sort.
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = window.localStorage.getItem(SORT_STORAGE_KEY_PREFIX + (company?.id ?? 'default'))
|
||||
if (stored && SORT_VALUES.has(stored as SortBy)) setSortBy(stored as SortBy)
|
||||
}
|
||||
setSortHydrated(true)
|
||||
}, [company?.id])
|
||||
|
||||
// Restore the persisted fiscal-year selection (per company), reading the same
|
||||
// localStorage key FiscalYearSelector writes. The selector lives inside the
|
||||
// filter dialog and only mounts when opened, so we resolve the saved scope
|
||||
// here — independent of the dialog — to keep the initial fetch correct.
|
||||
// periodHydrated gates the first fetch so the list loads already scoped.
|
||||
useEffect(() => {
|
||||
if (company?.id && typeof window !== 'undefined') {
|
||||
const stored = window.localStorage.getItem(FISCAL_YEAR_STORAGE_KEY_PREFIX + company.id)
|
||||
setPeriodId(stored && stored !== FISCAL_YEAR_ALL_VALUE ? stored : null)
|
||||
} else {
|
||||
setPeriodId(null)
|
||||
}
|
||||
setPeriodHydrated(true)
|
||||
}, [company?.id])
|
||||
|
||||
// Fetch fiscal periods so the active räkenskapsår can be labelled on the
|
||||
// filter bar without opening the dialog (BFL period-orientation: the user
|
||||
// should always see which year the ledger is scoped to). Read-only — the
|
||||
// dialog's FiscalYearSelector still owns selection; this copy resolves the
|
||||
// name for display.
|
||||
useEffect(() => {
|
||||
if (!company?.id) {
|
||||
setPeriods([])
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/bookkeeping/fiscal-periods')
|
||||
if (!res.ok) return
|
||||
const { data } = await res.json()
|
||||
if (!cancelled) setPeriods((data || []) as FiscalPeriod[])
|
||||
} catch {
|
||||
// Non-critical — the chip falls back to the active-filter count badge.
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [company?.id])
|
||||
|
||||
// Debounce the free-text search before it reaches the API. Require ≥2 chars:
|
||||
// a single character matches almost every verifikationstext and isn't a useful
|
||||
// filter, so 0–1 chars are treated as "no search" instead of firing a query on
|
||||
// every keystroke (ASVS V2.4).
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
const trimmed = searchInput.trim()
|
||||
setSearch(trimmed.length >= 2 ? trimmed : '')
|
||||
setPage(0)
|
||||
}, 300)
|
||||
return () => clearTimeout(handle)
|
||||
}, [searchInput])
|
||||
|
||||
async function fetchEntries() {
|
||||
setLoading(true)
|
||||
const params = new URLSearchParams({
|
||||
@@ -147,6 +239,7 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
if (dateFrom) params.set('date_from', dateFrom)
|
||||
if (dateTo) params.set('date_to', dateTo)
|
||||
if (seriesFilter !== 'all') params.set('series', seriesFilter)
|
||||
if (search) params.set('search', search)
|
||||
|
||||
const res = await fetch(`/api/bookkeeping/journal-entries?${params}`)
|
||||
if (!res.ok) {
|
||||
@@ -165,8 +258,9 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!sortHydrated || !periodHydrated) return
|
||||
fetchEntries()
|
||||
}, [periodId, page, sortBy, dateFrom, dateTo, seriesFilter])
|
||||
}, [periodId, page, sortBy, dateFrom, dateTo, seriesFilter, search, sortHydrated, periodHydrated])
|
||||
|
||||
const handleAttachmentCountChange = useCallback((entryId: string, count: number) => {
|
||||
setAttachmentCounts((prev) => ({ ...prev, [entryId]: count }))
|
||||
@@ -198,18 +292,63 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground mb-3" />
|
||||
<p className="text-sm text-muted-foreground">{t('loading')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
const filteredEntries = showMissingOnly
|
||||
? entries.filter(
|
||||
(e) =>
|
||||
NEEDS_ATTACHMENT.has(e.source_type) &&
|
||||
!attachmentCounts[e.id] &&
|
||||
e.status === 'posted' &&
|
||||
!noDocRequired.has(e.id)
|
||||
)
|
||||
: entries
|
||||
|
||||
// Count of active dialog filters, shown as a badge on the Filtrera button so
|
||||
// the user can tell the list is scoped without opening the dialog. Sort order
|
||||
// is a view preference (always set), not a filter, so it is excluded.
|
||||
const activeFilterCount =
|
||||
(periodId ? 1 : 0) +
|
||||
(seriesFilter !== 'all' ? 1 : 0) +
|
||||
(dateFrom || dateTo ? 1 : 0) +
|
||||
(showMissingOnly ? 1 : 0)
|
||||
|
||||
// When any filter or search is active we keep the filter bar mounted even
|
||||
// with zero results, so the user can edit or clear their query. The pristine
|
||||
// "no entries yet" state below only applies to an untouched, empty ledger.
|
||||
const hasActiveFilters = Boolean(search) || activeFilterCount > 0
|
||||
|
||||
// Resolve the active fiscal-year scope for the bar chip. "All years" (periodId
|
||||
// null) renders immediately; a specific period waits until its name resolves
|
||||
// from the fetched list (scopeLabel stays null meanwhile, so the chip never
|
||||
// flashes the wrong scope). Surfacing this keeps the period visible per BFL
|
||||
// without the user having to open the filter dialog.
|
||||
const activePeriod = periodId ? periods.find((p) => p.id === periodId) ?? null : null
|
||||
const scopeLabel = periodId ? activePeriod?.name ?? null : t('scope_all_years')
|
||||
|
||||
// Apply a fiscal-year selection from the dialog. The FiscalYearSelector
|
||||
// persists the choice to localStorage itself; here we only mirror it into
|
||||
// local state and reset pagination.
|
||||
const handlePeriodChange = (next: string | null) => {
|
||||
setPeriodId(next)
|
||||
setPage(0)
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
const clearAllFilters = () => {
|
||||
setPeriodId(null)
|
||||
// Mirror the selector's "Alla räkenskapsår" write so the cleared scope
|
||||
// survives a remount/reload instead of being restored from a stale value.
|
||||
if (company?.id && typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(FISCAL_YEAR_STORAGE_KEY_PREFIX + company.id, FISCAL_YEAR_ALL_VALUE)
|
||||
}
|
||||
setSeriesFilter('all')
|
||||
setShowMissingOnly(false)
|
||||
setDateFrom('')
|
||||
setDateTo('')
|
||||
setDateFromInput('')
|
||||
setDateToInput('')
|
||||
setPage(0)
|
||||
}
|
||||
|
||||
if (!loading && entries.length === 0 && !hasActiveFilters) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
@@ -225,124 +364,240 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
)
|
||||
}
|
||||
|
||||
const filteredEntries = showMissingOnly
|
||||
? entries.filter(
|
||||
(e) =>
|
||||
NEEDS_ATTACHMENT.has(e.source_type) &&
|
||||
!attachmentCounts[e.id] &&
|
||||
e.status === 'posted' &&
|
||||
!noDocRequired.has(e.id)
|
||||
)
|
||||
: entries
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filters and sorting */}
|
||||
<div className="space-y-3 sm:space-y-0 sm:flex sm:items-center sm:gap-4 sm:flex-wrap">
|
||||
<div className="flex items-center justify-between sm:justify-start gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="missing-attachments"
|
||||
checked={showMissingOnly}
|
||||
onCheckedChange={setShowMissingOnly}
|
||||
/>
|
||||
<Label htmlFor="missing-attachments" className="text-sm cursor-pointer">
|
||||
{t('show_missing')}
|
||||
</Label>
|
||||
{showMissingOnly && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{filteredEntries.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Select value={sortBy} onValueChange={(v) => { setSortBy(v as typeof sortBy); setPage(0) }}>
|
||||
<SelectTrigger className="h-8 w-auto gap-1.5 text-xs sm:w-[200px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="date_desc">{t('sort_date_desc')}</SelectItem>
|
||||
<SelectItem value="date_asc">{t('sort_date_asc')}</SelectItem>
|
||||
<SelectItem value="voucher_asc">{t('sort_voucher_asc')}</SelectItem>
|
||||
<SelectItem value="voucher_desc">{t('sort_voucher_desc')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={seriesFilter} onValueChange={(v) => { setSeriesFilter(v); setPage(0) }}>
|
||||
<SelectTrigger
|
||||
className="h-8 w-auto gap-1.5 text-xs sm:w-[120px] font-mono"
|
||||
aria-label="Verifikationsserie"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all" className="text-xs">Alla serier</SelectItem>
|
||||
{'ABCDEFG'.split('').map((letter) => (
|
||||
<SelectItem key={letter} value={letter} className="font-mono text-xs">
|
||||
Serie {letter}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Search (always visible) + filter dialog for everything else */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1 sm:flex-none sm:w-[280px]">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t('date_from_placeholder')}
|
||||
value={dateFromInput}
|
||||
onChange={(e) => setDateFromInput(e.target.value)}
|
||||
onBlur={() => {
|
||||
const v = dateFromInput.trim()
|
||||
if (v === '') return
|
||||
const normalized = normalizeDate(v)
|
||||
if (normalized) setDateFromInput(normalized)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
applyDateFilter()
|
||||
}
|
||||
}}
|
||||
className="h-8 flex-1 sm:flex-none sm:w-[145px] text-xs"
|
||||
inputMode="search"
|
||||
placeholder={t('search_placeholder')}
|
||||
aria-label={t('search_placeholder')}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="h-8 pl-8 pr-7 text-xs"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t('date_to_placeholder')}
|
||||
value={dateToInput}
|
||||
onChange={(e) => setDateToInput(e.target.value)}
|
||||
onBlur={() => {
|
||||
const v = dateToInput.trim()
|
||||
if (v === '') return
|
||||
const normalized = normalizeDate(v)
|
||||
if (normalized) setDateToInput(normalized)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
applyDateFilter()
|
||||
}
|
||||
}}
|
||||
className="h-8 flex-1 sm:flex-none sm:w-[145px] text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs shrink-0"
|
||||
onClick={applyDateFilter}
|
||||
>
|
||||
{t('filter')}
|
||||
</Button>
|
||||
{(dateFrom || dateTo) && (
|
||||
{searchInput && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setDateFrom(''); setDateTo(''); setDateFromInput(''); setDateToInput(''); setPage(0) }}
|
||||
className="p-1 rounded-sm hover:bg-muted text-muted-foreground shrink-0"
|
||||
title={t('clear_date_filter')}
|
||||
onClick={() => setSearchInput('')}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 rounded-sm hover:bg-muted text-muted-foreground"
|
||||
title={t('clear_search')}
|
||||
aria-label={t('clear_search')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={filterOpen} onOpenChange={setFilterOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-2 text-xs shrink-0"
|
||||
aria-label={
|
||||
activeFilterCount > 0
|
||||
? t('filter_with_count', { count: activeFilterCount })
|
||||
: t('filter')
|
||||
}
|
||||
>
|
||||
<SlidersHorizontal className="h-3.5 w-3.5" />
|
||||
{t('filter')}
|
||||
{activeFilterCount > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-4 min-w-4 justify-center px-1 text-[10px] tabular-nums"
|
||||
>
|
||||
{activeFilterCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('filter_dialog_title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Räkenskapsår */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">{t('filter_section_period')}</Label>
|
||||
<FiscalYearSelector
|
||||
value={periodId}
|
||||
onChange={handlePeriodChange}
|
||||
label={null}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sortering */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">{t('filter_section_sort')}</Label>
|
||||
<Select
|
||||
value={sortBy}
|
||||
onValueChange={(v) => {
|
||||
const next = v as SortBy
|
||||
setSortBy(next)
|
||||
setPage(0)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(SORT_STORAGE_KEY_PREFIX + (company?.id ?? 'default'), next)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="date_desc">{t('sort_date_desc')}</SelectItem>
|
||||
<SelectItem value="date_asc">{t('sort_date_asc')}</SelectItem>
|
||||
<SelectItem value="voucher_asc">{t('sort_voucher_asc')}</SelectItem>
|
||||
<SelectItem value="voucher_desc">{t('sort_voucher_desc')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Verifikationsserie */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">{t('filter_section_series')}</Label>
|
||||
<Select value={seriesFilter} onValueChange={(v) => { setSeriesFilter(v); setPage(0) }}>
|
||||
<SelectTrigger className="h-9 w-full text-sm font-mono" aria-label={t('filter_section_series')}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Alla serier</SelectItem>
|
||||
{'ABCDEFG'.split('').map((letter) => (
|
||||
<SelectItem key={letter} value={letter} className="font-mono">
|
||||
Serie {letter}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Datumintervall */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">{t('filter_section_date')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t('date_from_placeholder')}
|
||||
value={dateFromInput}
|
||||
onChange={(e) => setDateFromInput(e.target.value)}
|
||||
onBlur={applyDateFilter}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
applyDateFilter()
|
||||
}
|
||||
}}
|
||||
className="h-9 flex-1 text-sm"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">–</span>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t('date_to_placeholder')}
|
||||
value={dateToInput}
|
||||
onChange={(e) => setDateToInput(e.target.value)}
|
||||
onBlur={applyDateFilter}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
applyDateFilter()
|
||||
}
|
||||
}}
|
||||
className="h-9 flex-1 text-sm"
|
||||
/>
|
||||
{(dateFrom || dateTo) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setDateFrom(''); setDateTo(''); setDateFromInput(''); setDateToInput(''); setPage(0) }}
|
||||
className="p-1 rounded-sm hover:bg-muted text-muted-foreground shrink-0"
|
||||
title={t('clear_date_filter')}
|
||||
aria-label={t('clear_date_filter')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visa saknade underlag */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="missing-attachments"
|
||||
checked={showMissingOnly}
|
||||
onCheckedChange={setShowMissingOnly}
|
||||
/>
|
||||
<Label htmlFor="missing-attachments" className="text-sm cursor-pointer">
|
||||
{t('show_missing')}
|
||||
</Label>
|
||||
{showMissingOnly && (
|
||||
<Badge variant="secondary" className="text-xs tabular-nums">
|
||||
{filteredEntries.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="sm:justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearAllFilters}
|
||||
disabled={activeFilterCount === 0}
|
||||
>
|
||||
{t('filter_clear_all')}
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" size="sm">{t('filter_done')}</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Active fiscal-year scope — visible without opening the filter dialog so
|
||||
the user always sees which räkenskapsår the ledger is scoped to (BFL
|
||||
period-correctness). Clicking it opens the dialog to change the scope. */}
|
||||
{periodHydrated && scopeLabel && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{t('scope_label')}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilterOpen(true)}
|
||||
className="inline-flex h-8 items-center gap-1 rounded-md border border-border px-2 font-medium text-foreground transition-colors duration-150 hover:bg-secondary/60"
|
||||
>
|
||||
{scopeLabel}
|
||||
{(activePeriod?.locked_at || activePeriod?.is_closed) && (
|
||||
<Lock className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground mb-3" />
|
||||
<p className="text-sm text-muted-foreground">{t('loading')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : filteredEntries.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<div className="p-4 rounded-full bg-muted mb-4">
|
||||
<Search className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium mb-1">{t('no_results_title')}</h3>
|
||||
<p className="text-sm text-muted-foreground text-center max-w-sm">
|
||||
{t('no_results_description')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredEntries.map((entry) => {
|
||||
const isExpanded = expandedId === entry.id
|
||||
@@ -385,6 +640,33 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
<JournalEntryStatusBadge entry={entry} showStatus={entry.status === 'reversed' || entry.status === 'draft'} />
|
||||
)}
|
||||
<span className="flex-1 truncate">{entry.description}</span>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground transition-colors duration-150 hover:bg-secondary"
|
||||
>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('copy_voucher_tooltip')}
|
||||
title={t('copy_voucher_tooltip')}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
router.push(`/bookkeeping?copy_from=${entry.id}`)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
router.push(`/bookkeeping?copy_from=${entry.id}`)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</Button>
|
||||
{attachmentCounts[entry.id] ? (
|
||||
<Button
|
||||
asChild
|
||||
@@ -461,6 +743,33 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
<JournalEntryStatusBadge entry={entry} showStatus={entry.status === 'reversed' || entry.status === 'draft'} />
|
||||
)}
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground transition-colors duration-150 hover:bg-secondary"
|
||||
>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('copy_voucher_tooltip')}
|
||||
title={t('copy_voucher_tooltip')}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
router.push(`/bookkeeping?copy_from=${entry.id}`)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
router.push(`/bookkeeping?copy_from=${entry.id}`)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</Button>
|
||||
{attachmentCounts[entry.id] ? (
|
||||
<Button
|
||||
asChild
|
||||
@@ -638,6 +947,7 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Correction dialog */}
|
||||
{correctionEntry && (
|
||||
|
||||
@@ -15,8 +15,10 @@ import { Lock } from 'lucide-react'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import type { FiscalPeriod } from '@/types'
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'Accounted:fiscal-year:'
|
||||
const ALL_YEARS_VALUE = '__all__'
|
||||
// Exported so other surfaces (e.g. JournalEntryList's filter dialog) can read
|
||||
// and write the same persisted selection without duplicating the magic string.
|
||||
export const STORAGE_KEY_PREFIX = 'Accounted:fiscal-year:'
|
||||
export const ALL_YEARS_VALUE = '__all__'
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
|
||||
@@ -171,7 +171,7 @@ export function CompanyMembersSection() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleInvite} className="flex gap-3">
|
||||
<form onSubmit={handleInvite} className="flex flex-col gap-3 sm:flex-row">
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="company-invite-email" className="sr-only">{t('members_invite_email_label')}</Label>
|
||||
<Input
|
||||
@@ -185,7 +185,7 @@ export function CompanyMembersSection() {
|
||||
/>
|
||||
</div>
|
||||
<Select value={inviteRole} onValueChange={setInviteRole}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectTrigger className="w-full sm:w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -31,7 +31,7 @@ export function SettingsShell({ variant, activeSection, children }: SettingsShel
|
||||
<aside className="hidden w-56 shrink-0 overflow-y-auto border-r border-border p-3 md:block">
|
||||
<SettingsRail variant="modal" display="rail" activeId={activeSection} />
|
||||
</aside>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-6">
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto p-6">
|
||||
<div className="mb-6 md:hidden">
|
||||
<SettingsRail variant="modal" display="select" activeId={activeSection} />
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { cn, formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { isImportedTransaction } from '@/lib/transactions/origin'
|
||||
import { getCategoryDisplayName } from '@/lib/tax/expense-warnings'
|
||||
import {
|
||||
ArrowUpRight,
|
||||
@@ -229,6 +230,9 @@ function BankHistoryRow({
|
||||
const { canWrite } = useCanWrite()
|
||||
const isIncome = transaction.amount > 0
|
||||
const isBooked = !!transaction.journal_entry_id
|
||||
// Only user-created rows are deletable; imported (bank sync / CSV) rows are
|
||||
// ignore-only. Mirrors the server guard in DELETE /api/transactions/[id].
|
||||
const canDelete = !isBooked && !isImportedTransaction(transaction)
|
||||
const isLinkedToInvoice = !!transaction.invoice_id
|
||||
const hasInvoiceMatch =
|
||||
!isLinkedToInvoice && !!transaction.potential_invoice && !isBooked
|
||||
@@ -315,7 +319,7 @@ function BankHistoryRow({
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{(hasInvoiceMatch || (!isBooked && onDelete) || (isBooked && canWrite)) && (
|
||||
{(hasInvoiceMatch || (canDelete && onDelete) || (isBooked && canWrite)) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -345,7 +349,7 @@ function BankHistoryRow({
|
||||
)}
|
||||
</CorrectionAffordance>
|
||||
)}
|
||||
{!isBooked && onDelete && (
|
||||
{canDelete && onDelete && (
|
||||
<>
|
||||
{hasInvoiceMatch && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
DataListMetaSeparator,
|
||||
} from '@/components/ui/data-list'
|
||||
import { cn, formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { isImportedTransaction } from '@/lib/transactions/origin'
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowUpRight,
|
||||
@@ -125,7 +126,13 @@ export default function TransactionInboxCard({
|
||||
!!transaction.potential_supplier_invoice && !transaction.supplier_invoice_id
|
||||
const isUncategorized = transaction.is_business === null && !transaction.journal_entry_id
|
||||
const showCheckbox = isBatchMode && isUncategorized
|
||||
const isDeletable = !transaction.journal_entry_id
|
||||
// Unbooked rows are still actionable (match, split, edit, categorize) — that
|
||||
// includes imported bank rows, which are the whole point of the inbox.
|
||||
const isUnbooked = !transaction.journal_entry_id
|
||||
// ...but only rows the USER created in the app may be deleted. Imported rows
|
||||
// (bank sync / CSV) are ignore-only — mirrors the server guard in
|
||||
// DELETE /api/transactions/[id]. See lib/transactions/origin.ts.
|
||||
const canDelete = isUnbooked && !isImportedTransaction(transaction)
|
||||
// Title is editable only on a mutable staging row — not booked and not
|
||||
// confirmed-matched. Mirrors the server-side gate in PATCH /api/transactions/[id].
|
||||
const isTitleEditable =
|
||||
@@ -200,7 +207,7 @@ export default function TransactionInboxCard({
|
||||
// Manual invoice-match affordance. Hidden once an auto-detected match is
|
||||
// already shown as the primary button — having both makes the row noisy.
|
||||
const showInvoiceMatchButton =
|
||||
isDeletable && !hasInvoiceMatch && !hasSupplierInvoiceMatch
|
||||
isUnbooked && !hasInvoiceMatch && !hasSupplierInvoiceMatch
|
||||
|
||||
const invoiceMatchLabel = isIncome
|
||||
? 'Matcha mot kundfaktura'
|
||||
@@ -216,10 +223,10 @@ export default function TransactionInboxCard({
|
||||
// Available on any unbooked row (income or expense), independent of whether an
|
||||
// invoice match was auto-detected: the user may want to point the bank line at
|
||||
// an existing salary/Fortnox/manual voucher instead of confirming a payment.
|
||||
const showMatchVoucherItem = isDeletable && !!onOpenMatchVoucher
|
||||
const showMatchVoucherItem = isUnbooked && !!onOpenMatchVoucher
|
||||
const showSplitItem = showInvoiceMatchButton && !!onOpenSplitMatch
|
||||
const showEditItem = isTitleEditable && !!onEditTitle
|
||||
const showDeleteItem = isDeletable && !!onDelete
|
||||
const showDeleteItem = canDelete && !!onDelete
|
||||
const showOverflowMenu = showMatchVoucherItem || showSplitItem || showEditItem || showDeleteItem
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user