Add/docs skv mcp (#494)
* feat: add "book directly" functionality for invoice inbox items - Extend JournalEntrySourceTypeSchema to include 'inbox_item'. - Introduce BookInboxItemDirectlySchema for direct journal entry creation. - Update InvoiceInboxItem type to include matched_transaction_id and created_journal_entry_id. - Implement BookDirectlyDialog component for user interaction. - Create API route for booking directly from inbox items with appropriate validations. - Add SQL migration to support new journal entry references in the invoice inbox items table. - Implement tests for the new booking functionality and ensure proper error handling. * fix(invoice-inbox): update status handling for resolved inbox items * feat: enforce unique journal entry constraint for invoice inbox items
This commit is contained in:
@@ -38,6 +38,9 @@ import {
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog'
|
||||
|
||||
type AccountingMethod = 'accrual' | 'cash'
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────
|
||||
|
||||
@@ -54,6 +57,7 @@ interface InboxItem {
|
||||
matched_supplier_id: string | null
|
||||
matched_transaction_id: string | null
|
||||
created_supplier_invoice_id: string | null
|
||||
created_journal_entry_id: string | null
|
||||
error_message: string | null
|
||||
// Set client-side only while a manual upload is in flight. Replaced by a
|
||||
// real server-side row once the AI extraction completes.
|
||||
@@ -147,6 +151,11 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
const [isRotating, setIsRotating] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [attachOpen, setAttachOpen] = useState(false)
|
||||
const [bookDirectOpen, setBookDirectOpen] = useState(false)
|
||||
// Cash method users see "Bokför direkt" as the primary CTA; accrual users
|
||||
// see "Skapa leverantörsfaktura". Defaults to 'accrual' until we've read
|
||||
// the company settings so we don't flicker the CTA order on first paint.
|
||||
const [accountingMethod, setAccountingMethod] = useState<AccountingMethod>('accrual')
|
||||
|
||||
// ── Data loading ───────────────────────────────────────────
|
||||
|
||||
@@ -177,6 +186,16 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
useEffect(() => {
|
||||
fetchItems()
|
||||
fetchInboxAddress()
|
||||
// Resolve the company's bookkeeping method — drives CTA hierarchy.
|
||||
fetch('/api/settings')
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((body) => {
|
||||
const method = body?.data?.accounting_method
|
||||
if (method === 'cash' || method === 'accrual') {
|
||||
setAccountingMethod(method)
|
||||
}
|
||||
})
|
||||
.catch(() => { /* keep 'accrual' default */ })
|
||||
}, [fetchItems, fetchInboxAddress])
|
||||
|
||||
// Read the onboarding-dismissed flag from localStorage after mount
|
||||
@@ -207,7 +226,10 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
const hasInboxAddress = !!inboxAddress
|
||||
const hasAnyItem = items.length > 0
|
||||
const hasResolvedItem = items.some(
|
||||
(it) => !!it.created_supplier_invoice_id || !!it.matched_transaction_id
|
||||
(it) =>
|
||||
!!it.created_supplier_invoice_id ||
|
||||
!!it.matched_transaction_id ||
|
||||
!!it.created_journal_entry_id
|
||||
)
|
||||
const showOnboarding =
|
||||
!onboardingDismissed && !(hasInboxAddress && hasAnyItem && hasResolvedItem)
|
||||
@@ -219,7 +241,10 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
return items.filter((item) => {
|
||||
// Status filter
|
||||
const isErr = item.status === 'error'
|
||||
const isDone = !!item.created_supplier_invoice_id || !!item.matched_transaction_id
|
||||
const isDone =
|
||||
!!item.created_supplier_invoice_id ||
|
||||
!!item.matched_transaction_id ||
|
||||
!!item.created_journal_entry_id
|
||||
const needsAction = !isErr && !isDone
|
||||
if (filter === 'error' && !isErr) return false
|
||||
if (filter === 'done' && !isDone) return false
|
||||
@@ -302,6 +327,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
matched_supplier_id: null,
|
||||
matched_transaction_id: null,
|
||||
created_supplier_invoice_id: null,
|
||||
created_journal_entry_id: null,
|
||||
error_message: null,
|
||||
isPlaceholder: true,
|
||||
fileName: file.name,
|
||||
@@ -423,7 +449,9 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
|
||||
// Skip items that the server would 409 on, surface the count to the user.
|
||||
const targets = items.filter((it) => selectedIds.has(it.id))
|
||||
const deletable = targets.filter((it) => !it.created_supplier_invoice_id)
|
||||
const deletable = targets.filter(
|
||||
(it) => !it.created_supplier_invoice_id && !it.created_journal_entry_id
|
||||
)
|
||||
const skipped = targets.length - deletable.length
|
||||
|
||||
setIsBulkDeleting(true)
|
||||
@@ -770,8 +798,10 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
{selected ? (
|
||||
<FieldsRail
|
||||
item={selected}
|
||||
accountingMethod={accountingMethod}
|
||||
onDelete={() => handleDelete(selected.id)}
|
||||
onAttach={() => setAttachOpen(true)}
|
||||
onBookDirect={() => setBookDirectOpen(true)}
|
||||
isDeleting={isDeleting}
|
||||
onRetryRequested={async () => {
|
||||
await Promise.all([fetchItems(), handleSelect(selected.id)])
|
||||
@@ -825,6 +855,16 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{selected && (
|
||||
<BookDirectlyDialog
|
||||
open={bookDirectOpen}
|
||||
onOpenChange={setBookDirectOpen}
|
||||
item={selected}
|
||||
onSuccess={async () => {
|
||||
await Promise.all([fetchItems(), handleSelect(selected.id)])
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1551,15 +1591,19 @@ function EmptyPreview({
|
||||
|
||||
function FieldsRail({
|
||||
item,
|
||||
accountingMethod,
|
||||
onDelete,
|
||||
onAttach,
|
||||
onBookDirect,
|
||||
isDeleting,
|
||||
onFieldsUpdated,
|
||||
onRetryRequested,
|
||||
}: {
|
||||
item: InboxItem
|
||||
accountingMethod: AccountingMethod
|
||||
onDelete: () => void
|
||||
onAttach: () => void
|
||||
onBookDirect: () => void
|
||||
isDeleting: boolean
|
||||
onFieldsUpdated: (data: InvoiceExtractionResult) => void
|
||||
onRetryRequested: () => Promise<void>
|
||||
@@ -1567,8 +1611,10 @@ function FieldsRail({
|
||||
const { toast } = useToast()
|
||||
const data = item.extracted_data
|
||||
const isProcessed = !!item.created_supplier_invoice_id
|
||||
const isLinkedToTransaction = !isProcessed && !!item.matched_transaction_id
|
||||
const isResolved = isProcessed || isLinkedToTransaction
|
||||
const isBookedDirectly = !isProcessed && !!item.created_journal_entry_id
|
||||
const isLinkedToTransaction =
|
||||
!isProcessed && !isBookedDirectly && !!item.matched_transaction_id
|
||||
const isResolved = isProcessed || isBookedDirectly || isLinkedToTransaction
|
||||
const [isRetrying, setIsRetrying] = useState(false)
|
||||
const [isCreatingSupplier, setIsCreatingSupplier] = useState(false)
|
||||
|
||||
@@ -1780,6 +1826,13 @@ function FieldsRail({
|
||||
Öppna leverantörsfaktura
|
||||
</Button>
|
||||
</Link>
|
||||
) : isBookedDirectly && item.created_journal_entry_id ? (
|
||||
<Link href={`/bookkeeping/${item.created_journal_entry_id}`} className="block">
|
||||
<Button variant="default" size="sm" className="w-full">
|
||||
<ArrowRight className="h-3.5 w-3.5 mr-1.5" />
|
||||
Öppna verifikation
|
||||
</Button>
|
||||
</Link>
|
||||
) : isLinkedToTransaction && item.matched_transaction_id ? (
|
||||
<Link href={`/transactions?highlight=${item.matched_transaction_id}`} className="block">
|
||||
<Button variant="default" size="sm" className="w-full">
|
||||
@@ -1787,6 +1840,33 @@ function FieldsRail({
|
||||
Bokför transaktionen
|
||||
</Button>
|
||||
</Link>
|
||||
) : accountingMethod === 'cash' ? (
|
||||
<>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={onBookDirect}
|
||||
>
|
||||
Bokför direkt
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={onAttach}
|
||||
disabled={!item.document_id}
|
||||
title={!item.document_id ? 'Ingen bilaga att koppla' : undefined}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5 mr-1.5" />
|
||||
Koppla till transaktion
|
||||
</Button>
|
||||
<Link href={`/supplier-invoices/new?inbox_item_id=${item.id}`} className="block">
|
||||
<Button variant="ghost" size="sm" className="w-full">
|
||||
Skapa leverantörsfaktura
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
@@ -1805,6 +1885,14 @@ function FieldsRail({
|
||||
Skapa leverantörsfaktura
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={onBookDirect}
|
||||
>
|
||||
Bokför direkt
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
@@ -1816,9 +1904,11 @@ function FieldsRail({
|
||||
title={
|
||||
isProcessed
|
||||
? 'Kopplad till leverantörsfaktura — kan inte tas bort'
|
||||
: isLinkedToTransaction
|
||||
? 'Kopplad till transaktion — koppla loss innan borttagning'
|
||||
: undefined
|
||||
: isBookedDirectly
|
||||
? 'Bokförd — kan inte tas bort'
|
||||
: isLinkedToTransaction
|
||||
? 'Kopplad till transaktion — koppla loss innan borttagning'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isDeleting ? (
|
||||
@@ -1834,6 +1924,12 @@ function FieldsRail({
|
||||
Bearbetad
|
||||
</Badge>
|
||||
)}
|
||||
{isBookedDirectly && (
|
||||
<Badge variant="secondary" className="w-full justify-center text-[10px]">
|
||||
<Check className="h-2.5 w-2.5 mr-1" />
|
||||
Bokförd
|
||||
</Badge>
|
||||
)}
|
||||
{isLinkedToTransaction && (
|
||||
<Badge variant="secondary" className="w-full justify-center text-[10px]">
|
||||
<Link2 className="h-2.5 w-2.5 mr-1" />
|
||||
|
||||
Reference in New Issue
Block a user