feat: invoice inbox extension — conversion, workspace UI, Gmail UX (#255)
* feat: invoice inbox extension — conversion, workspace UI, Gmail UX Complete the invoice-inbox extension with full end-to-end flow: - Add POST /items/:id/convert route to create supplier invoices from classified inbox items, with accrual journal entry and document linking - Add PATCH /items/:id/reject route to dismiss non-relevant items - Add workspace UI at /e/general/invoice-inbox with items table, status filtering, convert dialog, and match confirmation - Add Gmail connection banner (connect/disconnect/status) in workspace - Add one-click supplier creation from AI-extracted data - Add transaction auto-matching with fuzzy name + currency-aware amount - Add event emission (received, extracted, confirmed) on classification - Redirect OAuth callback to workspace instead of /settings/banking - Fix extension catch-all body clone for POST routes with path params - Fix duplicate Löner nav entry from salary module merge - Remove summary cards from expenses and supplier invoices pages - Fix supplier-invoices/new amount input (valueAsNumber → Controller) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address Greptile review — company_id filters, currency guard, skipAuth clone - Add company_id filter to reject route update (defense in depth) - Add company_id filter to document_attachments journal entry link - Guard sekMatch with tx.currency === 'SEK' to prevent false matches - Clone request in skipAuth branch for consistency with auth branch Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
bb0db7a588
commit
4fbfadb2b7
@@ -12,7 +12,7 @@ import { PageHeader } from '@/components/ui/page-header'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { Plus, Search, Wallet, Clock, AlertCircle, Lock } from 'lucide-react'
|
||||
import { Plus, Search, Wallet, Lock } from 'lucide-react'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import type { SupplierInvoice } from '@/types'
|
||||
@@ -105,16 +105,6 @@ export default function ExpensesPage() {
|
||||
return matchesSearch && matchesTab
|
||||
})
|
||||
|
||||
const unpaidInvoices = invoices.filter((i) => UNPAID_STATUSES.includes(i.status))
|
||||
const overdueInvoices = invoices.filter((i) => i.status === 'overdue')
|
||||
|
||||
const stats = {
|
||||
unpaidAmount: unpaidInvoices.reduce((sum, i) => sum + i.remaining_amount, 0),
|
||||
unpaidCount: unpaidInvoices.length,
|
||||
overdueAmount: overdueInvoices.reduce((sum, i) => sum + i.remaining_amount, 0),
|
||||
overdueCount: overdueInvoices.length,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
@@ -140,46 +130,6 @@ export default function ExpensesPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-12 w-12 rounded-lg bg-muted flex items-center justify-center">
|
||||
<Clock className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Att betala</p>
|
||||
<p className="font-display text-2xl font-medium tabular-nums">{formatCurrency(stats.unpaidAmount)}</p>
|
||||
<p className="text-xs text-muted-foreground">{stats.unpaidCount} utgifter</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-12 w-12 rounded-lg bg-muted flex items-center justify-center">
|
||||
<AlertCircle className={`h-6 w-6 ${stats.overdueCount > 0 ? 'text-destructive' : 'text-muted-foreground'}`} />
|
||||
</div>
|
||||
<div>
|
||||
{stats.overdueCount > 0 ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">Förfallet</p>
|
||||
<p className="font-display text-2xl font-medium tabular-nums text-destructive">{formatCurrency(stats.overdueAmount)}</p>
|
||||
<p className="text-xs text-muted-foreground">{stats.overdueCount} utgifter</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">Totalt antal</p>
|
||||
<p className="font-display text-2xl font-medium tabular-nums">{invoices.length}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Search and tabs */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
|
||||
@@ -128,7 +128,8 @@ export default function NewSupplierInvoicePage() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [watchedSupplierId, suppliers])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [watchedSupplierId, suppliers, watch, setValue, fields.length])
|
||||
|
||||
async function fetchSuppliers() {
|
||||
const res = await fetch('/api/suppliers')
|
||||
@@ -421,11 +422,18 @@ export default function NewSupplierInvoicePage() {
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="0,00"
|
||||
{...register(`items.${index}.amount`, { valueAsNumber: true })}
|
||||
<Controller
|
||||
name={`items.${index}.amount`}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="0,00"
|
||||
value={field.value || ''}
|
||||
onChange={(e) => field.onChange(e.target.value === '' ? 0 : parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
@@ -507,11 +515,18 @@ export default function NewSupplierInvoicePage() {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground">Belopp (exkl.)</label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="0,00"
|
||||
{...register(`items.${index}.amount`, { valueAsNumber: true })}
|
||||
<Controller
|
||||
name={`items.${index}.amount`}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="0,00"
|
||||
value={field.value || ''}
|
||||
onChange={(e) => field.onChange(e.target.value === '' ? 0 : parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
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 { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
@@ -62,15 +62,6 @@ export default function SupplierInvoicesPage() {
|
||||
}
|
||||
})
|
||||
|
||||
// Summary stats
|
||||
const totalUnpaid = invoices
|
||||
.filter((i) => !['paid', 'credited'].includes(i.status))
|
||||
.reduce((sum, i) => sum + i.remaining_amount, 0)
|
||||
const overdueAmount = invoices
|
||||
.filter((i) => i.status === 'overdue')
|
||||
.reduce((sum, i) => sum + i.remaining_amount, 0)
|
||||
const overdueCount = invoices.filter((i) => i.status === 'overdue').length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
@@ -98,62 +89,6 @@ export default function SupplierInvoicesPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{isLoading ? (
|
||||
<>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="h-4 bg-muted rounded w-24 animate-pulse" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="h-7 bg-muted rounded w-32 animate-pulse" />
|
||||
<div className="h-3 bg-muted rounded w-16 animate-pulse" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground">
|
||||
Totalt obetalt
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-display text-2xl font-medium tabular-nums">{formatAmount(totalUnpaid)} kr</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{invoices.filter((i) => !['paid', 'credited'].includes(i.status)).length} fakturor
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground">
|
||||
Förfallet
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-display text-2xl font-medium tabular-nums text-destructive">{formatAmount(overdueAmount)} kr</p>
|
||||
<p className="text-xs text-muted-foreground">{overdueCount} fakturor</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground">
|
||||
Antal fakturor
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-display text-2xl font-medium tabular-nums">{invoices.length}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
|
||||
@@ -98,10 +98,11 @@ async function handleRequest(
|
||||
for (const [key, value] of Object.entries(extractedParams)) {
|
||||
url.searchParams.set(`_${key}`, value)
|
||||
}
|
||||
const cloned = request.clone()
|
||||
handlerRequest = new Request(url.toString(), {
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body: request.body,
|
||||
method: cloned.method,
|
||||
headers: cloned.headers,
|
||||
body: cloned.body,
|
||||
// @ts-expect-error -- duplex needed for streaming body
|
||||
duplex: 'half',
|
||||
})
|
||||
@@ -126,10 +127,12 @@ async function handleRequest(
|
||||
for (const [key, value] of Object.entries(extractedParams)) {
|
||||
url.searchParams.set(`_${key}`, value)
|
||||
}
|
||||
// Clone first to avoid body stream locking issues when transferring to new Request
|
||||
const cloned = request.clone()
|
||||
handlerRequest = new Request(url.toString(), {
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body: request.body,
|
||||
method: cloned.method,
|
||||
headers: cloned.headers,
|
||||
body: cloned.body,
|
||||
// @ts-expect-error -- duplex needed for streaming body
|
||||
duplex: 'half',
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user