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')}
|
||||
|
||||
Reference in New Issue
Block a user