feat(invoices): create customer & supplier invoices in modals, matching the verifikat pattern (#861)

Invoice and supplier-invoice creation now open as pop-up dialogs on their
list pages instead of navigating to standalone form pages — the same UX as
NewJournalEntryDialog (capped-height scroll, explicit-close-only so a
half-typed invoice survives stray Escape/backdrop clicks).

- InvoiceEditor gains a `bare` variant (page chrome stripped, inline actions
  replacing the fixed mobile bar, live document-type title kept) hosted by
  the new NewInvoiceDialog (sm:max-w-5xl). Draft editing pages unchanged.
- The 2,057-line supplier form moves out of the route page into
  components/supplier-invoices/NewSupplierInvoiceForm.tsx with
  bare/inboxItemId/onCreated/onCancel props, hosted by
  NewSupplierInvoiceDialog (sm:max-w-4xl).
- Modals are URL-driven (?new=1): header buttons, empty states, command
  palette, and the reports CTA all open the same dialog; browser back
  closes it. /invoices/new and /supplier-invoices/new survive as redirects
  (bookmarks, agent intents, /expenses/new alias, inbox deep links).
- The invoice-inbox "Skapa leverantörsfaktura" action opens the modal in
  place and refreshes the inbox on success instead of navigating away.

No new i18n keys; dialog titles reuse existing strings.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-02 14:04:52 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 11126d6d56
commit 86071334cb
13 changed files with 2370 additions and 2103 deletions
+4 -2
View File
@@ -17,6 +17,8 @@ export default async function NewExpenseRedirectPage({
qs.set(key, value)
}
}
const suffix = qs.toString()
redirect(`/supplier-invoices/new${suffix ? `?${suffix}` : ''}`)
// Supplier invoice registration lives in a modal on the list page now
// (?new=1) — go there directly instead of bouncing via /supplier-invoices/new.
qs.set('new', '1')
redirect(`/supplier-invoices?${qs.toString()}`)
}
+6 -5
View File
@@ -1,8 +1,9 @@
import InvoiceEditor from '@/components/invoices/InvoiceEditor'
import { redirect } from 'next/navigation'
// The invoice creator lives in the shared <InvoiceEditor> component so the same
// form powers both creating a new invoice and editing an existing draft
// (app/(dashboard)/invoices/[id]/edit).
// Invoice creation now happens in a modal on the invoice list (issue: match
// the verifikat pattern). This route survives as a redirect so old links,
// bookmarks, and agent intents keep working. Editing drafts still has a full
// page at /invoices/[id]/edit.
export default function NewInvoicePage() {
return <InvoiceEditor mode="create" />
redirect('/invoices?new=1')
}
+25 -7
View File
@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
@@ -26,6 +27,7 @@ import { invoiceDisplayNumber } from '@/lib/invoices/display'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { Plus, Search, ReceiptText, Lock, Repeat } from 'lucide-react'
import { EmptyInvoices } from '@/components/ui/empty-state'
import NewInvoiceDialog from '@/components/invoices/NewInvoiceDialog'
import { useCompany } from '@/contexts/CompanyContext'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { Invoice, InvoiceStatus } from '@/types'
@@ -69,6 +71,8 @@ function useRelativeTimeLabel() {
export default function InvoicesPage() {
const { company } = useCompany()
const { canWrite } = useCanWrite()
const router = useRouter()
const searchParams = useSearchParams()
const [invoices, setInvoices] = useState<Invoice[]>([])
const [oreRounding, setOreRounding] = useState<boolean>(true)
const [isLoading, setIsLoading] = useState(true)
@@ -79,6 +83,15 @@ export default function InvoicesPage() {
const t = useTranslations('invoices')
const getRelativeTimeLabel = useRelativeTimeLabel()
// The "Ny faktura" modal is driven by the URL (?new=1) so every entry point
// — the header button, empty states, the command palette, and the legacy
// /invoices/new redirect — opens the same dialog, and the browser back
// button closes it. No canWrite gate here: like the old /invoices/new page,
// the editor itself disables submission for viewers.
const showNewInvoice = searchParams.has('new')
const closeNewInvoice = () => router.replace('/invoices', { scroll: false })
const openNewInvoice = () => router.push('/invoices?new=1', { scroll: false })
async function fetchInvoices() {
if (!company) return
setIsLoading(true)
@@ -160,12 +173,10 @@ export default function InvoicesPage() {
</Button>
</Link>
{canWrite ? (
<Link href="/invoices/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
{t('new_invoice')}
</Button>
</Link>
<Button onClick={openNewInvoice}>
<Plus className="mr-2 h-4 w-4" />
{t('new_invoice')}
</Button>
) : (
<Button
disabled
@@ -261,7 +272,7 @@ export default function InvoicesPage() {
description={t('no_search_results_description', { term: searchTerm })}
/>
) : invoices.length === 0 ? (
<EmptyInvoices />
<EmptyInvoices onAction={openNewInvoice} />
) : (
<DataListEmpty
icon={<ReceiptText className="h-6 w-6" />}
@@ -381,6 +392,13 @@ export default function InvoicesPage() {
})
)}
</DataList>
<NewInvoiceDialog
open={showNewInvoice}
onOpenChange={(open) => {
if (!open) closeNewInvoice()
}}
/>
</div>
)
}
File diff suppressed because it is too large Load Diff
+45 -9
View File
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from '@/components/ui/badge'
@@ -11,6 +12,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
import { Plus, FileInput, Lock } from 'lucide-react'
import Link from 'next/link'
import { PageHeader } from '@/components/ui/page-header'
import NewSupplierInvoiceDialog from '@/components/supplier-invoices/NewSupplierInvoiceDialog'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { formatDate } from '@/lib/utils'
import type { SupplierInvoice } from '@/types'
@@ -44,10 +46,22 @@ const STATUS_LABEL_KEYS: Record<string, string> = {
export default function SupplierInvoicesPage() {
const t = useTranslations('supplier_invoices')
const { canWrite } = useCanWrite()
const router = useRouter()
const searchParams = useSearchParams()
const [invoices, setInvoices] = useState<(SupplierInvoice & { supplier?: { id: string; name: string } })[]>([])
const [isLoading, setIsLoading] = useState(true)
const [activeTab, setActiveTab] = useState('all')
// The "Registrera leverantörsfaktura" modal is driven by the URL (?new=1,
// optionally with inbox_item_id for the invoice-inbox conversion flow) so
// every entry point — the header button, the empty state, the command
// palette, and the legacy /supplier-invoices/new redirect — opens the same
// dialog, and the browser back button closes it.
const showNewInvoice = searchParams.has('new')
const inboxItemId = searchParams.get('inbox_item_id')
const closeNewInvoice = () => router.replace('/supplier-invoices', { scroll: false })
const openNewInvoice = () => router.push('/supplier-invoices?new=1', { scroll: false })
async function fetchInvoices() {
setIsLoading(true)
const res = await fetch('/api/supplier-invoices?status=all')
@@ -60,6 +74,23 @@ export default function SupplierInvoicesPage() {
fetchInvoices()
}, [])
// Mirrors the old standalone page's post-create navigation: inbox
// conversions land back in the inbox, a created invoice opens its detail
// page, and flows that end here (e.g. private expense) close the modal and
// refresh the list in place.
const handleCreated = (invoiceId?: string) => {
if (inboxItemId) {
router.push('/e/general/invoice-inbox')
return
}
if (invoiceId) {
router.push(`/supplier-invoices/${invoiceId}`)
return
}
closeNewInvoice()
fetchInvoices()
}
const filteredInvoices = invoices.filter((inv) => {
switch (activeTab) {
case 'registered': return inv.status === 'registered'
@@ -76,12 +107,10 @@ export default function SupplierInvoicesPage() {
title={t('title')}
action={
canWrite ? (
<Link href="/supplier-invoices/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
{t('register_invoice')}
</Button>
</Link>
<Button onClick={openNewInvoice}>
<Plus className="mr-2 h-4 w-4" />
{t('register_invoice')}
</Button>
) : (
<Button
disabled
@@ -134,9 +163,7 @@ export default function SupplierInvoicesPage() {
}
action={
activeTab === 'all' && canWrite ? (
<Button asChild>
<Link href="/supplier-invoices/new">{t('register_invoice')}</Link>
</Button>
<Button onClick={openNewInvoice}>{t('register_invoice')}</Button>
) : undefined
}
/>
@@ -185,6 +212,15 @@ export default function SupplierInvoicesPage() {
</DataList>
</TabsContent>
</Tabs>
<NewSupplierInvoiceDialog
open={showNewInvoice}
onOpenChange={(open) => {
if (!open) closeNewInvoice()
}}
inboxItemId={inboxItemId}
onCreated={handleCreated}
/>
</div>
)
}
+1 -1
View File
@@ -244,7 +244,7 @@ export default function SupplierDetailPage() {
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg">{t('invoices_section_title')}</CardTitle>
<Link href="/supplier-invoices/new">
<Link href="/supplier-invoices?new=1">
<Button size="sm">
<FileText className="mr-2 h-4 w-4" />
{t('new_invoice')}