feat(inbox): run the receipt hunt from the page it fills (#1519)

* feat(inbox): run the receipt hunt from the page it fills

The hunt could only be started from Settings. The page where a person
notices that receipts are missing had no way to go and look for them, and
the button that fixes it sat behind a different navigation item. That seam
is the kind that makes a working feature look broken.

"Leta i mejlen" now sits in the Underlag header, next to Ladda upp, and
only when a mailbox is actually connected: offering it otherwise promises
something it cannot do.

The loop moves into a shared hook rather than being copied. It belongs to
neither surface, and two implementations of "when does a run stop" would
eventually disagree about the one thing that matters, which is that a pass
finding nothing new means the mailboxes hold nothing more for the
purchases still open.

Each pass refreshes both lists, so a run fills the page as it goes instead
of all at once at the end. That matters more here than in Settings: a pass
can attach a document to a purchase, which moves a row out of "saknar
underlag" and into the inbox, and watching that happen is the feedback
that the button did something.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(inbox): only offer the hunt when a mailbox can actually be searched

Counting connection rows does not answer whether anything is searchable. A
revoked or expired connection is still a row, and the hunt skips it, so the
button promised a search that would return nothing on every pass.

A dead mailbox that still looks healthy is the exact failure this feature
exists to surface. Starting by doing it in its own header would be a poor
joke.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-11 12:53:45 +02:00
committed by GitHub
co-authored by Claude Opus 5 Jakob Wennberg
parent 11b82cbb91
commit bf220eac3c
3 changed files with 156 additions and 60 deletions
@@ -41,6 +41,7 @@ import {
} from 'lucide-react'
import Link from 'next/link'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { useReceiptHunt } from '@/components/extensions/general/use-receipt-hunt'
import { createClient } from '@/lib/supabase/client'
import { fetchWithTimeout } from '@/lib/http/fetch-with-timeout'
import { copyInboxAddress, type AddressCopyState } from '@/components/extensions/general/inbox-address-copy'
@@ -448,6 +449,27 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
const [purchases, setPurchases] = useState<PurchaseWithoutUnderlag[]>([])
const [selectedPurchaseId, setSelectedPurchaseId] = useState<string | null>(null)
// Whether any mailbox can actually be searched. Counting rows would not
// answer that: a revoked or expired connection is still a row, and the hunt
// skips it, so the button would promise a search that returns nothing every
// pass. A dead mailbox looking healthy is the exact failure this feature
// exists to surface, so it must not start by doing it in its own header.
const [mailConnected, setMailConnected] = useState(false)
useEffect(() => {
void (async () => {
try {
const res = await fetch('/api/extensions/ext/mail/connections')
if (!res.ok) return
const json = (await res.json()) as {
data?: { connections?: { status?: string }[] }
}
setMailConnected((json.data?.connections ?? []).some((c) => c.status === 'active'))
} catch {
// The extension may not be enabled at all; stay quiet.
}
})()
}, [])
const fetchPurchases = useCallback(async () => {
try {
const res = await fetch('/api/extensions/ext/invoice-inbox/purchases')
@@ -463,6 +485,18 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
void fetchPurchases()
}, [fetchPurchases])
// A pass can attach a document to a purchase, which moves a row from one
// list to the other, so both refresh as the run goes rather than at the end.
const {
hunt,
stop: stopHunt,
hunting,
progress: huntProgress,
} = useReceiptHunt(() => {
void fetchItems()
void fetchPurchases()
})
const selectedPurchase = useMemo(
() => purchases.find((p) => p.id === selectedPurchaseId) ?? null,
[purchases, selectedPurchaseId],
@@ -924,6 +958,26 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
className="hidden"
onChange={handleFileInputChange}
/>
{/* The hunt lived only in Settings, so the button that fills this
page sat on a different page. It runs in passes and reports as it
goes, because a backlog does not clear in one request. */}
{mailConnected && (
<Button variant="ghost" size="sm" onClick={hunting ? stopHunt : hunt} disabled={false}>
{hunting ? (
<>
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
{huntProgress
? `Letar… ${huntProgress.fetched} hittade`
: 'Letar…'}
</>
) : (
<>
<Search className="h-3.5 w-3.5 mr-1.5" />
Leta i mejlen
</>
)}
</Button>
)}
<Button
variant="outline"
size="sm"