From 7175daee874d67058c41dd79c578dc70efbae207 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Fri, 15 May 2026 16:25:05 +0200 Subject: [PATCH] Bug/skv konto numbers (#498) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(skattekonto): add overdue transactions handling and split logic * feat: enhance transaction handling and loading states - Update BalanceHero component to display last synced date and additional information about Skatteverket updates. - Refactor BookDirectlyDialog to simplify transaction linking logic and improve UI for transaction selection. - Revamp InvoiceInboxWorkspace layout for better responsiveness and user experience, including improved skeleton loading states. - Introduce new loading states for ExtensionWorkspace to match the live layout and improve user feedback during data fetching. - Implement exchange rate fetching in QuickReviewDialog, ensuring transactions are always processed in SEK with error handling for rate fetching. - Add structured error handling for unavailable exchange rates in the transaction API. * feat(skattekonto): add 'Skattekonto – saldo & transaktioner' scope and update authorization checks * feat: implement reverse charge handling in supplier invoice calculations and UI --- app/(dashboard)/e/[sector]/[slug]/loading.tsx | 149 +++++ app/(dashboard)/skattekonto/page.tsx | 27 +- .../supplier-invoices/new/page.tsx | 11 +- app/api/supplier-invoices/route.ts | 6 +- .../[id]/refresh-exchange-rate/route.ts | 64 +++ components/bookkeeping/AccountCombobox.tsx | 2 +- .../extensions/general/BookDirectlyDialog.tsx | 270 +++++----- .../general/InvoiceInboxWorkspace.tsx | 510 +++--------------- .../extensions/general/TicWorkspace.tsx | 46 +- .../settings/SkatteverketConnectPanel.tsx | 3 +- components/transactions/QuickReviewDialog.tsx | 155 ++++-- .../__tests__/skattekonto-buckets.test.ts | 102 ++++ extensions/general/skatteverket/index.ts | 7 +- extensions/general/skatteverket/lib/oauth.ts | 2 +- .../skatteverket/lib/skattekonto-buckets.ts | 42 ++ lib/errors/structured-errors.ts | 7 + 16 files changed, 780 insertions(+), 623 deletions(-) create mode 100644 app/(dashboard)/e/[sector]/[slug]/loading.tsx create mode 100644 app/api/transactions/[id]/refresh-exchange-rate/route.ts create mode 100644 extensions/general/skatteverket/__tests__/skattekonto-buckets.test.ts create mode 100644 extensions/general/skatteverket/lib/skattekonto-buckets.ts diff --git a/app/(dashboard)/e/[sector]/[slug]/loading.tsx b/app/(dashboard)/e/[sector]/[slug]/loading.tsx new file mode 100644 index 00000000..c80be459 --- /dev/null +++ b/app/(dashboard)/e/[sector]/[slug]/loading.tsx @@ -0,0 +1,149 @@ +import { headers } from 'next/headers' +import { Skeleton } from '@/components/ui/skeleton' +import { Card, CardHeader, CardContent } from '@/components/ui/card' +import { PageHeader } from '@/components/ui/page-header' +import { getExtensionDefinition } from '@/lib/extensions/sectors' + +// Mirror of FULLSCREEN_WORKSPACES in ExtensionWorkspaceLoader. loading.tsx +// can't read route params, so we inspect the forwarded x-pathname header to +// branch the skeleton shape — the parent dashboard loading.tsx renders a +// metrics dashboard shape that has nothing to do with extension workspaces. +const FULLSCREEN_WORKSPACES = new Set(['general/invoice-inbox']) + +export default async function ExtensionWorkspaceLoading() { + const h = await headers() + const pathname = h.get('x-pathname') ?? '' + const match = pathname.match(/^\/e\/([^/]+)\/([^/]+)/) + const sector = match?.[1] ?? '' + const slug = match?.[2] ?? '' + const key = `${sector}/${slug}` + + if (FULLSCREEN_WORKSPACES.has(key)) { + return + } + + const definition = sector && slug ? getExtensionDefinition(sector, slug) : undefined + return ( +
+ {definition ? ( + + ) : ( + + )} + +
+ ) +} + +function ShellWorkspaceBody({ workspaceKey }: { workspaceKey: string }) { + if (workspaceKey === 'general/tic') { + return + } + return ( +
+ + +
+ ) +} + +function TicSkeleton() { + return ( +
+
+ + + + + + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+ +
+
+ + + + + + +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ + +
+ ))} +
+
+
+
+
+ ) +} + +function FullScreenWorkspaceSkeleton() { + return ( +
+
+
+
+ + + +
+ +
+
+ +
+
+
+
+ ) +} diff --git a/app/(dashboard)/skattekonto/page.tsx b/app/(dashboard)/skattekonto/page.tsx index 72c75a45..1fdf69d0 100644 --- a/app/(dashboard)/skattekonto/page.tsx +++ b/app/(dashboard)/skattekonto/page.tsx @@ -40,6 +40,7 @@ interface SaldoEnvelope { interface TransaktionerEnvelope { data: { booked: SkattekontoTransactionWithSuggestion[] + overdue: StoredSkattekontoTransaction[] upcoming: StoredSkattekontoTransaction[] } } @@ -267,6 +268,9 @@ export default function SkattekontoPage() { Genomförda {tx?.booked ? `(${tx.booked.length})` : ''} + + Förfallna {tx?.overdue ? `(${tx.overdue.length})` : ''} + Kommande {tx?.upcoming ? `(${tx.upcoming.length})` : ''} @@ -280,6 +284,16 @@ export default function SkattekontoPage() { emptyText="Inga genomförda transaktioner än." /> + + +

- Senast uppdaterad + Saldo per

{new Date(data.senastUppdaterad).toLocaleString('sv-SE')} @@ -428,6 +442,17 @@ function BalanceHero({

+ {saldo.lastSyncedAt && ( +

+ Senast synkad{' '} + + {new Date(saldo.lastSyncedAt).toLocaleString('sv-SE')} + + . Skatteverket uppdaterar saldot periodvis — datumet ovan ändras + inte varje gång du synkroniserar. +

+ )} + {data.informationstext.length > 0 && (

diff --git a/app/(dashboard)/supplier-invoices/new/page.tsx b/app/(dashboard)/supplier-invoices/new/page.tsx index 62432c6b..cd48a6b8 100644 --- a/app/(dashboard)/supplier-invoices/new/page.tsx +++ b/app/(dashboard)/supplier-invoices/new/page.tsx @@ -168,6 +168,7 @@ export default function NewSupplierInvoicePage() { const watchedSupplierId = watch('supplier_id') const watchedCurrency = watch('currency') const watchedPaidPrivately = watch('paid_with_private_funds') + const watchedReverseCharge = watch('reverse_charge') const isEF = entityType === 'enskild_firma' @@ -401,7 +402,11 @@ export default function NewSupplierInvoicePage() { }) const subtotal = itemTotals.reduce((sum, t) => sum + t.lineTotal, 0) const totalVat = itemTotals.reduce((sum, t) => sum + t.vatAmount, 0) - const total = Math.round((subtotal + totalVat) * 100) / 100 + // Reverse charge: supplier never invoices VAT, so it doesn't roll into the + // payable total. The VAT is still accounted for via 2614 / 2645 in + // bookkeeping — the line stays in the breakdown for transparency. + const payableVat = watchedReverseCharge ? 0 : totalVat + const total = Math.round((subtotal + payableVat) * 100) / 100 // Show the AI-suggested supplier card when we have an inbox item, the AI // surfaced a supplier name, and we couldn't match it to an existing record. @@ -1251,7 +1256,9 @@ export default function NewSupplierInvoicePage() { {formatCurrency(subtotal, watchedCurrency)}

- Moms + + {watchedReverseCharge ? 'Moms (omvänd, redovisas av köparen)' : 'Moms'} + {formatCurrency(totalVat, watchedCurrency)}
diff --git a/app/api/supplier-invoices/route.ts b/app/api/supplier-invoices/route.ts index f7938e30..ca11d33f 100644 --- a/app/api/supplier-invoices/route.ts +++ b/app/api/supplier-invoices/route.ts @@ -132,7 +132,11 @@ export const POST = withRouteContext( const subtotal = items.reduce((sum, i) => sum + i.line_total, 0) const vatAmount = items.reduce((sum, i) => sum + i.vat_amount, 0) - const total = Math.round((subtotal + vatAmount) * 100) / 100 + // Reverse charge: supplier never invoices VAT, so the payable total equals + // the net. VAT is still tracked separately (vat_amount) for declarations + // and books fiktiv 2614/2645 in the engine, but neither side moves cash. + const payableVat = body.reverse_charge ? 0 : vatAmount + const total = Math.round((subtotal + payableVat) * 100) / 100 // Representation (BAS 6070–6079): ingående moms is only deductible up to // 300 SEK base/person per ML 8 kap. 1 §, and the income-tax deduction was diff --git a/app/api/transactions/[id]/refresh-exchange-rate/route.ts b/app/api/transactions/[id]/refresh-exchange-rate/route.ts new file mode 100644 index 00000000..b685752a --- /dev/null +++ b/app/api/transactions/[id]/refresh-exchange-rate/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { fetchExchangeRate } from '@/lib/currency/riksbanken' +import type { Currency, Transaction } from '@/types' + +export const POST = withRouteContext( + 'transaction.refreshExchangeRate', + async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { supabase, companyId, log, requestId } = ctx + + const { data: transaction, error: fetchError } = await supabase + .from('transactions') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (fetchError || !transaction) { + return errorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', log, { requestId }) + } + + // No-op for SEK transactions, or when the rate is already cached. + if ( + transaction.currency === 'SEK' || + (transaction.amount_sek != null && transaction.exchange_rate != null) + ) { + return NextResponse.json({ data: transaction }) + } + + const rate = await fetchExchangeRate(transaction.currency as Currency, new Date(transaction.date)) + if (!rate) { + return errorResponseFromCode('TX_EXCHANGE_RATE_UNAVAILABLE', log, { + requestId, + details: { currency: transaction.currency, date: transaction.date }, + }) + } + + const amountSek = Math.round(transaction.amount * rate.rate * 100) / 100 + + const { data: updated, error: updateError } = await supabase + .from('transactions') + .update({ + amount_sek: amountSek, + exchange_rate: rate.rate, + exchange_rate_date: rate.date, + }) + .eq('id', id) + .eq('company_id', companyId) + .is('journal_entry_id', null) + .select('*') + .single() + + if (updateError || !updated) { + return errorResponse(updateError ?? new Error('Failed to persist exchange rate'), log, { + requestId, + }) + } + + return NextResponse.json({ data: updated }) + }, + { requireWrite: true }, +) diff --git a/components/bookkeeping/AccountCombobox.tsx b/components/bookkeeping/AccountCombobox.tsx index 296d54d5..4eab866e 100644 --- a/components/bookkeeping/AccountCombobox.tsx +++ b/components/bookkeeping/AccountCombobox.tsx @@ -178,7 +178,7 @@ export default function AccountCombobox({ value, accounts, onChange }: AccountCo onFocus={handleFocus} onBlur={handleBlur} onKeyDown={handleKeyDown} - placeholder="1930" + placeholder="Sök konto…" className="font-mono h-8" autoComplete="off" /> diff --git a/components/extensions/general/BookDirectlyDialog.tsx b/components/extensions/general/BookDirectlyDialog.tsx index 326e4414..4d8f6945 100644 --- a/components/extensions/general/BookDirectlyDialog.tsx +++ b/components/extensions/general/BookDirectlyDialog.tsx @@ -13,12 +13,17 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { Switch } from '@/components/ui/switch' import { Badge } from '@/components/ui/badge' import { useToast } from '@/components/ui/use-toast' import { Loader2, Plus, Trash2, AlertTriangle, Search, Check } from 'lucide-react' import { cn, formatCurrency } from '@/lib/utils' import AccountCombobox from '@/components/bookkeeping/AccountCombobox' +import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog' +import { + useSubmitWithAccountActivation, + throwOnStructuredError, +} from '@/lib/hooks/use-submit-with-account-activation' +import { getErrorMessage } from '@/lib/errors/get-error-message' import type { BASAccount, FiscalPeriod, InvoiceExtractionResult } from '@/types' interface InboxItem { @@ -139,8 +144,7 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess const [notes, setNotes] = useState('') const [lines, setLines] = useState(() => buildPrefillLines(item)) - // Transaction link state - const [linkToTransaction, setLinkToTransaction] = useState(!!item.matched_transaction_id) + // Transaction picker — optional selection. const [selectedTransactionId, setSelectedTransactionId] = useState( item.matched_transaction_id ) @@ -155,7 +159,6 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess if (!open) return setEntryDate(item.extracted_data?.invoice?.invoiceDate || new Date().toISOString().slice(0, 10)) setLines(buildPrefillLines(item)) - setLinkToTransaction(!!item.matched_transaction_id) setSelectedTransactionId(item.matched_transaction_id) const supplier = item.extracted_data?.supplier?.name?.trim() || '' const invoiceNum = item.extracted_data?.invoice?.invoiceNumber?.trim() || '' @@ -168,10 +171,10 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess // the prefilled amounts so foreign-currency invoices follow the SEK // figure on the actual bank movement. const selectedTransactionAmount = useMemo(() => { - if (!linkToTransaction || !selectedTransactionId) return null + if (!selectedTransactionId) return null const tx = transactions.find((t) => t.id === selectedTransactionId) return tx?.amount ?? null - }, [linkToTransaction, selectedTransactionId, transactions]) + }, [selectedTransactionId, transactions]) useEffect(() => { if (!open) return @@ -227,9 +230,10 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess } }, [entryDate, periods, periodId]) - // Fetch unmatched transactions when the link toggle turns on + // Fetch unmatched transactions whenever the dialog opens — the picker + // is always visible now (selection is optional). useEffect(() => { - if (!open || !linkToTransaction) return + if (!open) return let cancelled = false setIsLoadingTransactions(true) const targetAmount = item.extracted_data?.totals?.total ?? null @@ -254,7 +258,7 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess } })() return () => { cancelled = true } - }, [open, linkToTransaction, item.extracted_data?.totals?.total]) + }, [open, item.extracted_data?.totals?.total]) const filteredTransactions = useMemo(() => { const term = txSearch.trim().toLowerCase() @@ -294,45 +298,45 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess if (description.trim().length === 0) return 'Fyll i beskrivning' if (lines.some((l) => l.account_number.trim().length === 0)) return 'Alla rader behöver ett konto' if (!totals.balanced) return 'Debet och kredit måste vara lika' - if (linkToTransaction && !selectedTransactionId) return 'Välj en banktransaktion att koppla till' return null - }, [isSubmitting, entryDate, periodId, description, lines, totals.balanced, linkToTransaction, selectedTransactionId]) + }, [isSubmitting, entryDate, periodId, description, lines, totals.balanced]) const canSubmit = !isSubmitting && disabledReason === null + const postBooking = useCallback(async () => { + const payload = { + fiscal_period_id: periodId, + entry_date: entryDate, + description: description.trim(), + notes: notes.trim() || undefined, + lines: lines.map((l) => ({ + account_number: l.account_number.trim(), + debit_amount: parseFloat(l.debit_amount) || 0, + credit_amount: parseFloat(l.credit_amount) || 0, + })), + transaction_id: selectedTransactionId ?? undefined, + } + const res = await fetch( + `/api/extensions/ext/invoice-inbox/items/${item.id}/book-direct`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + } + ) + return (await throwOnStructuredError(res)) as { + data?: { journal_entry?: { voucher_series: string; voucher_number: number } } + } + }, [periodId, entryDate, description, notes, lines, selectedTransactionId, item.id]) + + const { runSubmit, dialog: activationDialog, confirm: confirmActivation, cancel: cancelActivation } = + useSubmitWithAccountActivation(postBooking) + const handleSubmit = useCallback(async () => { if (!canSubmit) return setIsSubmitting(true) try { - const payload = { - fiscal_period_id: periodId, - entry_date: entryDate, - description: description.trim(), - notes: notes.trim() || undefined, - lines: lines.map((l) => ({ - account_number: l.account_number.trim(), - debit_amount: parseFloat(l.debit_amount) || 0, - credit_amount: parseFloat(l.credit_amount) || 0, - })), - transaction_id: linkToTransaction ? selectedTransactionId ?? undefined : undefined, - } - const res = await fetch( - `/api/extensions/ext/invoice-inbox/items/${item.id}/book-direct`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - } - ) - const json = await res.json().catch(() => ({})) - if (!res.ok) { - toast({ - title: 'Kunde inte bokföra', - description: json.error || 'Försök igen.', - variant: 'destructive', - }) - return - } + const json = await runSubmit() const voucher = json?.data?.journal_entry toast({ title: 'Bokfört', @@ -342,13 +346,24 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess }) await onSuccess() onOpenChange(false) + } catch (err) { + if (err instanceof Error && err.message === 'cancelled') { + // User dismissed the activation dialog — no toast needed + } else { + const anyErr = err as { body?: unknown; status?: number } + toast({ + title: 'Kunde inte bokföra', + description: getErrorMessage(anyErr.body ?? err, { + context: 'journal_entry', + statusCode: anyErr.status, + }), + variant: 'destructive', + }) + } } finally { setIsSubmitting(false) } - }, [ - canSubmit, periodId, entryDate, description, notes, lines, - linkToTransaction, selectedTransactionId, item.id, toast, onSuccess, onOpenChange, - ]) + }, [canSubmit, runSubmit, toast, onSuccess, onOpenChange]) const targetAmount = item.extracted_data?.totals?.total ?? null const targetCurrency = item.extracted_data?.invoice?.currency ?? 'SEK' @@ -417,89 +432,90 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess />
- {/* Transaction link toggle + picker */} + {/* Transaction picker — always shown, selection is optional. */}
-
-
- -

- Slå på om dokumentet motsvarar en redan-bokad bankhändelse. Annars - bokförs det som en fristående verifikation. -

-
- +
+ +

+ Välj en transaktion om dokumentet motsvarar en redan-bokad + bankhändelse — den bokas då samtidigt. Lämna tom för en + fristående verifikation. +

- {linkToTransaction && ( -
-
- - setTxSearch(e.target.value)} - className="pl-10" - disabled={isSubmitting} - /> -
-
- {isLoadingTransactions ? ( -
- Laddar… -
- ) : filteredTransactions.length === 0 ? ( -

- Inga okategoriserade transaktioner. -

- ) : ( -
    - {filteredTransactions.slice(0, 30).map((tx) => { - const isSelected = selectedTransactionId === tx.id - return ( -
  • - -
  • - ) - })} -
- )} -
+
+
+ + setTxSearch(e.target.value)} + className="pl-10" + disabled={isSubmitting} + />
- )} +
+ {isLoadingTransactions ? ( +
+ Laddar… +
+ ) : filteredTransactions.length === 0 ? ( +

+ Inga okategoriserade transaktioner. +

+ ) : ( +
    + {filteredTransactions.slice(0, 30).map((tx) => { + const isSelected = selectedTransactionId === tx.id + return ( +
  • + +
  • + ) + })} +
+ )} +
+ {selectedTransactionId && ( + + )} +
{/* Journal entry lines */} @@ -685,6 +701,12 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess
+ ) } diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index 0c67c878..63d18279 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -7,7 +7,6 @@ import { Checkbox } from '@/components/ui/checkbox' import { Input } from '@/components/ui/input' import { Skeleton } from '@/components/ui/skeleton' import { useToast } from '@/components/ui/use-toast' -import { ToastAction } from '@/components/ui/toast' import { Inbox, Upload, @@ -27,17 +26,9 @@ import { X, } from 'lucide-react' import Link from 'next/link' -import { useRouter } from 'next/navigation' import { cn, formatCurrency } from '@/lib/utils' import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' import type { InvoiceExtractionResult } from '@/types' -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, -} from '@/components/ui/dialog' import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog' type AccountingMethod = 'accrual' | 'cash' @@ -98,15 +89,51 @@ function pickSupplierName(item: InboxItem): string | null { } // ── Skeleton ───────────────────────────────────────────────── +// Mirrors the live layout (top bar + 3-pane card) so the transition from +// the route-level loading.tsx to data-loaded content has no visible reflow. +// Keep in sync with app/(dashboard)/e/[sector]/[slug]/loading.tsx. function WorkspaceSkeleton() { return ( -
- -
- - - +
+
+
+
+ + + +
+ +
+
+ +
+
) @@ -116,16 +143,11 @@ function WorkspaceSkeleton() { export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { const { toast } = useToast() - const router = useRouter() const fileInputRef = useRef(null) const [items, setItems] = useState([]) const [isLoading, setIsLoading] = useState(true) const [selectedId, setSelectedId] = useState(null) - // Phone-only master-detail toggle. On screens ('list') // List filter + search (client-side over the already-fetched items list). const [filter, setFilter] = useState<'all' | 'needs_action' | 'done' | 'error'>('all') const [searchTerm, setSearchTerm] = useState('') @@ -150,7 +172,6 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { const [isDeleting, setIsDeleting] = useState(false) 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 @@ -272,7 +293,10 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { setSelected(null) setDocUrl(null) setDocMime(null) - setMobileView('detail') + // Intentionally no auto-scroll: in the vertical-stack layout (below xl) + // scrolling the preview into view pushes the list off-screen, and the + // user has no obvious way back to pick another item. The row-highlight + // + the preview content update are enough feedback that the tap took. try { const res = await fetch(`/api/extensions/ext/invoice-inbox/items/${id}`) @@ -523,7 +547,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { return (
{ e.preventDefault(); if (!isDragging) setIsDragging(true) }} onDragLeave={(e) => { // only clear when leaving the workspace itself, not children @@ -531,7 +555,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { }} onDrop={handleDrop} > -
+
{/* Top bar */}
@@ -611,16 +635,14 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
- {/* Three-pane body. On phone ( - {/* List */} -