* docs(inbox): the onboarding card described the page as it used to be Three steps ending at 'matcha mot en transaktion eller bokför', a Beta badge it had outgrown, and no mention that the page now searches the mailboxes itself, lists the purchases missing a receipt, or proposes the kontering. It now names the three things a person actually does: get an address, connect a brevlåda so Kvittojakten can look on its own, and approve the proposed kontering. The pricing line keeps the distinction that matters (collecting underlag is free; AI-tolkning and the hunt are in the plan) and drops the Beta badge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(inbox): say each thing once, and stop explaining what doing it teaches Six things the page said that it did not need to say, or said twice. The mailbox rows misaligned because the address could not shrink: a long one pushed the date and Koppla från onto a second line while the provider mark stayed centred against a now two-line row. min-w-0 lets truncate work. The settings group was labelled Leta efter underlag directly above a row labelled Sök igenom brevlådorna. The group is now Kvittojakten, which is what the rest of the app calls it. A hunt that found nothing left its line on screen indefinitely. There is nothing to act on, so it clears after a few seconds. A run that found something, or failed, still stays: both name a next step. Ändra kontering opened with no rows at all for an unknown supplier, so the first move was Lägg till rad before anything could be typed. The form already defaults to two blank rows when given nothing, but an empty proposal was passed as [] rather than undefined, which is not the same. Its description restated the title, and the keyboard tips sat under every entry form permanently; both are learned by doing. The matched state was stated twice in one rail, as a bordered box and a badge. The badge keeps it, and takes over the box's link to the transaction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(pending): name the account, not just the line text A proposal read '5890 Utlägg Norwegian'. 5890 is Övriga resekostnader, which the preview never said: it printed the account number next to the line's own description, so the only readable word on the line was one the proposal wrote about itself. Approving meant trusting a label that never named what was being debited, and a travel cost looked like an utlägg. The account's own name now leads, with the line text after it when it says something the name does not. Same for the VAT lines. Fetched once and shared across previews; a failed lookup leaves the number rather than blanking the line. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(pending): the account-name map must not outlive the company Review finding, and the tenancy half is the real one. The lookup was a module-level promise populated once with ??= and never invalidated, so a company switch that does not reload the page would keep showing the previous company's account names against this company's numbers: a wrong name reads as verified in a way a bare number never does. It is also permanent on failure. A single transient error resolved the cached promise to {} for the rest of the session, with no retry short of a reload. The page owns it now and passes it down by context: one fetch per mount, gone when the page is, and a failure leaves the bare number and retries next time. 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>
209 lines
7.6 KiB
TypeScript
209 lines
7.6 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import { useTranslations } from 'next-intl'
|
|
import { Loader2 } from 'lucide-react'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import {
|
|
SettingsGroup,
|
|
SettingsRow,
|
|
SettingsRowEnd,
|
|
SettingsRowNote,
|
|
} from '@/components/settings/SettingsRows'
|
|
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
|
import { GoogleMark, MicrosoftMark } from '@/components/ui/provider-marks'
|
|
import { formatDateLong } from '@/lib/utils'
|
|
|
|
interface MailConnection {
|
|
id: string
|
|
provider: 'gmail' | 'microsoft'
|
|
emailAddress: string
|
|
scopeLabel: string | null
|
|
status: 'active' | 'needs_reconsent' | 'revoked'
|
|
lastSearchedAt: string | null
|
|
lastErrorCode: string | null
|
|
}
|
|
|
|
import { useReceiptHunt } from '@/components/extensions/general/use-receipt-hunt'
|
|
|
|
const BASE = '/api/extensions/ext/mail'
|
|
|
|
export function MailConnectionsPanel() {
|
|
const t = useTranslations('mail')
|
|
const [connections, setConnections] = useState<MailConnection[]>([])
|
|
const [configured, setConfigured] = useState(true)
|
|
const [loading, setLoading] = useState(true)
|
|
const [connecting, setConnecting] = useState(false)
|
|
const [pendingDisconnect, setPendingDisconnect] = useState<MailConnection | null>(null)
|
|
const { hunt, stop: stopHunt, hunting, progress, result: huntResult } = useReceiptHunt(() => void load())
|
|
// Read inside the loop, so pressing Stop takes effect on the current pass
|
|
// rather than after every remaining pass has run.
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const response = await fetch(`${BASE}/connections`)
|
|
if (!response.ok) return
|
|
const body = (await response.json()) as {
|
|
data: { connections: MailConnection[]; configured: boolean }
|
|
}
|
|
setConnections(body.data.connections)
|
|
setConfigured(body.data.configured)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
void load()
|
|
}, [load])
|
|
|
|
async function connect() {
|
|
setConnecting(true)
|
|
try {
|
|
// The consent screen must open from the user's own gesture, so the tab is
|
|
// opened first and its location set once the URL is known: opening it
|
|
// after the await is what popup blockers stop.
|
|
const tab = window.open('', '_blank')
|
|
const response = await fetch(`${BASE}/oauth/start`, { method: 'POST' })
|
|
if (!response.ok) {
|
|
tab?.close()
|
|
return
|
|
}
|
|
const body = (await response.json()) as { url: string }
|
|
if (tab) tab.location.href = body.url
|
|
else window.location.href = body.url
|
|
} finally {
|
|
setConnecting(false)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* One bounded pass, on request.
|
|
*
|
|
* Deliberately not a background job: a sweep of a real mailbox runs longer
|
|
* than a serverless function may live, so the honest shape is a pass that
|
|
* ends, says what it found and what is left, and can be pressed again.
|
|
*/
|
|
/**
|
|
* Keep asking until the mailboxes stop yielding.
|
|
*
|
|
* Each request is a bounded pass, because fetching a receipt means
|
|
* downloading it and having a model read the PDF, which is far too slow to
|
|
* finish a backlog inside one serverless invocation. The loop lives here
|
|
* rather than in a queue drained by cron: the finest schedule this app runs
|
|
* is hourly, so a queue would mean pressing a button and waiting an hour.
|
|
*
|
|
* It stops when a pass finds nothing new, which is the honest signal that
|
|
* the mailboxes hold nothing more for the purchases still open. The cap is a
|
|
* backstop against a pass that keeps reporting work it cannot finish.
|
|
*/
|
|
|
|
async function disconnect(connection: MailConnection) {
|
|
await fetch(`${BASE}/connections?id=${encodeURIComponent(connection.id)}`, { method: 'DELETE' })
|
|
setPendingDisconnect(null)
|
|
void load()
|
|
}
|
|
|
|
if (loading) return null
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<SettingsGroup label={t('connected')} help={t('help')}>
|
|
{connections.length === 0 ? (
|
|
<SettingsRow label={t('none_label')} borderless>
|
|
<SettingsRowNote>{t('none')}</SettingsRowNote>
|
|
</SettingsRow>
|
|
) : (
|
|
connections.map((connection) => (
|
|
<SettingsRow
|
|
key={connection.id}
|
|
label={
|
|
<span className="flex items-center gap-2">
|
|
{connection.provider === 'gmail' ? (
|
|
<GoogleMark className="h-3.5 w-3.5" />
|
|
) : (
|
|
<MicrosoftMark className="h-3.5 w-3.5" />
|
|
)}
|
|
{connection.provider === 'gmail' ? 'Gmail' : 'Microsoft 365'}
|
|
</span>
|
|
}
|
|
>
|
|
<span className="min-w-0 flex-1 truncate">{connection.emailAddress}</span>
|
|
{connection.status === 'needs_reconsent' ? (
|
|
<Badge variant="warning">{t('needs_reconsent')}</Badge>
|
|
) : null}
|
|
<SettingsRowEnd>
|
|
{connection.lastSearchedAt ? (
|
|
<SettingsRowNote>
|
|
{t('last_searched', { date: formatDateLong(connection.lastSearchedAt, 'sv') })}
|
|
</SettingsRowNote>
|
|
) : null}
|
|
<Button variant="ghost" size="sm" onClick={() => setPendingDisconnect(connection)}>
|
|
{t('disconnect')}
|
|
</Button>
|
|
</SettingsRowEnd>
|
|
</SettingsRow>
|
|
))
|
|
)}
|
|
</SettingsGroup>
|
|
|
|
{connections.length > 0 ? (
|
|
<SettingsGroup label={t('hunt_title')} help={t('hunt_help')}>
|
|
<SettingsRow label={t('hunt_row')} borderless>
|
|
<SettingsRowEnd>
|
|
{huntResult ? (
|
|
<SettingsRowNote>
|
|
{huntResult.failed
|
|
? t('hunt_failed')
|
|
: huntResult.fetched > 0
|
|
? t('hunt_found', { count: huntResult.fetched, left: huntResult.remaining })
|
|
: t('hunt_none', { left: huntResult.remaining })}
|
|
</SettingsRowNote>
|
|
) : null}
|
|
{hunting ? (
|
|
<Button variant="ghost" size="sm" onClick={stopHunt}>
|
|
{t('hunt_stop')}
|
|
</Button>
|
|
) : null}
|
|
<Button variant="secondary" size="sm" onClick={hunt} disabled={hunting}>
|
|
{hunting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
|
{hunting
|
|
? progress
|
|
? t('hunt_progress', { fetched: progress.fetched })
|
|
: t('hunt_running')
|
|
: t('hunt_action')}
|
|
</Button>
|
|
</SettingsRowEnd>
|
|
</SettingsRow>
|
|
</SettingsGroup>
|
|
) : null}
|
|
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<Button onClick={connect} disabled={connecting || !configured}>
|
|
{connecting ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<GoogleMark className="mr-2 h-4 w-4" />
|
|
)}
|
|
{t('connect')}
|
|
</Button>
|
|
{!configured ? <SettingsRowNote>{t('not_configured')}</SettingsRowNote> : null}
|
|
</div>
|
|
|
|
<p className="max-w-[62ch] text-xs text-muted-foreground">{t('promise')}</p>
|
|
|
|
<ConfirmDialog
|
|
open={pendingDisconnect !== null}
|
|
onOpenChange={(open) => !open && setPendingDisconnect(null)}
|
|
title={t('disconnect_title')}
|
|
description={t('disconnect_body', { address: pendingDisconnect?.emailAddress ?? '' })}
|
|
confirmLabel={t('disconnect')}
|
|
onConfirm={async () => {
|
|
if (pendingDisconnect) await disconnect(pendingDisconnect)
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|