diff --git a/app/(dashboard)/invoices/page.tsx b/app/(dashboard)/invoices/page.tsx index abb80c12..aff1bde7 100644 --- a/app/(dashboard)/invoices/page.tsx +++ b/app/(dashboard)/invoices/page.tsx @@ -9,28 +9,25 @@ import { createClient } from '@/lib/supabase/client' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' -import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { PageHeader } from '@/components/ui/page-header' import { Skeleton } from '@/components/ui/skeleton' import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' -import { - DataList, - DataListRow, - DataListPrimary, - DataListMeta, - DataListMetaSeparator, - DataListEmpty, -} from '@/components/ui/data-list' +import { DataListEmpty } from '@/components/ui/data-list' +import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table' +import { FyPicker } from '@/components/common/FyPicker' +import { ContextPicker } from '@/components/common/ContextPicker' +import { SplitButton, type SplitButtonOption } from '@/components/ui/split-button' +import { useUiState } from '@/lib/hooks/use-ui-state' +import { resolveInitialMode } from '@/lib/ui-state/client' import { useToast } from '@/components/ui/use-toast' import { formatCurrency, formatDate } from '@/lib/utils' import { cn } from '@/lib/utils' import { invoiceDisplayNumber } from '@/lib/invoices/display' import { getDisplayTotal } from '@/lib/invoices/rounding' -import { Plus, Search, ReceiptText, Lock, Repeat } from 'lucide-react' +import { Plus, Search, ReceiptText, Repeat, FileInput } from 'lucide-react' import { EmptyInvoices } from '@/components/ui/empty-state' import { useCompany } from '@/contexts/CompanyContext' import { useCanWrite } from '@/lib/hooks/use-can-write' -import type { Invoice, InvoiceStatus } from '@/types' +import type { FiscalPeriod, Invoice, InvoiceStatus } from '@/types' function NewInvoiceDialogLoading() { const t = useTranslations('invoices') @@ -53,42 +50,33 @@ const NewInvoiceDialog = dynamic( { loading: NewInvoiceDialogLoading }, ) -type InvoiceStatusVariant = 'default' | 'secondary' | 'success' | 'warning' | 'destructive' - const INITIAL_VISIBLE_ROWS = 100 -const STATUS_CONFIG: Record = { - draft: { labelKey: 'status_draft', variant: 'secondary' }, - sent: { labelKey: 'status_sent', variant: 'default' }, - paid: { labelKey: 'status_paid', variant: 'success' }, - partially_paid: { labelKey: 'status_partially_paid', variant: 'warning' }, - overdue: { labelKey: 'status_overdue', variant: 'destructive' }, - cancelled: { labelKey: 'status_cancelled', variant: 'secondary' }, - credited: { labelKey: 'status_credited', variant: 'secondary' }, +const CREATE_MODES = ['faktura', 'aterkommande', 'sjalvfaktura'] as const + +// Main views (concept seg) and the low-frequency views behind "Fler …". +const SEG_TABS = ['all', 'unpaid', 'overdue', 'draft'] as const +const MORE_TABS = ['paid', 'proforma', 'delivery_note', 'credit', 'cancelled'] as const +type ListTab = (typeof SEG_TABS)[number] | (typeof MORE_TABS)[number] + +const TAB_LABEL_KEYS: Record = { + all: 'tab_all', + unpaid: 'tab_unpaid', + overdue: 'tab_overdue', + draft: 'tab_draft', + paid: 'tab_paid', + proforma: 'tab_proforma', + delivery_note: 'tab_delivery_note', + credit: 'tab_credit', + cancelled: 'tab_cancelled', } -function useRelativeTimeLabel() { - const t = useTranslations('invoices') - return function getRelativeTimeLabel(dueDateStr: string, status: InvoiceStatus): { text: string; color: string } | null { - if (status === 'paid' || status === 'cancelled' || status === 'credited' || status === 'draft') return null - - const today = new Date() - today.setHours(0, 0, 0, 0) - const dueDate = new Date(dueDateStr) - dueDate.setHours(0, 0, 0, 0) - const diffDays = Math.round((dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)) - - if (diffDays < 0) { - return { text: t('due_days_overdue', { days: Math.abs(diffDays) }), color: 'text-destructive' } - } else if (diffDays === 0) { - return { text: t('due_today'), color: 'text-warning-foreground' } - } else if (diffDays <= 3) { - return { text: t('due_days_left', { days: diffDays }), color: 'text-warning-foreground' } - } else if (diffDays <= 7) { - return { text: t('due_days_left', { days: diffDays }), color: 'text-muted-foreground' } - } - return null - } +function daysOverdue(dueDateStr: string): number { + const today = new Date() + today.setHours(0, 0, 0, 0) + const dueDate = new Date(dueDateStr) + dueDate.setHours(0, 0, 0, 0) + return Math.round((today.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24)) } export default function InvoicesPage() { @@ -100,23 +88,37 @@ export default function InvoicesPage() { const [oreRounding, setOreRounding] = useState(true) const [isLoading, setIsLoading] = useState(true) const [searchTerm, setSearchTerm] = useState('') - const [activeTab, setActiveTab] = useState('all') + const [activeTab, setActiveTab] = useState(() => { + // Deep links from the worklist and older bookmarks: ?status= / ?tab=. + const param = searchParams.get('status') ?? searchParams.get('tab') + const alias: Record = { drafts: 'draft' } + const candidate = param ? (alias[param] ?? (param as ListTab)) : null + return candidate && [...SEG_TABS, ...MORE_TABS].includes(candidate as never) + ? (candidate as ListTab) + : 'all' + }) const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_ROWS) + // Fiscal-year scope (convention 8): null = all years. + const [fyPeriodId, setFyPeriodId] = useState(null) + const [fyPeriod, setFyPeriod] = useState(null) const { toast } = useToast() const supabase = createClient() const t = useTranslations('invoices') const tCommon = useTranslations('common') - const getRelativeTimeLabel = useRelativeTimeLabel() + const { uiState, loaded: uiStateLoaded } = useUiState() // 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. + // the editor itself disables submission for viewers. ?self=1 preselects the + // självfaktura tab (split-button entry). const copyFromId = searchParams.get('copy') const showNewInvoice = searchParams.has('new') || copyFromId !== null + const openSelfBilled = searchParams.has('self') const closeNewInvoice = () => router.replace('/invoices', { scroll: false }) const openNewInvoice = () => router.push('/invoices?new=1', { scroll: false }) + const openNewSelfBilled = () => router.push('/invoices?new=1&self=1', { scroll: false }) async function fetchInvoices() { if (!company) return @@ -149,6 +151,7 @@ export default function InvoicesPage() { useEffect(() => { fetchInvoices() + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const filteredInvoices = invoices.filter((invoice) => { @@ -157,263 +160,275 @@ export default function InvoicesPage() { (invoice.external_invoice_number ?? '').toLowerCase().includes(searchTerm.toLowerCase()) || (invoice.customer as { name: string })?.name?.toLowerCase().includes(searchTerm.toLowerCase()) + const matchesFy = + !fyPeriod || + (invoice.invoice_date >= fyPeriod.period_start && invoice.invoice_date <= fyPeriod.period_end) + const isCreditNote = !!invoice.credited_invoice_id const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice' const matchesTab = (activeTab === 'all' && invoice.status !== 'cancelled') || (activeTab === 'unpaid' && ['sent', 'overdue'].includes(invoice.status) && !isCreditNote && docType === 'invoice') || + (activeTab === 'overdue' && invoice.status === 'overdue' && !isCreditNote && docType === 'invoice') || + (activeTab === 'draft' && invoice.status === 'draft' && docType === 'invoice' && !isCreditNote) || + (activeTab === 'paid' && invoice.status === 'paid') || (activeTab === 'credit' && isCreditNote) || (activeTab === 'proforma' && docType === 'proforma' && invoice.status !== 'cancelled') || (activeTab === 'delivery_note' && docType === 'delivery_note' && invoice.status !== 'cancelled') || - (activeTab === 'cancelled' && invoice.status === 'cancelled') || - (activeTab !== 'all' && activeTab !== 'proforma' && activeTab !== 'delivery_note' && activeTab !== 'cancelled' && invoice.status === activeTab) + (activeTab === 'cancelled' && invoice.status === 'cancelled') - return matchesSearch && matchesTab + return matchesSearch && matchesFy && matchesTab }) const visibleInvoices = filteredInvoices.slice(0, visibleCount) - const isOutstandingReceivable = (i: Invoice) => - ['sent', 'overdue'].includes(i.status) && !i.credited_invoice_id - const stats = { - unpaid: invoices.filter(isOutstandingReceivable).length, - unpaidAmount: invoices - .filter(isOutstandingReceivable) - .reduce((sum, i) => { - if (i.currency === 'SEK') { - return sum + getDisplayTotal({ total: Number(i.total), currency: 'SEK', ore_rounding: i.ore_rounding }, { ore_rounding: oreRounding }).displayed - } - return sum + Number(i.total_sek || i.total) - }, 0), - overdue: invoices.filter((i) => i.status === 'overdue' && !i.credited_invoice_id).length, + const overdueCount = invoices.filter( + (i) => i.status === 'overdue' && !i.credited_invoice_id, + ).length + + const resetPaging = () => setVisibleCount(INITIAL_VISIBLE_ROWS) + + const createOptions: SplitButtonOption[] = [ + { + key: 'faktura', + label: t('new_invoice'), + icon: Plus, + description: t('create_invoice_desc'), + disabled: !canWrite, + disabledTitle: t('viewer_disabled_tooltip'), + onSelect: () => openNewInvoice(), + }, + { + key: 'aterkommande', + label: t('create_recurring'), + icon: Repeat, + description: t('create_recurring_desc'), + onSelect: () => router.push('/invoices/recurring'), + }, + { + key: 'sjalvfaktura', + label: t('create_self'), + icon: FileInput, + description: t('create_self_desc'), + disabled: !canWrite, + disabledTitle: t('viewer_disabled_tooltip'), + onSelect: () => openNewSelfBilled(), + }, + ] + + // One derivable status chip per row (concept scene 15). Doc-type markers + // (proforma/följesedel/självfaktura) only appear in views where the type + // isn't already implied. + function statusChip(invoice: Invoice): { label: string; variant: 'secondary' | 'outline' | 'success' | 'warning' | 'destructive' } { + const isCreditNote = !!invoice.credited_invoice_id + if (invoice.status === 'cancelled') return { label: t('status_cancelled'), variant: 'secondary' } + if (isCreditNote && invoice.status !== 'paid') return { label: t('badge_credit'), variant: 'destructive' } + if (invoice.status === 'credited') return { label: t('status_credited'), variant: 'secondary' } + if (invoice.status === 'draft') { + const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice' + const isUnsent = + !!invoice.invoice_number && docType === 'invoice' && !isCreditNote && !invoice.is_self_billed + return isUnsent + ? { label: t('status_unsent'), variant: 'outline' } + : { label: t('status_draft'), variant: 'secondary' } + } + if (invoice.status === 'paid') { + return { + label: invoice.paid_at + ? t('status_paid_date', { date: formatDate(invoice.paid_at) }) + : t('status_paid'), + variant: 'success', + } + } + if (invoice.status === 'partially_paid') return { label: t('status_partially_paid'), variant: 'warning' } + if (invoice.status === 'overdue' && invoice.due_date) { + return { + label: t('status_overdue_days', { days: Math.max(1, daysOverdue(invoice.due_date)) }), + variant: 'warning', + } + } + return { label: t('status_sent'), variant: 'outline' } } return (
- - - - - {canWrite ? ( - - ) : ( - - )} -
- } - /> + {/* Page header (concept scene 15): title + Ny faktura split button */} +
+

