1f9578ca76
* 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>
262 lines
9.3 KiB
TypeScript
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>
|
|
)
|
|
}
|