Files
accounted/components/invoices/NewInvoiceDialog.tsx
T
Mattsson c981384a0e fix(transactions): keep the assistant usable through the whole booking flow (#1975)
* fix(transactions): keep the assistant usable through the whole booking flow

Three gaps around the agent sheet during booking on Transaktioner:

- The AgentTrigger bubble lived inside #dash-shell, so the inert that
  non-modal dialogs set on the shell made the assistant impossible to
  OPEN once a booking dialog was up. Moved outside the shell (it is
  position: fixed) and raised to z-[45]: above the DialogVeil (z-40) so
  it stays clickable, below dialog content (z-50). Under true modal
  dialogs Radix's body pointer-events lock keeps it dead as before.
- Wide dialogs centered on the full viewport while the docked sheet
  (z-60) covered their right edge, hiding e.g. the Granska button.
  DialogContent now centers in the space left of --agent-dock-w, and
  the wide booking/review variants cap their width against it.
- Step 1 of the flow (template picker, QuickReviewDialog) was still
  fully modal, so the assistant was dead there. Both are now non-modal
  with DialogVeil + a shared ref-counted useDashShellInert hook (also
  replacing the duplicated inert effects in TransactionBookingDialog
  and NewInvoiceDialog). Their Esc/veil-click dismissal is kept: they
  hold no half-filled form. Clicks in the agent sheet or its trigger
  never dismiss any dialog: data-agent-ui counts as inside, same
  mechanism as data-dialog-companion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): dialog centering reads a docked-only sheet width variable

Skeptic review refuted the first cut: globals.css seeds --agent-dock-w
at the 10px frame gutter on :root by design, so the 0px fallback in the
new dialog centering never applied and every dialog sat 5px left of
center (full-bleed dialogs clipped 5px off-screen).

Introduce --agent-sheet-w, set inline by AgentSheetProvider only while
the sheet is docked and removed otherwise, so the 0px fallback is real:
sheet closed or floating renders byte-identical to the old left-[50%]
and old max widths. Also cap the template picker and QuickReview's
narrow variant, which could clip off-screen left on narrow desktops
with the sheet docked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 14:22:20 +02:00

163 lines
6.3 KiB
TypeScript

'use client'
import { useEffect, useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Dialog, DialogContent, DialogTitle, DialogVeil, useDashShellInert } from '@/components/ui/dialog'
import { Skeleton } from '@/components/ui/skeleton'
import { Button } from '@/components/ui/button'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import {
buildInvoiceCopyInitial,
canCopyInvoice,
type InvoiceCopyInitial,
type InvoiceCopySource,
} from '@/lib/invoices/copy-invoice'
// The editor (and its framer-motion dependency) is a large chunk that must
// not ship with the invoice LIST bundle. This dialog is itself loaded with
// next/dynamic by app/(dashboard)/invoices/page.tsx, so a static import here
// keeps the editor in the dialog's deferred chunk: ONE chunk download when
// "Ny faktura" opens instead of the dialog chunk followed by a second,
// dependent editor chunk (and then the editor's own data fetches).
import InvoiceEditor from '@/components/invoices/InvoiceEditor'
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
copyFromId?: string | null
/** Preselect the självfaktura tab (split-button entry on /invoices). */
selfBilled?: boolean
}
/**
* "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, copyFromId = null, selfBilled = false }: Props) {
const t = useTranslations('invoice_editor')
const { company } = useCompany()
const supabase = useMemo(() => createClient(), [])
const [copyLoad, setCopyLoad] = useState<{
sourceId: string | null
initial: InvoiceCopyInitial | null
failed: boolean
}>({ sourceId: null, initial: null, failed: false })
useEffect(() => {
if (!open || !copyFromId) return
if (!company?.id) return
let cancelled = false
const loadCopySource = async () => {
const { data, error } = await supabase
.from('invoices')
.select('*')
.eq('id', copyFromId)
.eq('company_id', company.id)
.single()
let items: Record<string, unknown>[] = []
if (!error && data) {
try {
// invoice_items has NO company_id column: it is scoped through its
// parent invoice. Filtering on it made PostgREST answer 42703, which
// fetchAllRows rethrows, so the copy flow hit the "kunde inte
// laddas" panel for EVERY company. Tenancy is unaffected: the
// invoices lookup above already pins copyFromId to this company, and
// the invoice_items RLS policy joins back to the parent invoice.
items = await fetchAllRows<Record<string, unknown>>(({ from, to }) =>
supabase
.from('invoice_items')
.select('*')
.eq('invoice_id', copyFromId)
.order('id', { ascending: true })
.range(from, to),
)
} catch {
if (!cancelled) setCopyLoad({ sourceId: copyFromId, initial: null, failed: true })
return
}
}
if (cancelled) return
const source = data ? { ...data, items } : null
if (error || !source || !canCopyInvoice(source)) {
setCopyLoad({ sourceId: copyFromId, initial: null, failed: true })
return
}
setCopyLoad({
sourceId: copyFromId,
initial: buildInvoiceCopyInitial(source as InvoiceCopySource),
failed: false,
})
}
void loadCopySource()
return () => {
cancelled = true
}
}, [company?.id, copyFromId, open, supabase])
const copyInitial = copyLoad.sourceId === copyFromId ? copyLoad.initial : null
const copyLoadFailed = copyLoad.sourceId === copyFromId && copyLoad.failed
// Non-modal dialog (see below): page modality is restored by hand so the
// agent sheet stays live. See useDashShellInert in components/ui/dialog.tsx.
useDashShellInert(open)
return (
<Dialog open={open} onOpenChange={onOpenChange} modal={false}>
<DialogVeil />
<DialogContent
// p-0/gap-0: the bare editor carries its own padding so its sticky
// action bar can sit flush against the dialog's bottom edge (position
// sticky binds to this DialogContent, the scroll container).
className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto p-0 gap-0"
// 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">
{copyFromId ? t('title_copy') : t('title_invoice')}
</DialogTitle>
{copyFromId ? (
copyLoadFailed ? (
<div className="space-y-4 p-6 text-center">
<p className="font-medium">{t('copy_load_failed_title')}</p>
<p className="text-sm text-muted-foreground">{t('copy_load_failed_description')}</p>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('close')}
</Button>
</div>
) : copyInitial ? (
<InvoiceEditor key={copyFromId} mode="copy" initial={copyInitial} bare />
) : (
<div className="space-y-4 p-6">
<Skeleton className="h-8 w-1/3" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
</div>
)
) : (
<InvoiceEditor mode="create" bare initialSelfBilled={selfBilled} />
)}
</DialogContent>
</Dialog>
)
}