feat(pending): bulk reject selected operations in granskning (#1085)
* feat(pending): bulk reject selected operations in granskning The granskning queue could approve selected operations in bulk but rejection was one row at a time. Adds: - POST /api/pending-operations/bulk-reject: one guarded UPDATE (status='pending' filter) so rows resolved in a parallel session are reported as skipped instead of being flipped; optional rejection_category/rejection_reason applied to every rejected row so agents still learn from bulk 'no'. No high-risk skip server-side: rejecting posts nothing to the ledger. - 'Avvisa valda' button next to 'Godkänn valda'; the existing reject dialog doubles as bulk confirmation (category + note apply to all). - Route tests: 401/403/400/500, not-found, already-handled skip, read-write race, happy path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pending): disable both bulk buttons while either bulk action is in flight Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
2908a951ab
commit
d860567976
@@ -243,3 +243,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-20] Follow-up (not done): delete_last_voucher RPC can delete the closing storno and flip the closing entry back to posted while closing_entry_id is already NULL, leaving an orphaned live closing entry; should refuse to delete stornos of year_end entries.
|
||||
[2026-07-20] Onboarding backdrop reuses marketing-site halftone webp assets copied into public/illustrations/ (not hotlinked, not regenerated): keeps app self-contained and signup->app visually continuous; decorative art uses plain <img> (physics sizes by %, next/image adds nothing for 1-35KB webp).
|
||||
[2026-07-20] Removed Dependabot entirely (.github/dependabot.yml deleted, open PRs #1083/#1082/#1012 closed) on Emil's request: weekly grouped bumps were noise and the #884 bedrock-sdk incident showed the risk profile. Dependency bumps are now manual/deliberate; the bedrock-sdk 0.29.1 exact pin stays enforced by scripts/checks/no-new-antipatterns.mjs.
|
||||
[2026-07-20] Bulk reject (/pending) reuses the exact bulk-approve selection set: high-risk and locked-period ops stay one-by-one for reject too, keeping one selection model instead of per-action eligibility. Server-side bulk-reject has NO high-risk skip (rejecting posts nothing), so the API stays permissive; the UI is the gate.
|
||||
|
||||
@@ -666,7 +666,8 @@ export default function PendingOperationsPage() {
|
||||
const [isBulkCommitting, setIsBulkCommitting] = useState(false)
|
||||
// Reject dialog state: separate from the generic destructive-confirm so we
|
||||
// can ask for a category + free-text reason that feeds back to the agent.
|
||||
const [rejectOp, setRejectOp] = useState<PendingOperation | null>(null)
|
||||
// 'bulk' targets the current checkbox selection instead of a single op.
|
||||
const [rejectTarget, setRejectTarget] = useState<PendingOperation | 'bulk' | null>(null)
|
||||
const [rejectCategory, setRejectCategory] = useState<PendingOperationRejectionCategory | ''>('')
|
||||
const [rejectReason, setRejectReason] = useState('')
|
||||
const [isRejecting, setIsRejecting] = useState(false)
|
||||
@@ -828,35 +829,63 @@ export default function PendingOperationsPage() {
|
||||
setIsBulkCommitting(false)
|
||||
}
|
||||
|
||||
function openRejectDialog(op: PendingOperation) {
|
||||
setRejectOp(op)
|
||||
function openRejectDialog(target: PendingOperation | 'bulk') {
|
||||
setRejectTarget(target)
|
||||
setRejectCategory('')
|
||||
setRejectReason('')
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
if (!rejectOp) return
|
||||
if (!rejectTarget) return
|
||||
setIsRejecting(true)
|
||||
try {
|
||||
const body =
|
||||
rejectCategory || rejectReason.trim()
|
||||
? {
|
||||
...(rejectCategory ? { rejection_category: rejectCategory } : {}),
|
||||
...(rejectReason.trim() ? { rejection_reason: rejectReason.trim() } : {}),
|
||||
}
|
||||
: undefined
|
||||
const res = await fetch(`/api/pending-operations/${rejectOp.id}/reject`, {
|
||||
method: 'POST',
|
||||
...(body
|
||||
? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
|
||||
: {}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => ({}))
|
||||
throw new Error(getErrorMessage(json, { statusCode: res.status }))
|
||||
const feedback = {
|
||||
...(rejectCategory ? { rejection_category: rejectCategory } : {}),
|
||||
...(rejectReason.trim() ? { rejection_reason: rejectReason.trim() } : {}),
|
||||
}
|
||||
toast({ title: 'Avvisad', description: rejectOp.title })
|
||||
setRejectOp(null)
|
||||
|
||||
if (rejectTarget === 'bulk') {
|
||||
const res = await fetch('/api/pending-operations/bulk-reject', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids: Array.from(selectedIds), ...feedback }),
|
||||
})
|
||||
const json = await res.json().catch(() => ({}))
|
||||
if (!res.ok) throw new Error(getErrorMessage(json, { statusCode: res.status }))
|
||||
|
||||
const summary = json.data?.summary as
|
||||
| { rejected: number; skipped: number; failed: number }
|
||||
| undefined
|
||||
if (summary) {
|
||||
const parts: string[] = []
|
||||
if (summary.rejected > 0) parts.push(`${summary.rejected} avvisade`)
|
||||
if (summary.skipped > 0) parts.push(`${summary.skipped} hoppades över`)
|
||||
if (summary.failed > 0) parts.push(`${summary.failed} misslyckades`)
|
||||
toast({
|
||||
title: summary.failed > 0 ? 'Klart med fel' : 'Avvisade',
|
||||
description: parts.join(', '),
|
||||
variant: summary.failed > 0 ? 'destructive' : 'default',
|
||||
})
|
||||
} else {
|
||||
toast({ title: 'Avvisade' })
|
||||
}
|
||||
setSelectedIds(new Set())
|
||||
} else {
|
||||
const hasFeedback = Object.keys(feedback).length > 0
|
||||
const res = await fetch(`/api/pending-operations/${rejectTarget.id}/reject`, {
|
||||
method: 'POST',
|
||||
...(hasFeedback
|
||||
? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(feedback) }
|
||||
: {}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => ({}))
|
||||
throw new Error(getErrorMessage(json, { statusCode: res.status }))
|
||||
}
|
||||
toast({ title: 'Avvisad', description: rejectTarget.title })
|
||||
}
|
||||
|
||||
setRejectTarget(null)
|
||||
fetchOperations()
|
||||
fetchAllCounts()
|
||||
} catch (err) {
|
||||
@@ -1094,8 +1123,19 @@ export default function PendingOperationsPage() {
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 px-3 text-xs"
|
||||
disabled={selectedCount === 0 || isBulkCommitting}
|
||||
disabled={selectedCount === 0 || isRejecting || isBulkCommitting}
|
||||
onClick={() => openRejectDialog('bulk')}
|
||||
>
|
||||
{selectedCount > 0
|
||||
? t('reject_selected', { count: selectedCount })
|
||||
: t('reject_selected_none')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
disabled={selectedCount === 0 || isBulkCommitting || isRejecting}
|
||||
onClick={() => setShowBulkDialog(true)}
|
||||
>
|
||||
{selectedCount > 0
|
||||
@@ -1302,13 +1342,20 @@ export default function PendingOperationsPage() {
|
||||
</ConfirmationDialog>
|
||||
|
||||
{/* Reject dialog: category + free-text reason. Both optional so the user
|
||||
can still reject quickly without filling anything in. */}
|
||||
<Dialog open={rejectOp != null} onOpenChange={(open) => { if (!open) setRejectOp(null) }}>
|
||||
can still reject quickly without filling anything in. Doubles as the
|
||||
bulk-reject confirmation; the feedback then applies to every selected op. */}
|
||||
<Dialog open={rejectTarget != null} onOpenChange={(open) => { if (!open) setRejectTarget(null) }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Avvisa operation</DialogTitle>
|
||||
<DialogTitle>
|
||||
{rejectTarget === 'bulk'
|
||||
? t('reject_bulk_title', { count: selectedCount })
|
||||
: 'Avvisa operation'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{rejectOp?.title}
|
||||
{rejectTarget === 'bulk'
|
||||
? t('reject_bulk_description')
|
||||
: rejectTarget?.title}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
@@ -1348,11 +1395,17 @@ export default function PendingOperationsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRejectOp(null)} disabled={isRejecting}>
|
||||
<Button variant="outline" onClick={() => setRejectTarget(null)} disabled={isRejecting}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleReject} disabled={isRejecting}>
|
||||
{isRejecting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Avvisa'}
|
||||
{isRejecting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : rejectTarget === 'bulk' ? (
|
||||
t('reject_count', { count: selectedCount })
|
||||
) : (
|
||||
'Avvisa'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createQueuedMockSupabase,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
const VALID_ID_1 = '11111111-1111-4111-8111-111111111111'
|
||||
const VALID_ID_2 = '22222222-2222-4222-8222-222222222222'
|
||||
const VALID_ID_3 = '33333333-3333-4333-8333-333333333333'
|
||||
|
||||
type ResultBody = {
|
||||
data: {
|
||||
results: Array<{ id: string; status: string; error?: string }>
|
||||
summary: { total: number; rejected: number; skipped: number; failed: number }
|
||||
}
|
||||
}
|
||||
|
||||
describe('POST /api/pending-operations/bulk-reject', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase: mockSupabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/pending-operations/bulk-reject', {
|
||||
method: 'POST',
|
||||
body: { ids: [VALID_ID_1] },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns 403 for a viewer without write permission', async () => {
|
||||
requireWriteMock.mockResolvedValue({
|
||||
ok: false,
|
||||
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/pending-operations/bulk-reject', {
|
||||
method: 'POST',
|
||||
body: { ids: [VALID_ID_1] },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns 400 when ids array is empty', async () => {
|
||||
const request = createMockRequest('/api/pending-operations/bulk-reject', {
|
||||
method: 'POST',
|
||||
body: { ids: [] },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 400 for an unknown rejection_category', async () => {
|
||||
const request = createMockRequest('/api/pending-operations/bulk-reject', {
|
||||
method: 'POST',
|
||||
body: { ids: [VALID_ID_1], rejection_category: 'not_a_category' },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 500 when fetching pending operations fails', async () => {
|
||||
enqueue({ data: null, error: { message: 'db connection lost' } })
|
||||
|
||||
const request = createMockRequest('/api/pending-operations/bulk-reject', {
|
||||
method: 'POST',
|
||||
body: { ids: [VALID_ID_1] },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
// Raw Supabase messages never reach the response field.
|
||||
expect(body.error).toBe('Åtgärderna kunde inte hämtas. Försök igen.')
|
||||
})
|
||||
|
||||
it('reports per-item not-found as failed without running an update', async () => {
|
||||
// Only the fetch result is enqueued: with no pending ids the route must
|
||||
// not issue the UPDATE query at all.
|
||||
enqueue({ data: [] })
|
||||
|
||||
const request = createMockRequest('/api/pending-operations/bulk-reject', {
|
||||
method: 'POST',
|
||||
body: { ids: [VALID_ID_1] },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<ResultBody>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.results).toEqual([
|
||||
{ id: VALID_ID_1, status: 'failed', error: 'Åtgärden kunde inte hittas.' },
|
||||
])
|
||||
expect(body.data.summary).toEqual({ total: 1, rejected: 0, skipped: 0, failed: 1 })
|
||||
})
|
||||
|
||||
it('skips already-handled operations with a Swedish status label', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: VALID_ID_1, status: 'committed' },
|
||||
{ id: VALID_ID_2, status: 'rejected' },
|
||||
],
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/pending-operations/bulk-reject', {
|
||||
method: 'POST',
|
||||
body: { ids: [VALID_ID_1, VALID_ID_2] },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<ResultBody>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.results).toEqual([
|
||||
{ id: VALID_ID_1, status: 'skipped', error: 'Redan hanterad (godkänd)' },
|
||||
{ id: VALID_ID_2, status: 'skipped', error: 'Redan hanterad (avvisad)' },
|
||||
])
|
||||
expect(body.data.summary).toEqual({ total: 2, rejected: 0, skipped: 2, failed: 0 })
|
||||
})
|
||||
|
||||
it('rejects pending operations and aggregates a mixed summary', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: VALID_ID_1, status: 'pending' },
|
||||
{ id: VALID_ID_2, status: 'pending' },
|
||||
{ id: VALID_ID_3, status: 'committed' },
|
||||
],
|
||||
})
|
||||
// The guarded UPDATE returns the rows it actually flipped.
|
||||
enqueue({ data: [{ id: VALID_ID_1 }, { id: VALID_ID_2 }] })
|
||||
|
||||
const request = createMockRequest('/api/pending-operations/bulk-reject', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
ids: [VALID_ID_1, VALID_ID_2, VALID_ID_3],
|
||||
rejection_category: 'duplicate',
|
||||
rejection_reason: 'Samma underlag stagat två gånger',
|
||||
},
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<ResultBody>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.results).toEqual([
|
||||
{ id: VALID_ID_1, status: 'rejected' },
|
||||
{ id: VALID_ID_2, status: 'rejected' },
|
||||
{ id: VALID_ID_3, status: 'skipped', error: 'Redan hanterad (godkänd)' },
|
||||
])
|
||||
expect(body.data.summary).toEqual({ total: 3, rejected: 2, skipped: 1, failed: 0 })
|
||||
})
|
||||
|
||||
it('returns 500 when the update fails', async () => {
|
||||
enqueue({ data: [{ id: VALID_ID_1, status: 'pending' }] })
|
||||
enqueue({ data: null, error: { message: 'update exploded' } })
|
||||
|
||||
const request = createMockRequest('/api/pending-operations/bulk-reject', {
|
||||
method: 'POST',
|
||||
body: { ids: [VALID_ID_1] },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error).toBe('Operationerna kunde inte avvisas. Försök igen.')
|
||||
})
|
||||
|
||||
it('marks a row resolved between read and write as skipped', async () => {
|
||||
enqueue({ data: [{ id: VALID_ID_1, status: 'pending' }] })
|
||||
// Guarded UPDATE found nothing still pending.
|
||||
enqueue({ data: [] })
|
||||
|
||||
const request = createMockRequest('/api/pending-operations/bulk-reject', {
|
||||
method: 'POST',
|
||||
body: { ids: [VALID_ID_1] },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<ResultBody>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.results).toEqual([
|
||||
{ id: VALID_ID_1, status: 'skipped', error: 'Hanterades i en annan session.' },
|
||||
])
|
||||
expect(body.data.summary).toEqual({ total: 1, rejected: 0, skipped: 1, failed: 0 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { PendingOperationsBulkRejectSchema } from '@/lib/api/schemas'
|
||||
|
||||
interface BulkRejectItemResult {
|
||||
id: string
|
||||
status: 'rejected' | 'failed' | 'skipped'
|
||||
error?: string
|
||||
}
|
||||
|
||||
// Swedish display labels for already-handled operations; the raw enum values
|
||||
// are English and must not reach the user-visible per-item error strings.
|
||||
const STATUS_LABELS_SV: Record<string, string> = {
|
||||
committing: 'godkänns just nu',
|
||||
committed: 'godkänd',
|
||||
rejected: 'avvisad',
|
||||
expired: 'utgången',
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/pending-operations/bulk-reject
|
||||
*
|
||||
* Reject up to 100 pending operations in one call. Optionally accepts
|
||||
* `rejection_category` and `rejection_reason`, applied to every rejected row
|
||||
* so agents can learn from "no" via gnubok_get_recent_rejections, same as the
|
||||
* single reject route. Unlike bulk-commit there is no high-risk skip here:
|
||||
* rejecting posts nothing to the ledger, so it is safe at any risk tier.
|
||||
*/
|
||||
export const POST = withRouteContext(
|
||||
'pending_operation.bulk_reject',
|
||||
async (request, { supabase, companyId, log }) => {
|
||||
const validated = await validateBody(request, PendingOperationsBulkRejectSchema)
|
||||
if (!validated.success) return validated.response
|
||||
const { ids, rejection_category } = validated.data
|
||||
const rejectionReason = validated.data.rejection_reason?.trim() || undefined
|
||||
|
||||
const { data: ops, error: fetchError } = await supabase
|
||||
.from('pending_operations')
|
||||
.select('id, status')
|
||||
.in('id', ids)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (fetchError) {
|
||||
log.error('failed to fetch pending operations for bulk reject', fetchError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Åtgärderna kunde inte hämtas. Försök igen.' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const opsById = new Map(
|
||||
((ops ?? []) as Array<{ id: string; status: string }>).map((op) => [op.id, op])
|
||||
)
|
||||
const pendingIds = ids.filter((id) => opsById.get(id)?.status === 'pending')
|
||||
|
||||
// One guarded UPDATE for the whole batch: the status filter keeps a row
|
||||
// that was committed in a parallel session from being flipped to rejected
|
||||
// underneath that approver. Rows the guard filtered out are reported as
|
||||
// skipped below instead of failing the request.
|
||||
let rejectedIds = new Set<string>()
|
||||
if (pendingIds.length > 0) {
|
||||
const { data: updated, error: updateError } = await supabase
|
||||
.from('pending_operations')
|
||||
.update({
|
||||
status: 'rejected',
|
||||
resolved_at: new Date().toISOString(),
|
||||
...(rejection_category ? { rejection_category } : {}),
|
||||
...(rejectionReason ? { rejection_reason: rejectionReason } : {}),
|
||||
})
|
||||
.in('id', pendingIds)
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'pending')
|
||||
.select('id')
|
||||
|
||||
if (updateError) {
|
||||
log.error('bulk reject update failed', updateError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Operationerna kunde inte avvisas. Försök igen.' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
rejectedIds = new Set(
|
||||
((updated ?? []) as Array<{ id: string }>).map((row) => row.id)
|
||||
)
|
||||
}
|
||||
|
||||
const results: BulkRejectItemResult[] = ids.map((id) => {
|
||||
const op = opsById.get(id)
|
||||
if (!op) {
|
||||
return { id, status: 'failed' as const, error: 'Åtgärden kunde inte hittas.' }
|
||||
}
|
||||
if (rejectedIds.has(id)) {
|
||||
return { id, status: 'rejected' as const }
|
||||
}
|
||||
if (op.status !== 'pending') {
|
||||
return {
|
||||
id,
|
||||
status: 'skipped' as const,
|
||||
error: `Redan hanterad (${STATUS_LABELS_SV[op.status] ?? op.status})`,
|
||||
}
|
||||
}
|
||||
// Fetched as pending but not updated: resolved by another session
|
||||
// between our read and the guarded write.
|
||||
return { id, status: 'skipped' as const, error: 'Hanterades i en annan session.' }
|
||||
})
|
||||
|
||||
const summary = {
|
||||
total: results.length,
|
||||
rejected: results.filter((r) => r.status === 'rejected').length,
|
||||
skipped: results.filter((r) => r.status === 'skipped').length,
|
||||
failed: results.filter((r) => r.status === 'failed').length,
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { results, summary } })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -1868,6 +1868,16 @@ export const PendingOperationsBulkSchema = z.object({
|
||||
ids: z.array(z.string().uuid()).min(1).max(100),
|
||||
})
|
||||
|
||||
// Bulk reject: same id list plus the optional category/reason pair from the
|
||||
// single reject route. When provided they are applied to every rejected row.
|
||||
export const PendingOperationsBulkRejectSchema = z.object({
|
||||
ids: z.array(z.string().uuid()).min(1).max(100),
|
||||
rejection_category: z
|
||||
.enum(['wrong_category', 'wrong_amount', 'duplicate', 'wrong_period', 'other'])
|
||||
.optional(),
|
||||
rejection_reason: z.string().max(2000).optional(),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Audit trail schemas
|
||||
// ============================================================
|
||||
|
||||
@@ -419,6 +419,11 @@
|
||||
"approve_operation_title": "Approve operation",
|
||||
"approve_bulk_title": "Approve {count} operations?",
|
||||
"reject": "Reject",
|
||||
"reject_selected": "Reject selected ({count})",
|
||||
"reject_selected_none": "Reject selected",
|
||||
"reject_count": "Reject {count}",
|
||||
"reject_bulk_title": "Reject {count} operations?",
|
||||
"reject_bulk_description": "The reason and note below apply to all selected operations.",
|
||||
"select_all_aria": "Select all",
|
||||
"select_operation_aria": "Select operation",
|
||||
"selected_count": "{count} selected",
|
||||
|
||||
@@ -419,6 +419,11 @@
|
||||
"approve_operation_title": "Godkänn operation",
|
||||
"approve_bulk_title": "Godkänn {count} operationer?",
|
||||
"reject": "Avvisa",
|
||||
"reject_selected": "Avvisa valda ({count})",
|
||||
"reject_selected_none": "Avvisa valda",
|
||||
"reject_count": "Avvisa {count}",
|
||||
"reject_bulk_title": "Avvisa {count} operationer?",
|
||||
"reject_bulk_description": "Anledning och notering nedan gäller alla valda operationer.",
|
||||
"select_all_aria": "Markera alla",
|
||||
"select_operation_aria": "Välj operation",
|
||||
"selected_count": "{count} valda",
|
||||
|
||||
Reference in New Issue
Block a user