feat(inbox): show the purchases that have no underlag (#1518)
The page has always listed documents, so a purchase with no document at all could not appear on it. The one thing a user most wants from a document inbox, "what am I still missing", was the one thing it could not answer. A "Saknar underlag" filter now lists them, from the endpoint added alongside. Selecting one shows the purchase and, when the directory knows the supplier, a link to where the invoice lives. We never log in for anyone: knowing where it is costs no password and is most of the value. The pill appears only when there is something behind it. A company that keeps every receipt should not be shown a permanent empty accusation. Purchases live beside `items` rather than inside it. Widening InboxItem to cover a bank row would push a null document, a null extraction and a null status through every consumer of that type, to describe a row that never becomes an inbox item. The middle pane says why there is no file instead of rendering an empty frame, and the two selections clear each other: the panes show one kind of row at a time, so a stale selection from the other kind would outlive its list. 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:
co-authored by
Jakob Wennberg
Claude Opus 5
parent
cd9b540401
commit
7a49aec0c3
@@ -30,6 +30,8 @@ import {
|
||||
ArrowRight,
|
||||
Plus,
|
||||
Link2,
|
||||
ExternalLink,
|
||||
FileQuestion,
|
||||
Search,
|
||||
Circle,
|
||||
X,
|
||||
@@ -249,7 +251,9 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
// Defaults to 'todo': the active inbox (everything not yet booked), so
|
||||
// booked underlag drop out of the default view while attached-but-unbooked
|
||||
// ones stay visible.
|
||||
const [filter, setFilter] = useState<'todo' | 'linked' | 'booked' | 'error' | 'all'>('todo')
|
||||
// 'missing' is the odd one out: it lists bank purchases, not inbox items, so
|
||||
// the list and both panes branch on it.
|
||||
const [filter, setFilter] = useState<'todo' | 'linked' | 'booked' | 'error' | 'all' | 'missing'>('todo')
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
// Bulk selection. Items linked to a supplier invoice are skipped at delete
|
||||
// time (server returns 409); we still allow them to be selected so the
|
||||
@@ -437,6 +441,33 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
|
||||
// ── List filter + search (client-side over the fetched list) ─
|
||||
|
||||
// Purchases with no underlag at all. They are not inbox items and never
|
||||
// become them, so they live beside `items` rather than inside it: widening
|
||||
// InboxItem to cover a bank row would put a null document, a null extraction
|
||||
// and a null status through every consumer of that type.
|
||||
const [purchases, setPurchases] = useState<PurchaseWithoutUnderlag[]>([])
|
||||
const [selectedPurchaseId, setSelectedPurchaseId] = useState<string | null>(null)
|
||||
|
||||
const fetchPurchases = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/extensions/ext/invoice-inbox/purchases')
|
||||
if (!res.ok) return
|
||||
const json = (await res.json()) as { data: { purchases: PurchaseWithoutUnderlag[] } }
|
||||
setPurchases(json.data.purchases ?? [])
|
||||
} catch {
|
||||
// A missing count is better than an error beside the user's documents.
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void fetchPurchases()
|
||||
}, [fetchPurchases])
|
||||
|
||||
const selectedPurchase = useMemo(
|
||||
() => purchases.find((p) => p.id === selectedPurchaseId) ?? null,
|
||||
[purchases, selectedPurchaseId],
|
||||
)
|
||||
|
||||
// Per-status counts for the filter pills. Computed once over the full list.
|
||||
const statusCounts = useMemo(() => {
|
||||
const counts = { todo: 0, linked: 0, booked: 0, error: 0, all: items.length }
|
||||
@@ -458,15 +489,29 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
{ key: 'linked', label: 'Kopplade', count: statusCounts.linked },
|
||||
{ key: 'booked', label: 'Bokförda', count: statusCounts.booked },
|
||||
]
|
||||
// Only worth a pill when there is something behind it: a company that
|
||||
// keeps every receipt should not be shown a permanent empty accusation.
|
||||
if (purchases.length > 0 || filter === 'missing') {
|
||||
list.push({ key: 'missing', label: 'Saknar underlag', count: purchases.length })
|
||||
}
|
||||
if (statusCounts.error > 0 || filter === 'error') {
|
||||
list.push({ key: 'error', label: 'Fel', count: statusCounts.error })
|
||||
}
|
||||
list.push({ key: 'all', label: 'Alla', count: statusCounts.all })
|
||||
return list
|
||||
}, [statusCounts, filter])
|
||||
}, [statusCounts, filter, purchases.length])
|
||||
|
||||
const filteredPurchases = useMemo(() => {
|
||||
const term = searchTerm.trim().toLowerCase()
|
||||
if (term === '') return purchases
|
||||
return purchases.filter((p) =>
|
||||
[p.merchant_name, p.description].some((v) => v?.toLowerCase().includes(term)),
|
||||
)
|
||||
}, [purchases, searchTerm])
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const term = searchTerm.trim().toLowerCase()
|
||||
if (filter === 'missing') return []
|
||||
return items.filter((item) => {
|
||||
// Status filter. "todo" is the active inbox: everything except booked.
|
||||
const status = deriveInboxStatus(item)
|
||||
@@ -540,6 +585,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
|
||||
const handleSelect = useCallback(async (id: string) => {
|
||||
setSelectedId(id)
|
||||
setSelectedPurchaseId(null)
|
||||
setSelected(null)
|
||||
setDocUrl(null)
|
||||
setDocMime(null)
|
||||
@@ -922,7 +968,13 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
<button
|
||||
key={pill.key}
|
||||
type="button"
|
||||
onClick={() => setFilter(pill.key)}
|
||||
onClick={() => {
|
||||
setFilter(pill.key)
|
||||
// The panes show one kind of row at a time; a stale
|
||||
// selection from the other kind would outlive its list.
|
||||
if (pill.key === 'missing') setSelectedId(null)
|
||||
else setSelectedPurchaseId(null)
|
||||
}}
|
||||
className={cn(
|
||||
'text-[11px] px-2 py-0.5 rounded-full border transition-colors',
|
||||
filter === pill.key
|
||||
@@ -1056,25 +1108,39 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
Inkorgen är tom.
|
||||
</div>
|
||||
)
|
||||
) : filteredItems.length === 0 ? (
|
||||
) : (filter === 'missing' ? filteredPurchases.length : filteredItems.length) === 0 ? (
|
||||
<div className="p-6 text-center text-xs text-muted-foreground">
|
||||
{filter === 'todo'
|
||||
? 'Inget att åtgärda; allt är bearbetat.'
|
||||
: 'Inga poster matchar filtret.'}
|
||||
: filter === 'missing'
|
||||
? 'Varje köp har sitt underlag.'
|
||||
: 'Inga poster matchar filtret.'}
|
||||
</div>
|
||||
) : (
|
||||
<ul>
|
||||
{filteredItems.map((item) => (
|
||||
<InboxRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
selected={item.id === selectedId}
|
||||
onClick={() => handleSelect(item.id)}
|
||||
isChecked={selectedIds.has(item.id)}
|
||||
onToggleChecked={() => toggleSelected(item.id)}
|
||||
anyChecked={selectedIds.size > 0}
|
||||
/>
|
||||
))}
|
||||
{filter === 'missing'
|
||||
? filteredPurchases.map((p) => (
|
||||
<PurchaseRow
|
||||
key={p.id}
|
||||
purchase={p}
|
||||
selected={p.id === selectedPurchaseId}
|
||||
onClick={() => {
|
||||
setSelectedPurchaseId(p.id)
|
||||
setSelectedId(null)
|
||||
}}
|
||||
/>
|
||||
))
|
||||
: filteredItems.map((item) => (
|
||||
<InboxRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
selected={item.id === selectedId}
|
||||
onClick={() => handleSelect(item.id)}
|
||||
isChecked={selectedIds.has(item.id)}
|
||||
onToggleChecked={() => toggleSelected(item.id)}
|
||||
anyChecked={selectedIds.size > 0}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</aside>
|
||||
@@ -1089,7 +1155,21 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
!hasAnyItem && 'hidden xl:block'
|
||||
)}
|
||||
>
|
||||
{selected ? (
|
||||
{selectedPurchase ? (
|
||||
// There is no file to show, so the pane says why instead of
|
||||
// rendering an empty frame.
|
||||
<div className="h-full flex items-center justify-center p-8">
|
||||
<div className="text-center max-w-sm">
|
||||
<FileQuestion className="h-6 w-6 mx-auto mb-3 text-muted-foreground opacity-60" />
|
||||
<p className="text-sm">Inget underlag hittat</p>
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
{selectedPurchase.portal
|
||||
? `${selectedPurchase.portal.vendor} skickar ingen fil. Hämta fakturan och ladda upp den här, så kopplas den till köpet.`
|
||||
: 'Vi har sökt i brevlådorna och i portalkatalogen. Ladda upp kvittot här, eller vidarebefordra det till inkorgsadressen.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : selected ? (
|
||||
<DocumentPreview
|
||||
docUrl={docUrl}
|
||||
docMime={docMime}
|
||||
@@ -1136,7 +1216,9 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
!hasAnyItem && 'hidden xl:block'
|
||||
)}
|
||||
>
|
||||
{selected ? (
|
||||
{selectedPurchase ? (
|
||||
<PurchaseRail purchase={selectedPurchase} />
|
||||
) : selected ? (
|
||||
<FieldsRail
|
||||
item={selected}
|
||||
docMime={docMime}
|
||||
@@ -1764,6 +1846,123 @@ function EmptyPreview({
|
||||
)
|
||||
}
|
||||
|
||||
// ── Purchases with no underlag ───────────────────────────────
|
||||
|
||||
/**
|
||||
* A bank purchase that has no document at all.
|
||||
*
|
||||
* These have never been on this page, because the page lists documents and a
|
||||
* purchase without one has nothing to list. They are the other half of the
|
||||
* question the receipt hunt asks, and the half a person can act on: fetch the
|
||||
* invoice from wherever the supplier keeps it, or ask whoever made the purchase.
|
||||
*/
|
||||
export interface PurchaseWithoutUnderlag {
|
||||
id: string
|
||||
date: string
|
||||
description: string | null
|
||||
merchant_name: string | null
|
||||
amount: number
|
||||
currency: string | null
|
||||
amount_sek: number | null
|
||||
portal: { vendor: string; url: string; note: string | null } | null
|
||||
}
|
||||
|
||||
function PurchaseRow({
|
||||
purchase,
|
||||
selected,
|
||||
onClick,
|
||||
}: {
|
||||
purchase: PurchaseWithoutUnderlag
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 border-b transition-colors',
|
||||
selected ? 'bg-secondary' : 'hover:bg-secondary/60',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-[13px] truncate">
|
||||
{purchase.merchant_name || purchase.description || 'Okänt köp'}
|
||||
</span>
|
||||
<span className="text-xs tabular-nums shrink-0">
|
||||
{formatCurrency(Math.abs(purchase.amount), purchase.currency ?? undefined)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-2 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground tabular-nums">{formatDate(purchase.date)}</span>
|
||||
{/* A chip only when the row deviates: here, when we can actually
|
||||
tell the user where to go. */}
|
||||
{purchase.portal && (
|
||||
<Badge variant="outline" className="text-[10px] font-normal">
|
||||
{purchase.portal.vendor}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function PurchaseRail({ purchase }: { purchase: PurchaseWithoutUnderlag }) {
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">
|
||||
{purchase.merchant_name || purchase.description || 'Okänt köp'}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Köp utan underlag</p>
|
||||
</div>
|
||||
|
||||
<dl className="space-y-1.5 text-xs">
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-muted-foreground">Datum</dt>
|
||||
<dd className="tabular-nums">{formatDate(purchase.date)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-muted-foreground">Belopp</dt>
|
||||
<dd className="tabular-nums">
|
||||
{formatCurrency(Math.abs(purchase.amount), purchase.currency ?? undefined)}
|
||||
</dd>
|
||||
</div>
|
||||
{purchase.description && (
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-muted-foreground shrink-0">Kontotext</dt>
|
||||
<dd className="text-muted-foreground text-right break-words">{purchase.description}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{purchase.portal ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{purchase.portal.note ??
|
||||
`${purchase.portal.vendor} skickar ingen fil. Fakturan ligger bakom en inloggning.`}
|
||||
</p>
|
||||
{/* We never log in for anyone. Knowing where the invoice is costs no
|
||||
password and is most of the value. */}
|
||||
<Button size="sm" className="w-full" asChild>
|
||||
<a href={purchase.portal.url} target="_blank" rel="noopener noreferrer">
|
||||
Öppna {purchase.portal.vendor}
|
||||
<ExternalLink className="h-3.5 w-3.5 ml-1.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Vi hittade ingen bilaga och känner inte till någon portal för den här leverantören. Ladda upp
|
||||
kvittot här, eller vidarebefordra det till inkorgsadressen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Proposed kontering ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user