* feat(transactions): "Ta bort underlag" detach action on a transaction (#2132) Wrong receipt pinned, no way back: the DELETE /api/transactions/[id]/attach-document route and its tests already existed, but nothing in the UI called it. This wires it up, frontend only. - Inbox card and history list: "Ta bort underlag" in the row menu, shown only for writers on unbooked rows that carry a pin (canDetachDocument helper). - Attach dialog: a small "Ta bort underlag" link beside the already-attached hint, the one place the app previously admitted a doc was pinned. - Page: handleDetachDocument confirms (useDestructiveConfirm, warning), then DELETEs; 200 clears document_id in local state (list, dialog snapshot, and the inbox card's optimistic override via a -unlinked window event) and toasts; 409 renders the route's Swedish BFL message verbatim; other errors map through get-error-message. - Strings under tx_detach in sv.json and en.json. - Tests: gate hidden when booked / read-only / no pin / no handler; 409 rendered unchanged; wiring and locale assertions. Out of scope, follow-up: MCP detach tool (new pending-op type + CHECK migration), detaching from the inbox for non-email docs, and clearing invoice_inbox_items.matched_transaction_id on detach so the doc is offered again by inbox-available. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YRXN5CqHrfuuDw5LLcSgTg * fix(transactions): clear the inbox back-link when detaching underlag (#2132) Skeptic finding on PR #2144: DELETE attach-document nulled only transactions.document_id and left invoice_inbox_items.matched_transaction_id pointing at the transaction. propagateUnderlagForBookedTransaction selects on exactly that column at categorize / book / bulk-book time, so the detached receipt would have been re-anchored onto the new verifikation as immutable underlag (BFL 5 kap 7 §), and the doc never reappeared in inbox-available for re-matching. The route now clears the back-link for the detached document, scoped to items not yet consumed by a verifikat (created_journal_entry_id null), mirroring the invoice-inbox extension's unmatch. Best-effort like the POST side: the pin removal is the primary effect. Three DELETE tests cover the filters, the no-pin case, and a failing unlink. DECISIONS.md and the PR body record the accepted bulk-booked-row limitation in the history list. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YRXN5CqHrfuuDw5LLcSgTg * fix(transactions): detach reports a failed inbox unlink instead of success (#2132) Swedish compliance review on PR #2144: the inbox back-link cleanup was fire-and-forget, so a failed UPDATE returned 200 while leaving exactly the stale matched_transaction_id that re-anchors a detached document onto the next verifikation (BFL 5 kap 6-7 §). The unlink is now scoped by transaction only (the unique index on matched_transaction_id means at most one item points here, and a stale item from the replace path would re-anchor just the same), runs even when nothing was pinned so a retry is idempotent, and a failure answers 500 with an honest Swedish partial-failure message, mirroring the POST side's propagation failure. Tests updated accordingly. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YRXN5CqHrfuuDw5LLcSgTg * fix(transactions): release inbox back-link before a compare-and-set pin clear (#2132) Review findings on PR #2144, one pass: - CodeRabbit (major): DELETE cleared the pin and then released the inbox back-link scoped by transaction, so a POST landing in between could end up as "new doc pinned, its inbox item unlinked". The release now runs FIRST, and the pin clear is a compare-and-set on the document that was read (.eq document_id, or .is null when nothing was pinned). Zero rows answers 409 "ändrades samtidigt" and keeps the newer pin. A failed release returns 500 before anything changed, so a retry is trivially idempotent. - Compliance swarm (A.8.15): the unlink failure log carried the raw driver error; it now logs errorCauseTag() only. - CodeRabbit docstring check: JSDoc on handleDetachDocument. Tests: order of the two writes, CAS filters for both pinned and empty states, 409 on concurrent re-attach, coded-cause logging. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YRXN5CqHrfuuDw5LLcSgTg --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
4f33184a9a
commit
4c76fb10d7
@@ -1475,6 +1475,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-09-01] mcp.tool_called gets errorCause = errorCauseTag(err) on the two execution catch paths only (#2051): SQLSTATE or coded-error code, else the error class name, capped at 64 chars; a plain Error deliberately tags null because the class name 'Error' is noise, and pre-execution denials pass nothing since their errorCode already IS the vocabulary. Raw driver messages stay out of event_log on purpose: a constraint-violation message can quote row values.
|
||||
[2026-09-01] counterparty_aliases joins the categorization_templates audit-trigger strip list (20260901200000) instead of staying logged: prod falsified the original exclusion list within 30 minutes of 20260901103000 going live (15 of the first 16 UPDATE audit rows were alias+learning noise, ~800/day projected vs ~50/day of real rule changes), because the learning path merges aliases in the same write that bumps occurrence_count. Explicit trade-off: a human editing ONLY aliases is no longer logged; accepted since alias growth is overwhelmingly automatic and any change also touching accounts/VAT/pattern/active still logs (first real one, 19:02:17Z same day, captured correctly). Pre-fix noise rows stay in audit_log (append-only) and the read model stops labelling the column so they render as no-ops.
|
||||
[2026-09-01] MCP catalog budget attacked at the duplicated staged envelope rather than by demoting more reads: measuring the payload by segment showed outputSchema is 38 % of the whole catalog (23 290 tokens) and STAGED_OPERATION_SCHEMA alone 14 736 of it, the same envelope transmitted 58 times, while descriptions (what the three previous rounds trimmed) are only 10 %. period_status now carries its shape in one sentence instead of declared JSON Schema, matching actor/approve/preview which were always bare objects; 2 552 tokens reclaimed with no tool demoted and no field removed. Every edit is in the LOOSER direction because the server emits structuredContent for every tool and the documented failure mode is a declaration too tight making a strict client reject a successful call. next kept additionalProperties: false: staging.test.ts pins it closed and a guard whose reason is not in front of you is not one to loosen for 420 tokens. Ceiling ratcheted to 60 000 rather than the usual ~300 margin, leaving ~1 070 deliberate working margin: server.ts took 70 commits in 14 days and the previous 116-token margin is what starts the ratchet-block-bump-demote cycle visible in the bench log.
|
||||
[2026-09-01] Ta bort underlag (#2132): detach gate and 409 mapping live in a pure helper (components/transactions/detach-underlag.ts) with file-level assertions, not a rendered component test, because Vitest runs in node and never renders. The skeptic pass refuted the frontend-only plan: the existing DELETE attach-document route left invoice_inbox_items.matched_transaction_id set, and propagateUnderlagForBookedTransaction selects on exactly that column at booking time, so the detached receipt would have been re-anchored onto the new verifikation as immutable underlag (BFL 5 kap 7 §). The route now clears the back-link for the detached doc (scoped to items not yet consumed by a verifikat), mirroring the invoice-inbox unmatch. Accepted limitation: the history list cannot see transaction_voucher_links, so bulk-booked rows (journal_entry_id null) still show the item and get the route 409 with the storno message; the same row-classification gap already applies to Matcha mot befintlig verifikation and Ta bort on those rows, and fixing it means new list-side data plumbing, filed as follow-up with the MCP detach tool.
|
||||
[2026-09-01] Checklist "Anslut till Claude" done-signal = unrevoked api_keys row named MCP-klient (OAuth) for the USER, not per company: the OAuth token route is the only writer of that name and the key company_id is whatever was active at sign-in (null for companyless signups), so a company filter would miss real connections; the AI-profile flag it replaced never meant "connected to Claude" (#2133). Counted through the service client with an explicit user_id filter, not the user client: api_keys' SELECT policy is company-scoped (20260330130000), which hides companyless and archived-company keys and left the step open for exactly the user who had just connected (skeptic refutation on PR #2147). The manual create route reserves the name (400) rather than adding a source column: a migration for a cosmetic tick is not worth it, and the name is already the only marker every reader of that row uses. The consent-page default (all scopes pre-selected, founder decision 2026-08-26) is described, not changed; the compliance swarm's GDPR Art.25(2) finding on this PR targets that decision and is Emil's call, not this docs fix.
|
||||
[2026-09-01] PR #2130 CodeRabbit P1 (org-number twin inherits a grant): the ombud path binds SKV system-credential access to an org number, and org numbers are public and tenant-editable, so while more than one live (non-archived) company claims the same 12-digit org number NO company may verify, mint a deep link, or be granted by the nightly sync on it (409 ORG_NUMBER_CONTESTED; cron counts them as contested and changes nothing). This does not re-add the company-creation org-number guard (org-number reuse stays allowed); it only fences the one feature where the org number is the authority boundary. Also: the cron honours summarizeGrants.recognized (unknown role codes = pinning problem, never a denial), mirroring probeViaOmbudsregister.
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { useRealtimeSupabase } from '@/lib/hooks/use-realtime-supabase'
|
||||
import { useRangeSelect } from '@/lib/hooks/use-range-select'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { resolveDetachErrorMessage } from '@/components/transactions/detach-underlag'
|
||||
import { cn, formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, SupplierInvoice, Supplier, VatTreatment, EntityType, LinePatternEntry, BookingTemplateLibrary } from '@/types'
|
||||
@@ -316,6 +317,7 @@ export default function TransactionsPage() {
|
||||
const companyId = company?.id ?? null
|
||||
const searchParams = useSearchParams()
|
||||
const t = useTranslations('transactions')
|
||||
const tDetach = useTranslations('tx_detach')
|
||||
const [transactions, setTransactions] = useState<TransactionWithInvoice[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [mode, setMode] = useState<ViewMode>('inbox')
|
||||
@@ -3063,6 +3065,61 @@ export default function TransactionsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Ta bort underlag" (#2132): confirm, then DELETE attach-document. On
|
||||
* success the pin is cleared in the list, in the attach dialog's own row
|
||||
* snapshot, and in the inbox card's optimistic override. A 409 (doc already
|
||||
* on a verifikation, or the row changed under us) shows the route's Swedish
|
||||
* message verbatim; other failures map through get-error-message.
|
||||
*/
|
||||
async function handleDetachDocument(tx: TransactionWithInvoice) {
|
||||
// Same idiom as Ignorera: the pin is reversible (re-attach), but the
|
||||
// indicator vanishes immediately, so confirm before the write.
|
||||
const ok = await confirm({
|
||||
title: tDetach('confirm_title'),
|
||||
description: tDetach('confirm_body', {
|
||||
description: tx.description,
|
||||
amount: formatCurrency(tx.amount, tx.currency),
|
||||
}),
|
||||
confirmLabel: tDetach('confirm_label'),
|
||||
cancelLabel: tDetach('cancel_label'),
|
||||
variant: 'warning',
|
||||
})
|
||||
if (!ok) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/transactions/${tx.id}/attach-document`, { method: 'DELETE' })
|
||||
const result: unknown = await res.json().catch(() => null)
|
||||
const errorBody = (result as { error?: unknown } | null)?.error
|
||||
if (!res.ok || errorBody) {
|
||||
// 409 = the doc already became räkenskapsinformation: the route's
|
||||
// Swedish message says so and names the storno path; keep it verbatim.
|
||||
toast({
|
||||
title: tDetach('toast_failed'),
|
||||
description: resolveDetachErrorMessage(res.status, result),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
setTransactions((prev) =>
|
||||
prev.map((row) => (row.id === tx.id ? { ...row, document_id: null } : row))
|
||||
)
|
||||
// The attach dialog renders its "already attached" hint off its own
|
||||
// snapshot of the row, not the list.
|
||||
setAttachDocTx((prev) => (prev?.id === tx.id ? { ...prev, document_id: null } : prev))
|
||||
// Drop the inbox card's optimistic "attached" override (set by the
|
||||
// upload path through the matching -linked event).
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('Accounted:transaction-document-unlinked', {
|
||||
detail: { transaction_id: tx.id },
|
||||
})
|
||||
)
|
||||
toast({ title: tDetach('toast_done') })
|
||||
} catch {
|
||||
toast({ title: tDetach('toast_failed'), variant: 'destructive' })
|
||||
}
|
||||
}
|
||||
|
||||
// Batch mode handlers
|
||||
|
||||
// Bounded pool for the per-row batch requests: parallel enough that a
|
||||
@@ -3952,6 +4009,7 @@ export default function TransactionsPage() {
|
||||
onOpenSplitMatch={openSplitMatchDialog}
|
||||
onOpenMatchVoucher={openMatchVoucherDialog}
|
||||
onOpenAttachDocument={openAttachDocumentDialog}
|
||||
onDetachDocument={handleDetachDocument}
|
||||
onOpenCategoryDialog={openCategoryDialog}
|
||||
onDelete={handleDeleteTransaction}
|
||||
onIgnore={handleIgnoreTransaction}
|
||||
@@ -4015,6 +4073,7 @@ export default function TransactionsPage() {
|
||||
onOpenMatchDialog={openMatchDialog}
|
||||
onOpenCategoryDialog={openCategoryDialog}
|
||||
onOpenAttachDocument={openAttachDocumentDialog}
|
||||
onDetachDocument={handleDetachDocument}
|
||||
onOpenMatchVoucher={openMatchVoucherDialog}
|
||||
onDelete={handleDeleteTransaction}
|
||||
onSkvBokfor={handleSkvBokfor}
|
||||
@@ -4130,6 +4189,7 @@ export default function TransactionsPage() {
|
||||
}}
|
||||
transaction={attachDocTx}
|
||||
onAttached={handleDocumentAttached}
|
||||
onDetach={handleDetachDocument}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
createQueuedMockSupabase,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
const { supabase: mockSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
@@ -324,12 +324,16 @@ describe('DELETE /api/transactions/[id]/attach-document', () => {
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(res)
|
||||
expect(status).toBe(409)
|
||||
expect(body.error).toContain('verifikation')
|
||||
// Nothing is written on the immutability path.
|
||||
expect(findCalls('invoice_inbox_items', 'update')).toEqual([])
|
||||
expect(findCalls('transactions', 'update')).toEqual([])
|
||||
})
|
||||
|
||||
it('clears document_id when no journal entry link', async () => {
|
||||
enqueue({ data: { id: 'tx-1', document_id: 'doc-1' }, error: null }) // tx fetch
|
||||
enqueue({ data: { journal_entry_id: null }, error: null }) // doc fetch
|
||||
enqueue({ data: null, error: null }) // update
|
||||
enqueue({ data: null, error: null }) // inbox unlink
|
||||
enqueue({ data: { id: 'tx-1' }, error: null }) // pin CAS (RETURNING id)
|
||||
const res = await DELETE(makeReq(null, 'DELETE'), createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ data: { document_id: string | null } }>(res)
|
||||
expect(status).toBe(200)
|
||||
@@ -338,9 +342,78 @@ describe('DELETE /api/transactions/[id]/attach-document', () => {
|
||||
|
||||
it('clears document_id when no doc was attached', async () => {
|
||||
enqueue({ data: { id: 'tx-1', document_id: null }, error: null }) // tx fetch
|
||||
enqueue({ data: null, error: null }) // update
|
||||
enqueue({ data: null, error: null }) // inbox unlink
|
||||
enqueue({ data: { id: 'tx-1' }, error: null }) // pin CAS
|
||||
const res = await DELETE(makeReq(null, 'DELETE'), createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(200)
|
||||
// The back-link is released even with nothing pinned (a stale item from
|
||||
// the replace path would re-anchor just the same), and the CAS then
|
||||
// requires the pin to still be empty.
|
||||
expect(findCalls('invoice_inbox_items', 'update')).toEqual([[{ matched_transaction_id: null }]])
|
||||
expect(findCalls('transactions', 'is')).toContainEqual(['document_id', null])
|
||||
})
|
||||
|
||||
it('releases the inbox back-link BEFORE the pin, scoped by transaction (else booking re-anchors it)', async () => {
|
||||
// propagateUnderlagForBookedTransaction selects inbox items by
|
||||
// matched_transaction_id at categorize time; a stale back-link would pin
|
||||
// the rejected receipt onto the new verifikation as immutable underlag.
|
||||
enqueue({ data: { id: 'tx-1', document_id: 'doc-1' }, error: null }) // tx fetch
|
||||
enqueue({ data: { journal_entry_id: null }, error: null }) // doc fetch
|
||||
enqueue({ data: null, error: null }) // inbox unlink
|
||||
enqueue({ data: { id: 'tx-1' }, error: null }) // pin CAS
|
||||
const res = await DELETE(makeReq(null, 'DELETE'), createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status } = await parseJsonResponse(res)
|
||||
expect(status).toBe(200)
|
||||
expect(findCalls('invoice_inbox_items', 'update')).toEqual([[{ matched_transaction_id: null }]])
|
||||
const eqArgs = findCalls('invoice_inbox_items', 'eq')
|
||||
// Scoped by transaction (unique index: at most one item points here), not
|
||||
// by the pinned doc, so a stale item from the replace path is cleared too.
|
||||
expect(eqArgs).toContainEqual(['matched_transaction_id', 'tx-1'])
|
||||
expect(eqArgs).toContainEqual(['company_id', 'company-1'])
|
||||
expect(eqArgs).not.toContainEqual(['document_id', 'doc-1'])
|
||||
// Items already consumed by a verifikat are left alone.
|
||||
expect(findCalls('invoice_inbox_items', 'is')).toContainEqual(['created_journal_entry_id', null])
|
||||
// Order: the unlink table is touched before the pin update.
|
||||
const tables = mockSupabase.from.mock.calls.map((c) => c[0])
|
||||
expect(tables.indexOf('invoice_inbox_items')).toBeLessThan(tables.lastIndexOf('transactions'))
|
||||
// Compare-and-set: the pin is cleared only if it is still doc-1.
|
||||
expect(findCalls('transactions', 'eq')).toContainEqual(['document_id', 'doc-1'])
|
||||
expect(findCalls('transactions', 'update')).toEqual([[{ document_id: null }]])
|
||||
})
|
||||
|
||||
it('refuses with 409 when the pin changed under us (concurrent re-attach)', async () => {
|
||||
// Interleaving: we read doc-1, a POST pins doc-2 (and links its inbox
|
||||
// item) before our CAS runs. Zero rows come back: the new pin is kept and
|
||||
// the caller is told nothing happened, instead of a success for a state
|
||||
// that is now "doc-2 pinned, doc-2 inbox item unlinked".
|
||||
enqueue({ data: { id: 'tx-1', document_id: 'doc-1' }, error: null }) // tx fetch
|
||||
enqueue({ data: { journal_entry_id: null }, error: null }) // doc fetch
|
||||
enqueue({ data: null, error: null }) // inbox unlink
|
||||
enqueue({ data: null, error: null }) // pin CAS: 0 rows
|
||||
const res = await DELETE(makeReq(null, 'DELETE'), createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(res)
|
||||
expect(status).toBe(409)
|
||||
expect(body.error).toContain('samtidigt')
|
||||
})
|
||||
|
||||
it('reports a failing inbox unlink as 500 with nothing changed, logging a coded cause only', async () => {
|
||||
// A stale back-link is the exact defect the detach exists to prevent, so
|
||||
// a 200 here would hide a compliance hazard. Because the unlink runs
|
||||
// first, the pin is untouched and a retry is trivially idempotent.
|
||||
enqueue({ data: { id: 'tx-1', document_id: 'doc-1' }, error: null }) // tx fetch
|
||||
enqueue({ data: { journal_entry_id: null }, error: null }) // doc fetch
|
||||
enqueue({ data: null, error: { code: '42501', message: 'rls denied: row values here' } }) // unlink fails
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const res = await DELETE(makeReq(null, 'DELETE'), createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(res)
|
||||
expect(status).toBe(500)
|
||||
expect(body.error).toContain('fortfarande kopplat')
|
||||
expect(findCalls('transactions', 'update')).toEqual([])
|
||||
// Raw driver messages can quote row values: only the coded cause is logged.
|
||||
expect(spy).toHaveBeenCalledWith('[attach-document] Failed to unlink inbox item:', {
|
||||
cause: '42501',
|
||||
})
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { AttachDocumentSchema } from '@/lib/api/schemas'
|
||||
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
||||
import { errorCauseTag } from '@/lib/errors/db-error'
|
||||
import {
|
||||
completeInboxItemsForBookedTransaction,
|
||||
resolveVoucherLinkedEntryIds,
|
||||
@@ -220,6 +221,9 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
*
|
||||
* Detach a document from a transaction.
|
||||
*
|
||||
* Also undoes the invoice_inbox_items back-link written by POST, so the next
|
||||
* booking does not re-anchor the detached doc (see propagateUnderlag...).
|
||||
*
|
||||
* Blocked once the document has propagated into a journal entry (BFL 5 kap 6 §
|
||||
* räkenskapsinformation immutability): at that point the doc is the
|
||||
* verifikation's underlag and can only be undone by reversing the entry.
|
||||
@@ -258,11 +262,57 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
}
|
||||
}
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
// Step 1: release the inbox back-link the POST path wrote, BEFORE the pin.
|
||||
// Without this the item still says matched_transaction_id = this tx, and
|
||||
// the next categorize / book / bulk-book runs
|
||||
// propagateUnderlagForBookedTransaction, which anchors the DETACHED
|
||||
// document onto the new verifikation as immutable underlag (BFL 5 kap 7 §:
|
||||
// the verifikat would cite a receipt the user rejected). Scoped by
|
||||
// transaction, not by the pinned doc: the unique index on
|
||||
// matched_transaction_id means at most one item points here, and a stale
|
||||
// item whose doc differs from the pin (replace path) would re-anchor just
|
||||
// the same. Items already consumed by a verifikat are left alone.
|
||||
//
|
||||
// Ordering: unlink first, then compare-and-set the pin (step 2). A POST
|
||||
// that lands in between re-pins a new doc and its back-link, and the CAS
|
||||
// below then refuses to clobber it (409), so the two writes cannot end up
|
||||
// as "new doc pinned, its inbox item unlinked". A failed unlink returns
|
||||
// before anything changed, so a retry is trivially idempotent.
|
||||
const { error: inboxUnlinkErr } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({ matched_transaction_id: null })
|
||||
.eq('company_id', companyId)
|
||||
.eq('matched_transaction_id', transactionId)
|
||||
.is('created_journal_entry_id', null)
|
||||
if (inboxUnlinkErr) {
|
||||
// Not best-effort: a stale back-link is the exact defect this detach
|
||||
// exists to prevent, so a "succeeded" response would hide a compliance
|
||||
// hazard. Coded cause only: raw driver messages can quote row values.
|
||||
console.error('[attach-document] Failed to unlink inbox item:', {
|
||||
cause: errorCauseTag(inboxUnlinkErr),
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Inkorgsposten kunde inte släppas, så underlaget är fortfarande kopplat. Försök igen.',
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
|
||||
// Step 2: clear the pin, but only if it is still the doc we read above
|
||||
// (or still empty). A concurrent POST that re-pinned in the meantime wins:
|
||||
// we must not detach a document the user just attached, nor report
|
||||
// success for a detach that did not happen.
|
||||
let clearPin = supabase
|
||||
.from('transactions')
|
||||
.update({ document_id: null })
|
||||
.eq('id', transactionId)
|
||||
.eq('company_id', companyId)
|
||||
clearPin = tx.document_id
|
||||
? clearPin.eq('document_id', tx.document_id)
|
||||
: clearPin.is('document_id', null)
|
||||
const { data: cleared, error: updateError } = await clearPin.select('id').maybeSingle()
|
||||
|
||||
if (updateError) {
|
||||
// The enforce_transactions_document_immutability trigger raises a
|
||||
@@ -284,6 +334,16 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
return NextResponse.json({ error: 'Failed to detach document' }, { status: 500 })
|
||||
}
|
||||
|
||||
if (!cleared) {
|
||||
// Zero rows: the pin changed under us (concurrent attach) or the row
|
||||
// vanished. Either way this detach did not happen; say so instead of
|
||||
// reporting success for someone else's state.
|
||||
return NextResponse.json(
|
||||
{ error: 'Transaktionen ändrades samtidigt. Ladda om sidan och försök igen.' },
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { transaction_id: transactionId, document_id: null } })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
|
||||
@@ -25,6 +25,9 @@ interface TransactionAttachDocumentDialogProps {
|
||||
onOpenChange: (open: boolean) => void
|
||||
transaction: TransactionWithInvoice | null
|
||||
onAttached: (transactionId: string, documentId: string) => void
|
||||
/** Detach the current pin (#2132). The page owns confirm + DELETE; the
|
||||
* dialog only offers the entry point next to the already-attached hint. */
|
||||
onDetach?: (transaction: TransactionWithInvoice) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,8 +43,10 @@ export default function TransactionAttachDocumentDialog({
|
||||
onOpenChange,
|
||||
transaction,
|
||||
onAttached,
|
||||
onDetach,
|
||||
}: TransactionAttachDocumentDialogProps) {
|
||||
const t = useTranslations('tx_attach_dialog')
|
||||
const tDetach = useTranslations('tx_detach')
|
||||
const { toast } = useToast()
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
|
||||
const [pickedDoc, setPickedDoc] = useState<AvailableInboxDoc | null>(null)
|
||||
@@ -144,7 +149,24 @@ export default function TransactionAttachDocumentDialog({
|
||||
</div>
|
||||
|
||||
{transaction.document_id && (
|
||||
<p className="text-xs text-muted-foreground">{t('already_attached_hint')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('already_attached_hint')}
|
||||
{onDetach && !transaction.journal_entry_id && (
|
||||
<>
|
||||
{' '}
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto p-0 text-xs"
|
||||
disabled={isAttaching}
|
||||
onClick={() => onDetach(transaction)}
|
||||
>
|
||||
{tDetach('menu_item')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -28,10 +28,12 @@ import {
|
||||
MoreHorizontal,
|
||||
Paperclip,
|
||||
Trash2,
|
||||
Unlink,
|
||||
} from 'lucide-react'
|
||||
import { TransactionAttachmentIndicator } from './TransactionAttachmentIndicator'
|
||||
import CorrectionAffordance from '@/components/bookkeeping/CorrectionAffordance'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { canDetachDocument } from './detach-underlag'
|
||||
import type { JeUnderlagStatus } from '@/lib/transactions/underlag-status'
|
||||
import type { TransactionWithInvoice, HistoryFilter, SourceFilter } from './transaction-types'
|
||||
import type {
|
||||
@@ -60,6 +62,8 @@ interface TransactionHistoryListProps {
|
||||
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
|
||||
/** Open the attach-underlag dialog (pin an inbox doc / fresh upload). */
|
||||
onOpenAttachDocument?: (transaction: TransactionWithInvoice) => void
|
||||
/** Detach the pinned underlag. Unbooked rows only (see canDetachDocument). */
|
||||
onDetachDocument?: (transaction: TransactionWithInvoice) => void
|
||||
/** Open the match-against-existing-voucher dialog. Unbooked rows can end up
|
||||
* here (not in the inbox) when is_business is already set, e.g. after a
|
||||
* voucher was removed without a full uncategorize; without this item such
|
||||
@@ -88,6 +92,7 @@ export default function TransactionHistoryList({
|
||||
onOpenMatchDialog,
|
||||
onOpenCategoryDialog,
|
||||
onOpenAttachDocument,
|
||||
onDetachDocument,
|
||||
onOpenMatchVoucher,
|
||||
onDelete,
|
||||
onSkvBokfor,
|
||||
@@ -191,6 +196,7 @@ export default function TransactionHistoryList({
|
||||
onOpenMatchDialog={onOpenMatchDialog}
|
||||
onOpenCategoryDialog={onOpenCategoryDialog}
|
||||
onOpenAttachDocument={onOpenAttachDocument}
|
||||
onDetachDocument={onDetachDocument}
|
||||
onOpenMatchVoucher={onOpenMatchVoucher}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
@@ -238,6 +244,7 @@ function BankHistoryRow({
|
||||
onOpenMatchDialog,
|
||||
onOpenCategoryDialog,
|
||||
onOpenAttachDocument,
|
||||
onDetachDocument,
|
||||
onOpenMatchVoucher,
|
||||
onDelete,
|
||||
}: {
|
||||
@@ -247,10 +254,12 @@ function BankHistoryRow({
|
||||
onOpenMatchDialog: (transaction: TransactionWithInvoice) => void
|
||||
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
|
||||
onOpenAttachDocument?: (transaction: TransactionWithInvoice) => void
|
||||
onDetachDocument?: (transaction: TransactionWithInvoice) => void
|
||||
onOpenMatchVoucher?: (transaction: TransactionWithInvoice) => void
|
||||
onDelete?: (id: string) => void
|
||||
}) {
|
||||
const t = useTranslations('tx_history')
|
||||
const tDetach = useTranslations('tx_detach')
|
||||
// Viewers must not see write affordances. CorrectionAffordance opens a
|
||||
// dialog that stages a storno + correction journal entry; the API path
|
||||
// already 403s for viewers but rendering the trigger creates a confusing
|
||||
@@ -273,11 +282,18 @@ function BankHistoryRow({
|
||||
const hasJeDoc = jeStatus === 'has'
|
||||
const missingUnderlag = isBooked && !transaction.document_id && jeStatus === 'missing'
|
||||
const showAttachItem = canWrite && !!onOpenAttachDocument
|
||||
// Detach is narrower than attach: only unbooked rows, and only with a pin.
|
||||
const showDetachItem = canDetachDocument({
|
||||
isBooked,
|
||||
canWrite,
|
||||
documentId: transaction.document_id,
|
||||
hasHandler: !!onDetachDocument,
|
||||
})
|
||||
// Same affordance as the inbox card: an unbooked row may need to be linked
|
||||
// to an already-booked voucher (e.g. the other leg of a transfer).
|
||||
const showMatchVoucherItem = canWrite && !isBooked && !!onOpenMatchVoucher
|
||||
const showOverflowMenu =
|
||||
hasInvoiceMatch || (canDelete && !!onDelete) || (isBooked && canWrite) || showAttachItem || showMatchVoucherItem
|
||||
hasInvoiceMatch || (canDelete && !!onDelete) || (isBooked && canWrite) || showAttachItem || showDetachItem || showMatchVoucherItem
|
||||
|
||||
const isPrivate = transaction.is_business === false
|
||||
const categoryLabel =
|
||||
@@ -418,6 +434,12 @@ function BankHistoryRow({
|
||||
{t('attach_document')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showDetachItem && (
|
||||
<DropdownMenuItem onSelect={() => onDetachDocument!(transaction)}>
|
||||
<Unlink className="h-3.5 w-3.5" />
|
||||
{tDetach('menu_item')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isBooked && canWrite && transaction.journal_entry_id && (
|
||||
<CorrectionAffordance journalEntryId={transaction.journal_entry_id}>
|
||||
{({ open, isLoading }) => (
|
||||
@@ -429,7 +451,7 @@ function BankHistoryRow({
|
||||
)}
|
||||
{canDelete && onDelete && (
|
||||
<>
|
||||
{(hasInvoiceMatch || showAttachItem || showMatchVoucherItem) && <DropdownMenuSeparator />}
|
||||
{(hasInvoiceMatch || showAttachItem || showDetachItem || showMatchVoucherItem) && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onDelete(transaction.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Pencil,
|
||||
Split,
|
||||
Trash2,
|
||||
Unlink,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -39,6 +40,7 @@ import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-exten
|
||||
const HAS_AI_EXTRACTION = ENABLED_EXTENSION_IDS.has('document-extraction')
|
||||
import { TransactionAttachmentIndicator } from './TransactionAttachmentIndicator'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { canDetachDocument } from './detach-underlag'
|
||||
import type { TransactionWithInvoice, CategorizeHandler } from './transaction-types'
|
||||
import type { CashAccount } from '@/types'
|
||||
|
||||
@@ -73,6 +75,9 @@ interface TransactionInboxCardProps {
|
||||
/** Open the attach-underlag dialog: pin an inbox document or a fresh upload
|
||||
* to the transaction (the tx→doc mirror of the Documents view's matcher). */
|
||||
onOpenAttachDocument?: (transaction: TransactionWithInvoice) => void
|
||||
/** Detach the pinned underlag (DELETE attach-document). Unbooked rows only:
|
||||
* once the doc has propagated onto a verifikation the route answers 409. */
|
||||
onDetachDocument?: (transaction: TransactionWithInvoice) => void
|
||||
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
|
||||
onDelete?: (id: string) => void
|
||||
/** Mark the transaction as ignored so it leaves the inbox without a journal entry. */
|
||||
@@ -112,6 +117,7 @@ export default function TransactionInboxCard({
|
||||
onOpenSplitMatch,
|
||||
onOpenMatchVoucher,
|
||||
onOpenAttachDocument,
|
||||
onDetachDocument,
|
||||
onOpenCategoryDialog,
|
||||
onDelete,
|
||||
onIgnore,
|
||||
@@ -122,6 +128,7 @@ export default function TransactionInboxCard({
|
||||
preMigrationCutoff = null,
|
||||
}: TransactionInboxCardProps) {
|
||||
const t = useTranslations('tx_inbox_card')
|
||||
const tDetach = useTranslations('tx_detach')
|
||||
const tMethod = useTranslations('tx_method')
|
||||
// Radix' onCheckedChange carries no mouse event, so the shift state is
|
||||
// captured from the click that precedes it (Radix composes our onClick
|
||||
@@ -146,8 +153,19 @@ export default function TransactionInboxCard({
|
||||
if (!detail || detail.transaction_id !== transaction.id || !detail.document_id) return
|
||||
setOptimisticDocumentId(detail.document_id)
|
||||
}
|
||||
// Detach (page handler) drops the override too, or the indicator would
|
||||
// keep showing a doc the row no longer carries.
|
||||
function onUnlinked(e: Event) {
|
||||
const detail = (e as CustomEvent<{ transaction_id?: string }>).detail
|
||||
if (!detail || detail.transaction_id !== transaction.id) return
|
||||
setOptimisticDocumentId(null)
|
||||
}
|
||||
window.addEventListener('Accounted:transaction-document-linked', onLinked)
|
||||
return () => window.removeEventListener('Accounted:transaction-document-linked', onLinked)
|
||||
window.addEventListener('Accounted:transaction-document-unlinked', onUnlinked)
|
||||
return () => {
|
||||
window.removeEventListener('Accounted:transaction-document-linked', onLinked)
|
||||
window.removeEventListener('Accounted:transaction-document-unlinked', onUnlinked)
|
||||
}
|
||||
}, [transaction.id])
|
||||
const attachedDocumentId =
|
||||
optimisticDocumentId ?? (transaction as { document_id?: string | null }).document_id ?? null
|
||||
@@ -218,6 +236,15 @@ export default function TransactionInboxCard({
|
||||
// "Matcha mot underlag": pin an inbox doc / fresh upload to the tx. The
|
||||
// tx→doc mirror of the Documents view's "Matcha mot transaktion".
|
||||
const showAttachDocumentItem = isUnbooked && canWrite && !!onOpenAttachDocument
|
||||
// Same gate as attach, plus an actual pin to remove.
|
||||
const showDetachDocumentItem =
|
||||
showAttachDocumentItem &&
|
||||
canDetachDocument({
|
||||
isBooked: !isUnbooked,
|
||||
canWrite,
|
||||
documentId: attachedDocumentId,
|
||||
hasHandler: !!onDetachDocument,
|
||||
})
|
||||
const showSplitItem = showInvoiceMatchButton && !!onOpenSplitMatch
|
||||
const showEditItem = isTitleEditable && !!onEditTitle
|
||||
// Moving between cash accounts only makes sense with somewhere to move TO,
|
||||
@@ -423,6 +450,17 @@ export default function TransactionInboxCard({
|
||||
{t('attach_document_btn')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showDetachDocumentItem && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDetachDocument!(transaction)
|
||||
}}
|
||||
>
|
||||
<Unlink className="h-4 w-4" />
|
||||
{tDetach('menu_item')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showSplitItem && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { canDetachDocument, resolveDetachErrorMessage } from '../detach-underlag'
|
||||
|
||||
/**
|
||||
* "Ta bort underlag" on a transaction (#2132).
|
||||
*
|
||||
* Vitest runs in the `node` environment here and never renders components,
|
||||
* so the menu-item gate and the error mapping live in a pure helper both
|
||||
* row components and the page call. The rendering side is pinned with
|
||||
* file-level assertions, like the sibling dialog tests: the components must
|
||||
* route through the helper, and the strings must exist in both locales.
|
||||
*/
|
||||
|
||||
const ROOT = path.resolve(__dirname, '../../..')
|
||||
const read = (rel: string) => fs.readFileSync(path.resolve(ROOT, rel), 'utf8')
|
||||
const readMessages = (locale: 'sv' | 'en', namespace: string) =>
|
||||
(JSON.parse(read(`messages/${locale}.json`)) as Record<string, Record<string, string>>)[
|
||||
namespace
|
||||
]
|
||||
|
||||
const ROUTE_409_MESSAGE =
|
||||
'Bilagan är kopplad till en bokförd verifikation och kan inte tas bort. Storno verifikationen först.'
|
||||
|
||||
describe('canDetachDocument', () => {
|
||||
const base = { isBooked: false, canWrite: true, documentId: 'doc-1', hasHandler: true }
|
||||
|
||||
it('shows the item on an unbooked, writable row that carries a pin', () => {
|
||||
expect(canDetachDocument(base)).toBe(true)
|
||||
})
|
||||
|
||||
it('hides the item once the row is booked (the route would answer 409)', () => {
|
||||
expect(canDetachDocument({ ...base, isBooked: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('hides the item for read-only members', () => {
|
||||
expect(canDetachDocument({ ...base, canWrite: false })).toBe(false)
|
||||
})
|
||||
|
||||
it('hides the item when there is nothing to detach', () => {
|
||||
expect(canDetachDocument({ ...base, documentId: null })).toBe(false)
|
||||
expect(canDetachDocument({ ...base, documentId: undefined })).toBe(false)
|
||||
expect(canDetachDocument({ ...base, documentId: '' })).toBe(false)
|
||||
})
|
||||
|
||||
it('hides the item when no handler is wired', () => {
|
||||
expect(canDetachDocument({ ...base, hasHandler: false })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveDetachErrorMessage', () => {
|
||||
it('renders the 409 server message unchanged', () => {
|
||||
expect(resolveDetachErrorMessage(409, { error: ROUTE_409_MESSAGE })).toBe(ROUTE_409_MESSAGE)
|
||||
})
|
||||
|
||||
it('does not treat a 409 without a message as verbatim', () => {
|
||||
const msg = resolveDetachErrorMessage(409, { error: '' })
|
||||
expect(typeof msg).toBe('string')
|
||||
expect(msg.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('maps other statuses through the shared translator to a Swedish message', () => {
|
||||
const msg = resolveDetachErrorMessage(500, { error: 'Failed to detach document' })
|
||||
expect(msg).not.toBe('Failed to detach document')
|
||||
expect(msg.length).toBeGreaterThan(0)
|
||||
expect(resolveDetachErrorMessage(404, null).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('detach affordance wiring', () => {
|
||||
const inboxCard = read('components/transactions/TransactionInboxCard.tsx')
|
||||
const historyList = read('components/transactions/TransactionHistoryList.tsx')
|
||||
const attachDialog = read('components/transactions/TransactionAttachDocumentDialog.tsx')
|
||||
const page = read('app/(dashboard)/transactions/page.tsx')
|
||||
|
||||
it('both row components gate the item through canDetachDocument', () => {
|
||||
expect(inboxCard).toContain('canDetachDocument({')
|
||||
expect(historyList).toContain('canDetachDocument({')
|
||||
expect(inboxCard).toContain("tDetach('menu_item')")
|
||||
expect(historyList).toContain("tDetach('menu_item')")
|
||||
})
|
||||
|
||||
it('the inbox card only offers detach under the attach gate (unbooked and canWrite)', () => {
|
||||
expect(inboxCard).toMatch(/showDetachDocumentItem =\s*showAttachDocumentItem &&/)
|
||||
})
|
||||
|
||||
it('the attach dialog offers detach only for unbooked rows', () => {
|
||||
expect(attachDialog).toContain('onDetach && !transaction.journal_entry_id')
|
||||
})
|
||||
|
||||
it('the page confirms before DELETE and renders 409 through the verbatim path', () => {
|
||||
expect(page).toContain("variant: 'warning'")
|
||||
expect(page).toContain("/attach-document`, { method: 'DELETE' }")
|
||||
expect(page).toContain('resolveDetachErrorMessage(res.status, result)')
|
||||
})
|
||||
|
||||
it('strings exist in both locales with the same keys', () => {
|
||||
const sv = readMessages('sv', 'tx_detach')
|
||||
const en = readMessages('en', 'tx_detach')
|
||||
const required = [
|
||||
'menu_item',
|
||||
'confirm_title',
|
||||
'confirm_body',
|
||||
'confirm_label',
|
||||
'cancel_label',
|
||||
'toast_done',
|
||||
'toast_failed',
|
||||
]
|
||||
for (const key of required) {
|
||||
expect(sv[key], `sv.tx_detach.${key}`).toBeTruthy()
|
||||
expect(en[key], `en.tx_detach.${key}`).toBeTruthy()
|
||||
}
|
||||
expect(Object.keys(sv).sort()).toEqual(Object.keys(en).sort())
|
||||
expect(sv.menu_item).toBe('Ta bort underlag')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
/**
|
||||
* Gate for the "Ta bort underlag" affordance on a transaction row (#2132).
|
||||
*
|
||||
* Mirrors DELETE /api/transactions/[id]/attach-document: once the document has
|
||||
* propagated onto a verifikation the route answers 409 (BFL 5 kap 6 §), so the
|
||||
* UI only offers the action to writers, on unbooked rows that carry a pin.
|
||||
*/
|
||||
export function canDetachDocument(input: {
|
||||
isBooked: boolean
|
||||
canWrite: boolean
|
||||
documentId: string | null | undefined
|
||||
hasHandler: boolean
|
||||
}): boolean {
|
||||
return !input.isBooked && input.canWrite && !!input.documentId && input.hasHandler
|
||||
}
|
||||
|
||||
/**
|
||||
* Toast description for a failed detach. A 409 carries the route's own
|
||||
* Swedish explanation (the doc is already räkenskapsinformation): surface it
|
||||
* verbatim. Everything else maps through the shared error translator.
|
||||
*/
|
||||
export function resolveDetachErrorMessage(status: number, body: unknown): string {
|
||||
const error = (body as { error?: unknown } | null | undefined)?.error
|
||||
if (status === 409 && typeof error === 'string' && error.trim()) return error
|
||||
return getErrorMessage(body, { context: 'transaction', statusCode: status })
|
||||
}
|
||||
@@ -3226,6 +3226,15 @@
|
||||
"error_toast": "Could not attach the document",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"tx_detach": {
|
||||
"menu_item": "Remove document",
|
||||
"confirm_title": "Remove the document?",
|
||||
"confirm_body": "The document will be unlinked from {description}, {amount}. The file is not deleted and can be attached again.",
|
||||
"confirm_label": "Remove document",
|
||||
"cancel_label": "Cancel",
|
||||
"toast_done": "Document removed",
|
||||
"toast_failed": "Could not remove the document"
|
||||
},
|
||||
"tx_underlag": {
|
||||
"attached_label": "Document",
|
||||
"attached_title": "Supporting document attached: click to open",
|
||||
|
||||
@@ -3226,6 +3226,15 @@
|
||||
"error_toast": "Kunde inte koppla underlaget",
|
||||
"cancel": "Avbryt"
|
||||
},
|
||||
"tx_detach": {
|
||||
"menu_item": "Ta bort underlag",
|
||||
"confirm_title": "Ta bort underlaget?",
|
||||
"confirm_body": "Underlaget kopplas loss från {description}, {amount}. Filen raderas inte och kan kopplas igen.",
|
||||
"confirm_label": "Ta bort underlag",
|
||||
"cancel_label": "Avbryt",
|
||||
"toast_done": "Underlaget borttaget",
|
||||
"toast_failed": "Kunde inte ta bort underlaget"
|
||||
},
|
||||
"tx_underlag": {
|
||||
"attached_label": "Underlag",
|
||||
"attached_title": "Underlag bifogat: klicka för att öppna",
|
||||
|
||||
Reference in New Issue
Block a user