{t('title')}

+ +
- {/* Inline summary */} - {!isLoading && invoices.length > 0 && ( -

- {invoices.length === 1 ? t('summary_one', { count: invoices.length }) : t('summary_other', { count: invoices.length })} - {stats.unpaid > 0 && ( - <> - {' · '} - {t('summary_unpaid', { count: stats.unpaid })} - {' · '} - {t('summary_to_collect', { amount: formatCurrency(stats.unpaidAmount) })} - {stats.overdue > 0 && ( - <> - {' · '} - {t('summary_overdue', { count: stats.overdue })} - - )} - - )} -

- )} - - {/* Search and tabs */} -
-
+ {/* Toolbar: one status chip-picker (founder direction: the status + views live behind a filter chip, not a seg), sök, FyPicker far + right. Counts ride as row annotations and on the trigger. */} +
+ { + setActiveTab(id as ListTab) + resetPaging() + }} + ariaLabel={t('status_picker_aria')} + triggerLabel={ + activeTab === 'overdue' && overdueCount > 0 + ? `${t(TAB_LABEL_KEYS[activeTab])} · ${overdueCount}` + : t(TAB_LABEL_KEYS[activeTab]) + } + items={[...SEG_TABS, ...MORE_TABS].map((tab) => ({ + id: tab, + label: t(TAB_LABEL_KEYS[tab]), + annotation: + tab === 'overdue' && overdueCount > 0 ? String(overdueCount) : undefined, + }))} + /> +
{ setSearchTerm(e.target.value) - setVisibleCount(INITIAL_VISIBLE_ROWS) + resetPaging() }} - className="pl-10" + className="h-9 pl-10" + /> +
+
+ { + setFyPeriodId(periodId) + setFyPeriod(period ?? null) + resetPaging() + }} + includeAllOption />
- { - setActiveTab(value) - setVisibleCount(INITIAL_VISIBLE_ROWS) - }} - className="min-w-0" - > - - {t('tab_all')} - {t('tab_unpaid')} - {t('tab_paid')} - {t('tab_draft')} - {t('tab_proforma')} - {t('tab_delivery_note')} - {t('tab_credit')} - {t('tab_cancelled')} - -
- - {isLoading ? ( - [1, 2, 3].map((i) => ( + {isLoading ? ( +
+ {[1, 2, 3].map((i) => (
-
- - -
+ +
- )) - ) : filteredInvoices.length === 0 ? ( - searchTerm ? ( - } - title={t('no_search_results_title')} - description={t('no_search_results_description', { term: searchTerm })} - /> - ) : invoices.length === 0 ? ( - - ) : ( - } - title={t('no_category_title')} - description={t('no_category_description')} - /> - ) + ))} +
+ ) : filteredInvoices.length === 0 ? ( + searchTerm ? ( + } + title={t('no_search_results_title')} + description={t('no_search_results_description', { term: searchTerm })} + /> + ) : invoices.length === 0 ? ( + ) : ( - visibleInvoices.map((invoice) => { - const status = STATUS_CONFIG[invoice.status] - const isCreditNote = !!invoice.credited_invoice_id - const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice' - const isProforma = docType === 'proforma' - const isDeliveryNote = docType === 'delivery_note' - // A draft that already has a number is issued-but-unsent ("Granska & - // skapa" done, "Skicka" pending): distinct from a true unnumbered - // draft. Show "Ej skickad" so the two don't look alike. Display-only. - const isUnsentInvoice = - invoice.status === 'draft' && - !!invoice.invoice_number && - !isProforma && - !isDeliveryNote && - !isCreditNote && - !invoice.is_self_billed - const statusLabelKey = isUnsentInvoice ? 'status_unsent' : status.labelKey - const statusVariant: InvoiceStatusVariant | 'outline' = isUnsentInvoice ? 'outline' : status.variant - // Credit notes are never payable (invoices_credit_note_not_paid), - // so a due-date countdown ("X dagar försenad") is meaningless for them. - const relativeTime = invoice.due_date && !isCreditNote ? getRelativeTimeLabel(invoice.due_date, invoice.status) : null - const displayedTotal = getDisplayTotal( - { total: Number(invoice.total), currency: invoice.currency, ore_rounding: invoice.ore_rounding }, - { ore_rounding: oreRounding }, - ).displayed - return ( - - -

} + title={t('no_category_title')} + description={t('no_category_description')} + /> + ) + ) : ( +

+ + + + + + + + + + + + {visibleInvoices.map((invoice) => { + const chip = statusChip(invoice) + const isCreditNote = !!invoice.credited_invoice_id + const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice' + const displayedTotal = getDisplayTotal( + { total: Number(invoice.total), currency: invoice.currency, ore_rounding: invoice.ore_rounding }, + { ore_rounding: oreRounding }, + ).displayed + const number = invoice.is_self_billed + ? invoiceDisplayNumber(invoice) + : invoice.invoice_number + // Doc-type marker only where the view doesn't already imply it. + const typeMarker = + activeTab === 'all' + ? docType === 'proforma' + ? t('badge_proforma') + : docType === 'delivery_note' + ? t('badge_delivery_note') + : invoice.is_self_billed + ? t('badge_self_billed') + : null + : null + return ( + router.push(`/invoices/${invoice.id}`)} + > + + + + + + + ) + })} + +
{t('th_nr')}{t('th_customer')}{t('th_due')}{t('th_amount')}{t('th_status')}
+ e.stopPropagation()} > - {formatCurrency(displayedTotal, invoice.currency)} -

