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
@@ -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()}`)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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