fix(transactions): confirm and allow undo on a counterparty booking (#1292)
Booking via "Tidigare motparter" ended on a bare setExitingIds().add(id): the row animated out of the inbox and that was the entire feedback. No "Bokförd" toast, no Ångra, no unbooked-count decrement, and the id was never removed from exitingIds again, so an undo would have restored the row's data while leaving it filtered out of the list. Extracted runCategorize's success tail into one finishBooking() rather than copying the toast into the counterparty branch: the counterparty path was already a second, thinner implementation of it, which is why it drifted. Both paths and the counterparty activate-and-retry go through it now, pinned by a parity test. Also fixed on the way: - handleTransactionBooked (manual booking dialog / voucher match) never decremented the unbooked count, so the header read one too high until the next refetch. It deliberately gets no Ångra: its `matched` branch links the transaction to a PRE-EXISTING verifikat, and /uncategorize storno-reverses whatever journal_entry_id the transaction points at. - The 350ms animation timer re-applied the booked shape unconditionally, so an Ångra resolving inside that window left the client claiming a journal_entry_id the server had already storno-reversed. A completed undo now wins. - finishBooking cleared processingId unconditionally; scoped to the finished id so it cannot wipe an unrelated row's spinner. No migrations.
This commit is contained in:
@@ -918,6 +918,104 @@ export default function TransactionsPage() {
|
||||
return runCategorize({ id, isBusiness, category, vatTreatment, accountOverride, templateId, inboxItemId, dimensions, confirmNoMatch: false })
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared tail of a successful booking: exit animation, unbooked-count
|
||||
* decrement, the outcome toast with its Ångra action, and the state patch
|
||||
* that lands the row in its booked shape.
|
||||
*
|
||||
* Both booking paths run this. The counterparty-template path used to do
|
||||
* only `setExitingIds().add(id)`, so a successful booking gave no
|
||||
* confirmation, no undo and no count decrement, and the id was never removed
|
||||
* from exitingIds again: an undo would have restored the row's data while
|
||||
* leaving it filtered out of the inbox.
|
||||
*/
|
||||
function finishBooking(args: {
|
||||
id: string
|
||||
isBusiness: boolean
|
||||
category?: TransactionCategory
|
||||
journalEntryId?: string | null
|
||||
journalEntryCreated?: boolean
|
||||
journalEntryError?: string | null
|
||||
}) {
|
||||
const { id, isBusiness, category, journalEntryId, journalEntryCreated, journalEntryError } = args
|
||||
// A completed Ångra must win over the delayed patch below. The undo has
|
||||
// already storno-reversed the verifikat server-side, so re-applying the
|
||||
// booked shape afterwards would show a journal_entry_id that no longer
|
||||
// represents a live entry.
|
||||
let undone = false
|
||||
|
||||
setExitingIds((prev) => new Set(prev).add(id))
|
||||
setTotalUncategorizedCount((prev) => Math.max(0, (prev ?? 1) - 1))
|
||||
|
||||
if (journalEntryCreated) {
|
||||
toast({
|
||||
title: 'Bokförd',
|
||||
action: (
|
||||
<ToastAction altText="Ångra kategorisering" onClick={async () => {
|
||||
try {
|
||||
const undoRes = await fetch(`/api/transactions/${id}/uncategorize`, { method: 'POST' })
|
||||
if (undoRes.ok) {
|
||||
undone = true
|
||||
setTransactions((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === id
|
||||
? { ...t, is_business: null, category: null as unknown as TransactionCategory, journal_entry_id: null }
|
||||
: t
|
||||
)
|
||||
)
|
||||
setTotalUncategorizedCount((prev) => (prev ?? 0) + 1)
|
||||
toast({ title: t('undone_title'), description: t('undone_description') })
|
||||
} else {
|
||||
const errData = await undoRes.json()
|
||||
toast({
|
||||
title: 'Kunde inte ångra',
|
||||
description: getErrorMessage(errData, { context: 'transaction', statusCode: undoRes.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
toast({ title: t('undo_failed_title'), description: t('undo_failed_description'), variant: 'destructive' })
|
||||
}
|
||||
}}>
|
||||
Ångra
|
||||
</ToastAction>
|
||||
),
|
||||
})
|
||||
} else if (journalEntryError) {
|
||||
toast({ title: 'Delvis bokförd', description: `Verifikation kunde inte skapas: ${journalEntryError}`, variant: 'destructive' })
|
||||
} else {
|
||||
toast({ title: t('partially_booked_title'), description: t('partially_booked_description') })
|
||||
}
|
||||
|
||||
// Update transaction in state after a brief delay for animation. Clearing
|
||||
// the id from exitingIds is what makes an Ångra later put the row back in
|
||||
// the inbox instead of leaving it invisible.
|
||||
setTimeout(() => {
|
||||
if (!undone) {
|
||||
setTransactions((prev) =>
|
||||
prev.map((tx) =>
|
||||
tx.id === id
|
||||
? {
|
||||
...tx,
|
||||
is_business: isBusiness,
|
||||
...(category ? { category } : {}),
|
||||
...(journalEntryId ? { journal_entry_id: journalEntryId } : {}),
|
||||
}
|
||||
: tx
|
||||
)
|
||||
)
|
||||
}
|
||||
setExitingIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(id)
|
||||
return next
|
||||
})
|
||||
// Only this row's spinner: the shared helper must not clear another
|
||||
// transaction's in-flight state.
|
||||
setProcessingId((prev) => (prev === id ? null : prev))
|
||||
}, 350)
|
||||
}
|
||||
|
||||
async function runCategorize(args: {
|
||||
id: string
|
||||
isBusiness: boolean
|
||||
@@ -1151,66 +1249,14 @@ export default function TransactionsPage() {
|
||||
return null
|
||||
}
|
||||
|
||||
// Mark as exiting for animation, then update state
|
||||
setExitingIds((prev) => new Set(prev).add(id))
|
||||
setTotalUncategorizedCount((prev) => Math.max(0, (prev ?? 1) - 1))
|
||||
|
||||
if (result.journal_entry_created) {
|
||||
toast({
|
||||
title: 'Bokförd',
|
||||
action: (
|
||||
<ToastAction altText="Ångra kategorisering" onClick={async () => {
|
||||
try {
|
||||
const undoRes = await fetch(`/api/transactions/${id}/uncategorize`, { method: 'POST' })
|
||||
if (undoRes.ok) {
|
||||
setTransactions((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === id
|
||||
? { ...t, is_business: null, category: null as unknown as TransactionCategory, journal_entry_id: null }
|
||||
: t
|
||||
)
|
||||
)
|
||||
setTotalUncategorizedCount((prev) => (prev ?? 0) + 1)
|
||||
toast({ title: t('undone_title'), description: t('undone_description') })
|
||||
} else {
|
||||
const errData = await undoRes.json()
|
||||
toast({
|
||||
title: 'Kunde inte ångra',
|
||||
description: getErrorMessage(errData, { context: 'transaction', statusCode: undoRes.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
toast({ title: t('undo_failed_title'), description: t('undo_failed_description'), variant: 'destructive' })
|
||||
}
|
||||
}}>
|
||||
Ångra
|
||||
</ToastAction>
|
||||
),
|
||||
})
|
||||
} else if (result.journal_entry_error) {
|
||||
toast({ title: 'Delvis bokförd', description: `Verifikation kunde inte skapas: ${result.journal_entry_error}`, variant: 'destructive' })
|
||||
} else {
|
||||
toast({ title: t('partially_booked_title'), description: t('partially_booked_description') })
|
||||
}
|
||||
|
||||
// Update transaction in state after a brief delay for animation
|
||||
setExitingIds((prev) => new Set(prev).add(id))
|
||||
setTimeout(() => {
|
||||
setTransactions((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === id
|
||||
? { ...t, is_business: isBusiness, category: result.category, journal_entry_id: result.journal_entry_id }
|
||||
: t
|
||||
)
|
||||
)
|
||||
setExitingIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(id)
|
||||
return next
|
||||
})
|
||||
setProcessingId(null)
|
||||
}, 350)
|
||||
finishBooking({
|
||||
id,
|
||||
isBusiness,
|
||||
category: result.category,
|
||||
journalEntryId: result.journal_entry_id,
|
||||
journalEntryCreated: result.journal_entry_created,
|
||||
journalEntryError: result.journal_entry_error,
|
||||
})
|
||||
|
||||
return result.journal_entry_id || null
|
||||
} catch {
|
||||
@@ -1901,6 +1947,10 @@ export default function TransactionsPage() {
|
||||
matched?: boolean,
|
||||
) {
|
||||
setExitingIds((prev) => new Set(prev).add(transactionId))
|
||||
// The row leaves the unbooked inbox either way (booked, or linked to an
|
||||
// existing voucher), so the header count has to follow: every other path
|
||||
// that removes a row decrements, this one did not.
|
||||
setTotalUncategorizedCount((prev) => Math.max(0, (prev ?? 1) - 1))
|
||||
setTimeout(() => {
|
||||
setTransactions((prev) =>
|
||||
prev.map((t) =>
|
||||
@@ -2246,7 +2296,7 @@ export default function TransactionsPage() {
|
||||
let journalEntryId: string | null
|
||||
if (!templateId && quickReview?.template?.id && isCounterpartyTemplateId(quickReview.template.id)) {
|
||||
const cpTemplateId = extractCounterpartyId(quickReview.template.id)
|
||||
const cpCategorize = async (): Promise<{ ok: boolean; journalEntryId: string | null; result: { error?: { code?: string; account_numbers?: string[]; details?: { account_numbers?: string[] } }; journal_entry_id?: string | null }; status: number }> => {
|
||||
const cpCategorize = async (): Promise<{ ok: boolean; journalEntryId: string | null; result: { error?: { code?: string; account_numbers?: string[]; details?: { account_numbers?: string[] } }; journal_entry_id?: string | null; journal_entry_created?: boolean; journal_entry_error?: string | null; category?: TransactionCategory }; status: number }> => {
|
||||
const r = await fetch(`/api/transactions/${id}/categorize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -2303,15 +2353,14 @@ export default function TransactionsPage() {
|
||||
// update conditionally writes the journal_entry_id when it's
|
||||
// actually present.
|
||||
if (retry.ok) {
|
||||
setExitingIds((prev) => new Set(prev).add(id))
|
||||
setTransactions((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === id
|
||||
? { ...t, is_business: true, ...(retry.journalEntryId ? { journal_entry_id: retry.journalEntryId } : {}) }
|
||||
: t
|
||||
)
|
||||
)
|
||||
toast({ title: 'Bokförd' })
|
||||
finishBooking({
|
||||
id,
|
||||
isBusiness: true,
|
||||
category: retry.result?.category,
|
||||
journalEntryId: retry.journalEntryId,
|
||||
journalEntryCreated: retry.result?.journal_entry_created,
|
||||
journalEntryError: retry.result?.journal_entry_error,
|
||||
})
|
||||
} else {
|
||||
toast({ title: 'Kategorisering misslyckades', description: getErrorMessage(retry.result, { context: 'transaction', statusCode: retry.status }), variant: 'destructive' })
|
||||
}
|
||||
@@ -2332,7 +2381,14 @@ export default function TransactionsPage() {
|
||||
setQuickReview(null)
|
||||
return null
|
||||
}
|
||||
setExitingIds((prev) => new Set(prev).add(id))
|
||||
finishBooking({
|
||||
id,
|
||||
isBusiness: true,
|
||||
category: result?.category,
|
||||
journalEntryId: cpJeId,
|
||||
journalEntryCreated: result?.journal_entry_created,
|
||||
journalEntryError: result?.journal_entry_error,
|
||||
})
|
||||
journalEntryId = cpJeId
|
||||
} else {
|
||||
journalEntryId = await handleCategorize(id, true, category, vatTreatment, accountOverride, templateId, undefined, dimensions)
|
||||
|
||||
Reference in New Issue
Block a user