- {invoice.currency !== 'SEK' && invoice.total_sek && ( -

- {formatCurrency(Number(invoice.total_sek))} -

+ {number ?? '·'} + +
+ + {(invoice.customer as { name: string })?.name ?? '-'} + + + {invoice.due_date && !isCreditNote && invoice.status !== 'draft' + ? formatDate(invoice.due_date) + : ''} + - } - > - - {invoice.is_self_billed ? invoiceDisplayNumber(invoice) : (invoice.invoice_number ?? '-')}{' '} - - · {(invoice.customer as { name: string })?.name} - - - - {formatDate(invoice.invoice_date)} - - - {t(statusLabelKey)} - - {isCreditNote && ( - <> - - - {t('badge_credit')} + {formatCurrency(displayedTotal, invoice.currency)} + + + {typeMarker && ( + + {typeMarker} + + )} + + {chip.label} - - )} - {isProforma && ( - <> - - - {t('badge_proforma')} - - - )} - {isDeliveryNote && ( - <> - - - {t('badge_delivery_note')} - - - )} - {invoice.is_self_billed && ( - <> - - - {t('badge_self_billed')} - - - )} - {relativeTime && ( - <> - - - {relativeTime.text} - - - )} - - - - ) - }) - )} - + +
+
+ )} {!isLoading && visibleCount < filteredInvoices.length && (
@@ -431,6 +446,7 @@ export default function InvoicesPage() { { if (!open) closeNewInvoice() }} diff --git a/components/invoices/InvoiceEditor.tsx b/components/invoices/InvoiceEditor.tsx index 3ba18264..7719f542 100644 --- a/components/invoices/InvoiceEditor.tsx +++ b/components/invoices/InvoiceEditor.tsx @@ -81,7 +81,12 @@ export type InvoiceEditorProps = ( | { mode?: 'create' } | { mode: 'edit'; initial: InvoiceForEdit } | { mode: 'copy'; initial: InvoiceCopyInitial } -) & { bare?: boolean } +) & { + bare?: boolean + /** Open with the självfaktura tab preselected (the "Självfaktura" entry in + * the invoice list's split button). Create mode only. */ + initialSelfBilled?: boolean +} // Subset of Article fields the line picker needs to pre-fill a row. type ArticleOption = Pick< @@ -127,7 +132,9 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat // Toggle between a normal customer invoice (default) and registering a // self-billing invoice we received (mottagen självfaktura, ML 17 kap 15§). // Self-billing is never available when editing an existing draft. - const [mode, setMode] = useState<'invoice' | 'self_billed'>('invoice') + const [mode, setMode] = useState<'invoice' | 'self_billed'>( + props.initialSelfBilled && !isEditMode ? 'self_billed' : 'invoice', + ) // Company-wide opt-in from the invoice settings page: the whole payment // link section (manual field + Stripe auto toggle) stays hidden until the // company enables it. The send routes enforce the same setting server-side diff --git a/components/invoices/NewInvoiceDialog.tsx b/components/invoices/NewInvoiceDialog.tsx index c276b4b0..90ed8085 100644 --- a/components/invoices/NewInvoiceDialog.tsx +++ b/components/invoices/NewInvoiceDialog.tsx @@ -34,6 +34,8 @@ interface Props { open: boolean onOpenChange: (open: boolean) => void copyFromId?: string | null + /** Preselect the självfaktura tab (split-button entry on /invoices). */ + selfBilled?: boolean } /** @@ -47,7 +49,7 @@ interface Props { * shows the invoice-number preview: a static DialogTitle would duplicate or * contradict it. */ -export default function NewInvoiceDialog({ open, onOpenChange, copyFromId = null }: Props) { +export default function NewInvoiceDialog({ open, onOpenChange, copyFromId = null, selfBilled = false }: Props) { const t = useTranslations('invoice_editor') const { company } = useCompany() const supabase = useMemo(() => createClient(), []) @@ -146,7 +148,7 @@ export default function NewInvoiceDialog({ open, onOpenChange, copyFromId = null
) ) : ( - + )} diff --git a/messages/en.json b/messages/en.json index c4893d06..1a860909 100644 --- a/messages/en.json +++ b/messages/en.json @@ -657,7 +657,8 @@ "approve": "Approve", "approve_failed_title": "Approval failed", "approved_title": "Approved", - "approved_description": "The invoice has been approved" + "approved_description": "The invoice has been approved", + "status_picker_aria": "Filter by status" }, "purchase_orders": { "title": "Purchase orders", @@ -5026,7 +5027,22 @@ "status_credited": "Credited", "due_days_overdue": "{days} days overdue", "due_today": "Due today", - "due_days_left": "{days} days left" + "due_days_left": "{days} days left", + "tab_overdue": "Overdue", + "more_views": "More …", + "th_nr": "No.", + "th_customer": "Customer", + "th_due": "Due", + "th_amount": "Amount", + "th_status": "Status", + "create_invoice_desc": "Regular customer invoice", + "create_recurring": "Recurring invoice", + "create_recurring_desc": "Created automatically, e.g. every month", + "create_self": "Self-billing invoice", + "create_self_desc": "Register a self-billing invoice from your customer", + "status_overdue_days": "Overdue {days} d", + "status_paid_date": "Paid {date}", + "status_picker_aria": "Filter by status" }, "dashboard": { "skv_promo_title": "Connect Skatteverket", diff --git a/messages/sv.json b/messages/sv.json index 935d6eed..7d441972 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -657,7 +657,8 @@ "approve": "Godkänn", "approve_failed_title": "Godkännande misslyckades", "approved_title": "Godkänd", - "approved_description": "Fakturan har godkänts" + "approved_description": "Fakturan har godkänts", + "status_picker_aria": "Filtrera på status" }, "purchase_orders": { "title": "Inköpsorder", @@ -5026,7 +5027,22 @@ "status_credited": "Krediterad", "due_days_overdue": "{days} dagar försenad", "due_today": "Förfaller idag", - "due_days_left": "{days} dagar kvar" + "due_days_left": "{days} dagar kvar", + "tab_overdue": "Förfallna", + "more_views": "Fler …", + "th_nr": "Nr", + "th_customer": "Kund", + "th_due": "Förfaller", + "th_amount": "Belopp", + "th_status": "Status", + "create_invoice_desc": "Vanlig kundfaktura", + "create_recurring": "Återkommande faktura", + "create_recurring_desc": "Skapas automatiskt, till exempel varje månad", + "create_self": "Självfaktura", + "create_self_desc": "Registrera en självfaktura från din kund", + "status_overdue_days": "Förfallen {days} dgr", + "status_paid_date": "Betald {date}", + "status_picker_aria": "Filtrera på status" }, "dashboard": { "skv_promo_title": "Koppla Skatteverket",