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:
co-authored by
Claude Fable 5
parent
11126d6d56
commit
86071334cb
@@ -36,10 +36,10 @@ type Entry = {
|
||||
}
|
||||
|
||||
const ACTION_ENTRIES: Entry[] = [
|
||||
{ id: 'new-invoice', label: 'Ny faktura', hint: 'Skapa & skicka faktura', icon: ReceiptText, href: '/invoices/new', keywords: 'fakturera ny invoice send create' },
|
||||
{ id: 'new-invoice', label: 'Ny faktura', hint: 'Skapa & skicka faktura', icon: ReceiptText, href: '/invoices?new=1', keywords: 'fakturera ny invoice send create' },
|
||||
{ id: 'book-transaction', label: 'Boka transaktion', hint: 'Gå till transaktionsinkorgen', icon: ArrowLeftRight, href: '/transactions', keywords: 'transaktion bokför kategorisera categorize' },
|
||||
{ id: 'new-customer', label: 'Lägg till kund', icon: Users, href: '/customers', keywords: 'kund customer ny lägg till' },
|
||||
{ id: 'new-supplier-invoice', label: 'Skapa leverantörsfaktura', icon: Wallet, href: '/supplier-invoices/new', keywords: 'leverantörsfaktura supplier invoice ny' },
|
||||
{ id: 'new-supplier-invoice', label: 'Skapa leverantörsfaktura', icon: Wallet, href: '/supplier-invoices?new=1', keywords: 'leverantörsfaktura supplier invoice ny' },
|
||||
{ id: 'reports', label: 'Visa resultaträkning', hint: 'Rapporter', icon: BarChart3, href: '/reports', keywords: 'rapport resultat balans report' },
|
||||
]
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import type { InvoiceExtractionResult } from '@/types'
|
||||
import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog'
|
||||
import NewSupplierInvoiceDialog from '@/components/supplier-invoices/NewSupplierInvoiceDialog'
|
||||
import BulkBookInboxDialog from '@/components/extensions/general/BulkBookInboxDialog'
|
||||
import TransactionMatchPicker from '@/components/inbox/TransactionMatchPicker'
|
||||
import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
|
||||
@@ -240,6 +241,10 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
// Match-to-bank-transaction picker (opens when user clicks "Matcha mot
|
||||
// transaktion" on an unmatched inbox item).
|
||||
const [matchPickerOpen, setMatchPickerOpen] = useState(false)
|
||||
// "Skapa leverantörsfaktura" modal for the selected underlag — opens in
|
||||
// place (instead of navigating to a form page) so the user lands right back
|
||||
// here to pick the next document.
|
||||
const [createSupplierInvoiceOpen, setCreateSupplierInvoiceOpen] = 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.
|
||||
@@ -1002,6 +1007,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
accountingMethod={accountingMethod}
|
||||
onDelete={() => handleDelete(selected.id)}
|
||||
onBookDirect={() => setBookDirectOpen(true)}
|
||||
onCreateSupplierInvoice={() => setCreateSupplierInvoiceOpen(true)}
|
||||
onMatchTransaction={() => setMatchPickerOpen(true)}
|
||||
onUnmatchTransaction={async () => {
|
||||
const targetId = selected.id
|
||||
@@ -1073,6 +1079,19 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{selected && (
|
||||
<NewSupplierInvoiceDialog
|
||||
open={createSupplierInvoiceOpen}
|
||||
onOpenChange={setCreateSupplierInvoiceOpen}
|
||||
inboxItemId={selected.id}
|
||||
onCreated={async () => {
|
||||
// Stay in the inbox (the whole point of the modal): close, then
|
||||
// refresh the list + the selected item so it shows as converted.
|
||||
setCreateSupplierInvoiceOpen(false)
|
||||
await Promise.all([fetchItems(), handleSelect(selected.id)])
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<BulkBookInboxDialog
|
||||
open={bulkBookOpen}
|
||||
onOpenChange={setBulkBookOpen}
|
||||
@@ -1492,6 +1511,7 @@ function FieldsRail({
|
||||
accountingMethod,
|
||||
onDelete,
|
||||
onBookDirect,
|
||||
onCreateSupplierInvoice,
|
||||
onMatchTransaction,
|
||||
onUnmatchTransaction,
|
||||
onAskAssistant,
|
||||
@@ -1503,6 +1523,7 @@ function FieldsRail({
|
||||
accountingMethod: AccountingMethod
|
||||
onDelete: () => void
|
||||
onBookDirect: () => void
|
||||
onCreateSupplierInvoice: () => void
|
||||
onMatchTransaction: () => void
|
||||
onUnmatchTransaction: () => Promise<void>
|
||||
onAskAssistant?: (transactionId: string) => void
|
||||
@@ -1766,16 +1787,14 @@ function FieldsRail({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/supplier-invoices/new?inbox_item_id=${item.id}`}
|
||||
className="flex flex-col items-start gap-1"
|
||||
>
|
||||
<span>Skapa leverantörsfaktura</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
För leverantörsskulder du vill följa (periodisering).
|
||||
</span>
|
||||
</Link>
|
||||
<DropdownMenuItem
|
||||
onClick={onCreateSupplierInvoice}
|
||||
className="flex flex-col items-start gap-1"
|
||||
>
|
||||
<span>Skapa leverantörsfaktura</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
För leverantörsskulder du vill följa (periodisering).
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={onBookDirect}
|
||||
|
||||
@@ -67,9 +67,13 @@ export type InvoiceForEdit = Invoice & { items: InvoiceItem[] }
|
||||
// `create` is the original "new invoice" flow (unchanged). `edit` pre-fills the
|
||||
// form from an existing DRAFT and saves via PATCH instead of POST — no review
|
||||
// dialog, no number allocation, no self-billed tab, no send/logo prompts.
|
||||
export type InvoiceEditorProps =
|
||||
// `bare` renders the editor without page chrome (back button, full-size
|
||||
// heading, fixed mobile action bar) so it drops into NewInvoiceDialog — the
|
||||
// same convention as JournalEntryForm's `bare`.
|
||||
export type InvoiceEditorProps = (
|
||||
| { mode?: 'create' }
|
||||
| { mode: 'edit'; initial: InvoiceForEdit }
|
||||
) & { bare?: boolean }
|
||||
|
||||
// Subset of Article fields the line picker needs to pre-fill a row.
|
||||
type ArticleOption = Pick<
|
||||
@@ -85,6 +89,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
// Edit mode pre-fills the form from an existing draft and saves via PATCH.
|
||||
const isEditMode = props.mode === 'edit'
|
||||
const initial = props.mode === 'edit' ? props.initial : null
|
||||
const bare = props.bare === true
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const { canWrite } = useCanWrite()
|
||||
@@ -1105,22 +1110,29 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
? t('subtitle_delivery_note')
|
||||
: t('subtitle_invoice')
|
||||
|
||||
// In bare (dialog) mode the dialog owns the accessible title (sr-only
|
||||
// DialogTitle) and the page already has its own h1, so the visible heading
|
||||
// steps down to h2 — it still tracks document type and number preview live.
|
||||
const Heading = bare ? 'h2' : 'h1'
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className={bare ? 'space-y-6' : 'space-y-8'}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label={t('back')}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
{!bare && (
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label={t('back')}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="font-display text-2xl md:text-3xl tracking-tight">
|
||||
<Heading className={bare ? 'font-display text-xl tracking-tight' : 'font-display text-2xl md:text-3xl tracking-tight'}>
|
||||
{titleText}
|
||||
{numberPreview && !isSelfBilled && (
|
||||
<span className="ml-2 text-muted-foreground tabular-nums text-xl md:text-2xl">
|
||||
<span className={bare ? 'ml-2 text-muted-foreground tabular-nums text-lg' : 'ml-2 text-muted-foreground tabular-nums text-xl md:text-2xl'}>
|
||||
({numberPreview})
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">{subtitleText}</p>
|
||||
</Heading>
|
||||
{!bare && <p className="text-muted-foreground">{subtitleText}</p>}
|
||||
</div>
|
||||
<AgentSparkleButton
|
||||
intentId="invoice.draft"
|
||||
@@ -1148,7 +1160,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 pb-28 md:pb-0">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className={bare ? 'space-y-6' : 'space-y-6 pb-28 md:pb-0'}>
|
||||
<div className="grid gap-6 lg:grid-cols-3 lg:items-start">
|
||||
{/* Main content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
@@ -1992,8 +2004,10 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions — desktop/tablet only */}
|
||||
<div className="hidden md:flex md:flex-col md:gap-2">
|
||||
{/* Actions — desktop/tablet only. In bare (dialog) mode the fixed
|
||||
mobile bar is unusable (DialogContent's transform re-anchors
|
||||
`fixed` children), so these buttons show at every width. */}
|
||||
<div className={bare ? 'flex flex-col gap-2' : 'hidden md:flex md:flex-col md:gap-2'}>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
@@ -2023,7 +2037,8 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile sticky total bar */}
|
||||
{/* Mobile sticky total bar — page mode only (see bare note above) */}
|
||||
{!bare && (
|
||||
<div className="md:hidden fixed left-0 right-0 z-40 bg-card/98 backdrop-blur-sm border-t border-border/40 px-5 py-3" style={{ bottom: 'calc(4rem + env(safe-area-inset-bottom, 0px))' }}>
|
||||
<div className="max-w-5xl mx-auto flex items-center justify-between gap-4">
|
||||
<div>
|
||||
@@ -2057,6 +2072,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{selectedCustomer && vatRules && (
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
|
||||
import InvoiceEditor from '@/components/invoices/InvoiceEditor'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* "Ny faktura" as a modal — mirrors NewJournalEntryDialog. Wraps the bare
|
||||
* InvoiceEditor; the editor's own review/confirm/send dialogs stack on top of
|
||||
* this one, and every successful create navigates to the invoice detail page
|
||||
* (unmounting the host list page and this dialog with it).
|
||||
*
|
||||
* The accessible title is visually hidden: the bare editor renders its own
|
||||
* live heading, which tracks document type (faktura/proforma/följesedel) and
|
||||
* shows the invoice-number preview — a static DialogTitle would duplicate or
|
||||
* contradict it.
|
||||
*/
|
||||
export default function NewInvoiceDialog({ open, onOpenChange }: Props) {
|
||||
const t = useTranslations('invoice_editor')
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="sm:max-w-5xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto"
|
||||
// A half-typed invoice must survive an accidental backdrop click or a
|
||||
// stray Escape (nested comboboxes and date pickers portal outside the
|
||||
// dialog). Closing is explicit — the header X. Same convention as
|
||||
// NewJournalEntryDialog.
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogTitle className="sr-only">{t('title_invoice')}</DialogTitle>
|
||||
<InvoiceEditor mode="create" bare />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import NewSupplierInvoiceForm from '@/components/supplier-invoices/NewSupplierInvoiceForm'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** Invoice-inbox item to convert; prefills the form from its AI extraction. */
|
||||
inboxItemId?: string | null
|
||||
/**
|
||||
* Fired after a successful create. Hosts close the dialog and either
|
||||
* navigate to the invoice detail (id given) or refresh their list in place.
|
||||
*/
|
||||
onCreated: (invoiceId?: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* "Registrera leverantörsfaktura" as a modal — mirrors NewJournalEntryDialog.
|
||||
* Wraps the bare NewSupplierInvoiceForm; the form's own review/confirm,
|
||||
* supplier-create, bank-picker, and conflict dialogs stack on top of this one.
|
||||
*/
|
||||
export default function NewSupplierInvoiceDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
inboxItemId,
|
||||
onCreated,
|
||||
}: Props) {
|
||||
const t = useTranslations('supplier_invoice_editor')
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="sm:max-w-4xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto"
|
||||
// A half-typed invoice must survive an accidental backdrop click or a
|
||||
// stray Escape (nested comboboxes and date pickers portal outside the
|
||||
// dialog). Closing is explicit — the header X or Avbryt. Same
|
||||
// convention as NewJournalEntryDialog.
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('page_title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<NewSupplierInvoiceForm
|
||||
key={inboxItemId ?? 'fresh'}
|
||||
bare
|
||||
inboxItemId={inboxItemId}
|
||||
onCreated={onCreated}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -99,7 +99,7 @@ export function EmptyState({
|
||||
|
||||
// Preset empty states for common pages
|
||||
|
||||
export function EmptyInvoices() {
|
||||
export function EmptyInvoices({ onAction }: { onAction?: () => void } = {}) {
|
||||
const t = useTranslations('empty')
|
||||
return (
|
||||
<EmptyState
|
||||
@@ -107,7 +107,8 @@ export function EmptyInvoices() {
|
||||
title={t('preset_invoices_title')}
|
||||
description={t('preset_invoices_description')}
|
||||
actionLabel={t('preset_invoices_action')}
|
||||
actionHref="/invoices/new"
|
||||
actionHref={onAction ? undefined : '/invoices?new=1'}
|
||||
onAction={onAction}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -186,7 +187,7 @@ export function EmptyReports() {
|
||||
title={t('preset_reports_title')}
|
||||
description={t('preset_reports_description')}
|
||||
actionLabel={t('preset_reports_action')}
|
||||
actionHref="/invoices/new"
|
||||
actionHref="/invoices?new=1"
|
||||
secondaryActionLabel={t('preset_reports_secondary')}
|
||||
secondaryActionHref="/import"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user