* feat(salary): add remove-employee button to draft salary runs
The DELETE /api/salary/runs/{id}/employees/{employeeId} endpoint already
existed (draft-only, cascades to the employee's line items) but had no UI
trigger, so a mistakenly added employee could only be cleared by deleting
the whole draft. Add a trash-icon action column to the "Anställda" table,
gated on draft status + write permission to match the endpoint's guard,
with a confirm prompt and success/error toast.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(settings): prevent horizontal overflow on mobile
The company settings invite form was a non-wrapping fixed-width flex row that overflowed narrow viewports, forcing the full-screen settings modal to scroll on the x-axis. Stack the form vertically on mobile (sm:flex-row at and above the sm breakpoint) and add the missing min-w-0 guard to the modal content pane.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(bookkeeping): move journal entry filters into a filter dialog
The ledger toolbar showed every filter inline (fiscal year, sort, series,
date range, missing-documents toggle), which felt cluttered. Keep only the
search field visible and move the rest into a "Filtrera" dialog with an
active-filter count badge.
- JournalEntryList now owns the fiscal-year scope, restored from the same
localStorage key FiscalYearSelector writes, so the page no longer renders
the selector separately.
- Filters apply live and the dialog stays open; "Rensa alla filter" clears them.
- Export STORAGE_KEY_PREFIX / ALL_YEARS_VALUE from FiscalYearSelector so the
list reuses the persisted selection without duplicating the key.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(transactions): implement imported transaction guard for deletion
- Added a guard to prevent deletion of transactions that are imported via bank sync or file uploads.
- Introduced `isImportedTransaction` utility to determine if a transaction is user-created or imported.
- Updated DELETE endpoint to return a 409 status for attempts to delete imported transactions.
- Enhanced transaction history and inbox components to reflect the new deletion rules.
- Added tests for transaction origin determination and deletion behavior.
- Updated UI components to include a confirmation dialog for clearing journal entry forms.
- Localized new strings for clearing form functionality in English and Swedish.
* feat(transactions): enhance transaction deletion guard and improve fiscal year visibility
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
197 lines
7.9 KiB
TypeScript
197 lines
7.9 KiB
TypeScript
import { createClient } from '@/lib/supabase/server'
|
||
import { NextResponse } from 'next/server'
|
||
import { createDraftEntry, createJournalEntry } from '@/lib/bookkeeping/engine'
|
||
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
|
||
import { ensureInitialized } from '@/lib/init'
|
||
import { validateBody } from '@/lib/api/validate'
|
||
import { CreateJournalEntrySchema } from '@/lib/api/schemas'
|
||
import { requireCompanyId } from '@/lib/company/context'
|
||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||
import { escapeLikePattern } from '@/lib/invoices/duplicate-payment-guard'
|
||
|
||
ensureInitialized()
|
||
|
||
export async function GET(request: Request) {
|
||
const supabase = await createClient()
|
||
const { data: { user } } = await supabase.auth.getUser()
|
||
|
||
if (!user) {
|
||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||
}
|
||
|
||
const companyId = await requireCompanyId(supabase, user.id)
|
||
|
||
const { searchParams } = new URL(request.url)
|
||
const periodId = searchParams.get('period_id')
|
||
const status = searchParams.get('status')
|
||
const limit = parseInt(searchParams.get('limit') || '50')
|
||
const offset = parseInt(searchParams.get('offset') || '0')
|
||
const dateFrom = searchParams.get('date_from')
|
||
const dateTo = searchParams.get('date_to')
|
||
const sortDate = searchParams.get('sort_date') // 'asc' | 'desc'
|
||
// 'series' optional filter — single uppercase letter A–Z. Ignored if any
|
||
// other value is passed (defense against trivial injection / typos).
|
||
const seriesRaw = searchParams.get('series')
|
||
const seriesFilter = seriesRaw && /^[A-Z]$/.test(seriesRaw) ? seriesRaw : null
|
||
// Free-text search over the voucher description (verifikationstext). When set,
|
||
// we take the direct-query path below (the include_related RPC can't search),
|
||
// which filters strictly by fiscal_period_id. So search is scoped to the
|
||
// selected fiscal period / company and — like voucher sort — does NOT surface
|
||
// cross-period follow-up entries: every result stays inside the selected
|
||
// year's series (the BFL-compliant per-year view). It narrows the period, it
|
||
// never widens it.
|
||
const search = searchParams.get('search')?.trim() || null
|
||
// 'date_desc' (default) | 'date_asc' | 'voucher_asc' | 'voucher_desc'
|
||
// sort_by overrides sort_date when present. sort_date is kept for backwards
|
||
// compatibility with older clients.
|
||
const sortBy = searchParams.get('sort_by')
|
||
const isVoucherSort = sortBy === 'voucher_asc' || sortBy === 'voucher_desc'
|
||
// Default on: when a fiscal period is selected, include follow-up entries
|
||
// booked in later periods whose source aggregate (invoice, supplier invoice)
|
||
// is dated inside the selected period. Pass include_related=false to
|
||
// restore strict fiscal_period_id filtering.
|
||
const includeRelated = searchParams.get('include_related') !== 'false'
|
||
|
||
const dateAscending = sortDate === 'asc' || sortBy === 'date_asc'
|
||
const sortDateParam = sortBy === 'date_asc' || sortDate === 'asc' ? 'asc' : 'desc'
|
||
|
||
// Voucher-sort path: include_related RPC doesn't support voucher ordering,
|
||
// so fall through to the direct query below. This means voucher sort is
|
||
// *strict by fiscal_period_id* — cross-period follow-up entries that the
|
||
// RPC normally surfaces under date sort are excluded under voucher sort.
|
||
// That's intentional: voucher numbers are series-scoped within a fiscal
|
||
// year (BFL 5 kap 6–7 §§), so showing series A1, A2 … alongside entries
|
||
// belonging to a different year's series would be misleading. The trade-off
|
||
// is that the visible row count may differ between sort modes for the same
|
||
// period; the strict count is the BFL-compliant view of that year.
|
||
if (periodId && includeRelated && !isVoucherSort && !search) {
|
||
const { data, error } = await supabase.rpc('list_fiscal_period_entries_with_related', {
|
||
p_company_id: companyId,
|
||
p_period_id: periodId,
|
||
p_include_related: true,
|
||
p_status: status,
|
||
p_date_from: dateFrom,
|
||
p_date_to: dateTo,
|
||
p_sort_date: sortDateParam,
|
||
p_limit: limit,
|
||
p_offset: offset,
|
||
})
|
||
|
||
if (error) {
|
||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||
}
|
||
|
||
const rows = data ?? []
|
||
let entries = rows.map((r: { entry: unknown }) => r.entry) as Array<{ voucher_series?: string }>
|
||
let count = rows.length > 0 ? Number((rows[0] as { total_count: number | string }).total_count) : 0
|
||
|
||
// The list_fiscal_period_entries_with_related RPC doesn't accept a series
|
||
// filter, so post-filter here. Recompute count from the filtered set so
|
||
// the paginator stays consistent; consequence: when a series filter is
|
||
// applied, the cross-period follow-up surfacing is still on but the
|
||
// visible total drops to the matching subset.
|
||
if (seriesFilter) {
|
||
entries = entries.filter((e) => (e?.voucher_series ?? 'A') === seriesFilter)
|
||
count = entries.length
|
||
}
|
||
|
||
return NextResponse.json({ data: entries, count })
|
||
}
|
||
|
||
let query = supabase
|
||
.from('journal_entries')
|
||
.select('*, lines:journal_entry_lines(*)', { count: 'exact' })
|
||
.eq('company_id', companyId)
|
||
|
||
if (isVoucherSort) {
|
||
const voucherAscending = sortBy === 'voucher_asc'
|
||
query = query
|
||
.order('voucher_series', { ascending: voucherAscending })
|
||
.order('voucher_number', { ascending: voucherAscending })
|
||
} else if (sortDate === 'asc' || sortDate === 'desc' || sortBy === 'date_asc' || sortBy === 'date_desc') {
|
||
query = query
|
||
.order('entry_date', { ascending: dateAscending })
|
||
.order('voucher_number', { ascending: dateAscending })
|
||
} else {
|
||
query = query
|
||
.order('voucher_series', { ascending: true })
|
||
.order('voucher_number', { ascending: true })
|
||
}
|
||
|
||
query = query.range(offset, offset + limit - 1)
|
||
|
||
if (periodId) {
|
||
query = query.eq('fiscal_period_id', periodId)
|
||
}
|
||
|
||
if (status) {
|
||
query = query.eq('status', status)
|
||
} else {
|
||
query = query.neq('status', 'cancelled')
|
||
}
|
||
|
||
if (dateFrom) {
|
||
query = query.gte('entry_date', dateFrom)
|
||
}
|
||
|
||
if (dateTo) {
|
||
query = query.lte('entry_date', dateTo)
|
||
}
|
||
|
||
if (seriesFilter) {
|
||
query = query.eq('voucher_series', seriesFilter)
|
||
}
|
||
|
||
if (search) {
|
||
// Escape LIKE wildcards (\ % _) so they match literally, and cap the needle
|
||
// length (≤200 chars) — both handled by the shared escapeLikePattern helper.
|
||
// The cap bounds DB work against oversized/pathological inputs (compliance
|
||
// A.8.28 / ASVS V1.2.5); escaping prevents silent over-matching on values
|
||
// like "50%". Supabase parameterises the value, so this is not about SQLi.
|
||
query = query.ilike('description', `%${escapeLikePattern(search)}%`)
|
||
}
|
||
|
||
const { data, error, count } = await query
|
||
|
||
if (error) {
|
||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||
}
|
||
|
||
return NextResponse.json({ data, count })
|
||
}
|
||
|
||
export async function POST(request: Request) {
|
||
const supabase = await createClient()
|
||
const { data: { user } } = await supabase.auth.getUser()
|
||
|
||
if (!user) {
|
||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||
}
|
||
|
||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||
if (!writeCheck.ok) return writeCheck.response
|
||
|
||
const companyId = await requireCompanyId(supabase, user.id)
|
||
|
||
const validation = await validateBody(request, CreateJournalEntrySchema)
|
||
if (!validation.success) return validation.response
|
||
const body = validation.data
|
||
|
||
const { searchParams } = new URL(request.url)
|
||
const asDraft = searchParams.get('as_draft') === 'true'
|
||
|
||
try {
|
||
const entry = asDraft
|
||
? await createDraftEntry(supabase, companyId, user.id, body)
|
||
: await createJournalEntry(supabase, companyId, user.id, body)
|
||
return NextResponse.json({ data: entry })
|
||
} catch (err) {
|
||
const typed = bookkeepingErrorResponse(err)
|
||
if (typed) return typed
|
||
return NextResponse.json(
|
||
{ error: err instanceof Error ? err.message : 'Failed to create journal entry' },
|
||
{ status: 400 }
|
||
)
|
||
}
|
||
}
|