Files
accounted/components/orders/MarkOrderBookedDialog.tsx
Mattsson 1f9578ca76 feat(woo): mark an order as already booked outside the integration (#1895)
* feat(woo): mark an order as already booked outside the integration

Orders booked by hand before the store was connected sat under Att
bokfora forever: the only exits were the book and create-invoice routes.

- Migration: manually_booked_at/_by + optional
  manually_booked_journal_entry_id on webshop_orders (informational link,
  no financial freeze; the mark produced no accounting objects).
- POST/DELETE /api/webshop-orders/[id]/mark-booked: mark with optional
  posted-verifikat reference (validated per company), conditional claim
  against concurrent booking/invoicing; unmark is a plain revert.
- book and create-invoice routes refuse marked rows (409
  WEBSHOP_ORDER_MANUALLY_BOOKED) and exclude them in their atomic claims.
- List route: booked/unbooked filters treat a manual mark as a closed
  exit, so marked rows leave the Att bokfora tab and join Bokforda.
- Orders page: row overflow menu with Markera som bokford / Angra
  markering, MarkOrderBookedDialog with a searchable candidate list of
  posted entries near the order date, muted status text linking to the
  referenced verifikat.

Fixes #1879

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

* fix(woo): close skeptic findings on the manual-booked mark

- mark-booked applies the same open-twin gate as book/create-invoice:
  an OPEN legacy feed transaction blocks the mark (409
  WEBSHOP_ORDER_LEGACY_TRANSACTION_OPEN); ignored or booked feed rows
  unlock it, so no open path to a duplicate remains.
- ingest treats manually marked rows as frozen for drift purposes:
  remote financial deltas set remote_changed_after_freeze (same badge as
  booked rows) instead of silently refreshing the row under the user's
  assertion.
- re-marking with a journal_entry_id updates the informational link
  instead of silently dropping it.
- dialog: candidate amount computed from the returned lines (the list
  API does not return total_amount), newest-first ordering, cap hint.

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

* chore(migrations): bump webshop manual-booking migration past freshly merged 20260825120000

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

* fix(woo): resolve PR review findings in one pass

- freeze v3 migration: financial fields are frozen at the DB level while
  a row is manually marked as booked (review finding: the mark's freeze
  lived only in ingest.ts, so any other write path could silently mutate
  a marked row); unmark stays the escape hatch. pg test added.
- pass the active locale to getErrorMessage in the orders page and
  MarkOrderBookedDialog (CodeRabbit: English users got Swedish errors).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 14:23:56 +02:00

262 lines
9.3 KiB
TypeScript

'use client'
import { useCallback, useEffect, useState } from 'react'
import { useLocale, useTranslations } from 'next-intl'
import { Loader2 } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useToast } from '@/components/ui/use-toast'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { roundOre } from '@/lib/money'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import type { WebshopOrder } from '@/types'
interface MarkOrderBookedDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
order: WebshopOrder
onMarked: () => void
}
interface EntryCandidate {
id: string
entry_date: string
description: string | null
voucher_series?: string | null
voucher_number?: number | null
/** The list API returns full rows with nested lines; the gross is their debit sum. */
lines?: Array<{ debit_amount: number | string | null }>
}
/** Gross amount of a candidate = sum of its debit legs (total_amount is a DB
* computed column and not part of the select the list route returns). */
function candidateGross(entry: EntryCandidate): number | null {
if (!entry.lines || entry.lines.length === 0) return null
const sum = entry.lines.reduce((acc, l) => acc + (Number(l.debit_amount) || 0), 0)
return roundOre(sum)
}
// ±45 days around the order date: wide enough for a manual booking done in
// the same period, narrow enough to keep the candidate list short. Typing a
// search drops the window (search over all posted entries instead).
const WINDOW_DAYS = 45
const CANDIDATE_LIMIT = 30
function shiftDate(isoDate: string, deltaDays: number): string {
const d = new Date(isoDate)
if (Number.isNaN(d.getTime())) return isoDate
d.setDate(d.getDate() + deltaDays)
return d.toISOString().slice(0, 10)
}
/**
* Marks one order/refund row as already booked/handled outside the
* integration (issue #1879): no verifikat is created, the row just leaves
* the to-book list. Optionally links the existing posted verifikat that
* covers the order, picked from a searchable candidate list.
*/
export default function MarkOrderBookedDialog({
open,
onOpenChange,
order,
onMarked,
}: MarkOrderBookedDialogProps) {
const t = useTranslations('webshop_orders')
const errorLocale = useLocale() as ErrorLocale
const { toast } = useToast()
const [candidates, setCandidates] = useState<EntryCandidate[]>([])
const [loading, setLoading] = useState(false)
const [search, setSearch] = useState('')
const [selected, setSelected] = useState('')
const [submitting, setSubmitting] = useState(false)
const loadCandidates = useCallback(
async (query: string, signal: { cancelled: boolean }) => {
setLoading(true)
try {
const params = new URLSearchParams()
params.set('status', 'posted')
params.set('exclude_draft', 'true')
params.set('limit', String(CANDIDATE_LIMIT))
// Newest first: the manual booking is usually recent relative to the
// window; default voucher order would surface the year's first
// vouchers and hide the relevant ones behind the cap.
params.set('sort_by', 'date_desc')
if (query) {
params.set('search', query)
} else {
const anchor = order.paid_date ?? order.order_date
params.set('date_from', shiftDate(anchor, -WINDOW_DAYS))
params.set('date_to', shiftDate(anchor, WINDOW_DAYS))
}
const res = await fetch(`/api/bookkeeping/journal-entries?${params}`)
if (!res.ok) throw new Error(`list failed: ${res.status}`)
const json = (await res.json()) as { data: EntryCandidate[] }
if (!signal.cancelled) setCandidates(json.data ?? [])
} catch {
if (!signal.cancelled) setCandidates([])
} finally {
if (!signal.cancelled) setLoading(false)
}
},
[order.paid_date, order.order_date],
)
// (Re)load when the dialog opens or the search changes (debounced).
useEffect(() => {
if (!open) return
const signal = { cancelled: false }
const timer = setTimeout(() => void loadCandidates(search.trim(), signal), 250)
return () => {
signal.cancelled = true
clearTimeout(timer)
}
}, [open, search, loadCandidates])
// Reset transient state when the dialog closes.
useEffect(() => {
if (open) return
setCandidates([])
setSearch('')
setSelected('')
}, [open])
async function handleConfirm() {
setSubmitting(true)
try {
const res = await fetch(`/api/webshop-orders/${order.id}/mark-booked`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(selected ? { journal_entry_id: selected } : {}),
})
const json = await res.json()
if (!res.ok || json.error) {
toast({
title: t('mark_failed'),
description: getErrorMessage(json, {
context: 'transaction',
statusCode: res.status,
locale: errorLocale,
}),
variant: 'destructive',
})
return
}
onMarked()
} catch {
toast({ title: t('mark_failed'), variant: 'destructive' })
} finally {
setSubmitting(false)
}
}
const isRefund = order.row_type === 'refund'
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
{/* data-ph-mask: the order number is user data */}
<DialogTitle data-ph-mask="">
{isRefund
? t('mark_refund_title', { number: order.order_number })
: t('mark_title', { number: order.order_number })}
</DialogTitle>
<DialogDescription>
{formatDate(order.paid_date ?? order.order_date)}
{' · '}
{formatCurrency(order.total, order.currency)}
</DialogDescription>
</DialogHeader>
<p className="text-sm text-muted-foreground">{t('mark_description')}</p>
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">{t('mark_link_label')}</p>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('mark_link_search_placeholder')}
aria-label={t('mark_link_search_placeholder')}
/>
{loading ? (
<div className="flex items-center justify-center gap-2 rounded-lg border border-border py-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('mark_link_loading')}
</div>
) : candidates.length === 0 ? (
<p className="rounded-lg border border-border px-3 py-4 text-center text-sm text-muted-foreground">
{t('mark_link_empty')}
</p>
) : (
<div
role="radiogroup"
aria-label={t('mark_link_label')}
className="max-h-56 space-y-1 overflow-y-auto rounded-lg border border-border p-1"
>
{candidates.map((entry) => {
const active = selected === entry.id
const gross = candidateGross(entry)
return (
<button
key={entry.id}
type="button"
role="radio"
aria-checked={active}
onClick={() => setSelected(active ? '' : entry.id)}
className={cn(
'flex w-full items-center gap-3 rounded-sm px-2 py-2 text-left text-[13px] transition-colors duration-150',
active ? 'bg-secondary text-foreground' : 'hover:bg-secondary/60',
)}
>
<span className="w-12 shrink-0 font-medium tabular-nums">
{formatVoucher(entry)}
</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
{formatDate(entry.entry_date)}
</span>
<span className="min-w-0 flex-1 truncate">{entry.description}</span>
<span className="shrink-0 text-right tabular-nums">
{gross != null ? formatCurrency(gross) : ''}
</span>
</button>
)
})}
</div>
)}
<p className="text-xs text-muted-foreground">
{candidates.length >= CANDIDATE_LIMIT
? t('mark_link_capped', { count: CANDIDATE_LIMIT })
: t('mark_link_optional_hint')}
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
{t('cancel')}
</Button>
<Button onClick={handleConfirm} disabled={submitting}>
{submitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('mark_submitting')}
</>
) : (
t('mark_confirm')
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}