Files
accounted/app/api/salary/runs/route.ts
T
Jakob Wennberg 2175eccddd feat(salary): concept Löner landing + nav collapse animation (UI migration PR 9) (#1130)
* feat(salary): concept scene 22 landing + smoother nav collapse

Loner gets the concept header (quiet Anstallda link + Starta lonekorning
primary), the hero de-cards to a flat serif line + description + pill,
and the runs list becomes the scene 22 dry-table (Period, Status,
Anstallda, Bruttolon, Netto) where booked runs read as muted text and
in-flight runs carry a chip plus the payout date. Attention cards and the
hero state machine survive unchanged.

Nav polish: the collapse now animates as one movement (300ms decelerating
curve on both the aside width and the panel margin) and the rail/full
contents slide+fade in on swap; the Register/Bokslut folds match the
RowFoldout timing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(salary): strip hero and attention cards from the landing

Founder direction: the Loner landing is header + lonekorningar only
(concept scene 22). The open run's table row is the entry point; AGI,
skatt, blockers and semester surfaces live on the run detail and the
employee register. Drops the hero state machine, the four cards, and all
their data plumbing (deadlines query, tax-payment chain, SKV status,
AGI submission hook).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(salary): drop the Lonekorningar eyebrow

Founder feedback: the uppercase eyebrow read as a stray font above the
table, and with the landing reduced to the runs table alone the label is
redundant under the Loner page title.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(salary): populate the Anstallda column via a count embed

The runs list selected bare salary_runs rows, so the landing's Anstallda
column was always empty (the employees array only ever existed on the
detail response). The list now embeds employee_count via
salary_run_employees(count) and the page reads the PostgREST count shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(salary): quiet beige chip for in-flight run states

Founder feedback vs the concept: the Granskning chip rendered as the
bordered ochre warning badge and read noisy next to the concept's quiet
Utkast chip. Draft/review/approved now all wear the beige secondary chip;
the payout-date note carries the urgency.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 22:17:20 +02:00

203 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { CreateSalaryRunWithDefaultsSchema } from '@/lib/api/schemas'
import { eventBus } from '@/lib/events'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { createSalaryRunWithEmployees } from '@/lib/salary/create-run'
import { runSalaryCalculation } from '@/lib/salary/run-calculation'
import { resolveDefaultSeriesForSource } from '@/lib/bookkeeping/voucher-series-resolver'
ensureInitialized()
export const GET = withRouteContext(
'salary_run.list',
async (request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
const { searchParams } = new URL(request.url)
const year = searchParams.get('year')
// employee_count feeds the Anställda column on the Löner landing; the
// embedded count avoids shipping the per-employee rows with the list.
let query = supabase
.from('salary_runs')
.select('*, employee_count:salary_run_employees(count)')
.eq('company_id', companyId)
if (year) {
query = query.eq('period_year', parseInt(year))
}
const { data, error } = await query
.order('period_year', { ascending: false })
.order('period_month', { ascending: false })
if (error) {
log.error('salary run list failed', error)
return errorResponse(error, log, { requestId })
}
return NextResponse.json({ data })
},
)
/**
* One-click run creation. All body fields are optional — defaults resolve
* server-side so the dashboard button can POST {}:
* period → month after the latest non-corrected run, else current month
* payment_date → company_settings.salary_pay_day (default 25) in the period month
* series → per-source-type map entry for 'salary_payment'
* The run is seeded with every active employee (shared lib — same behavior as
* the MCP tool) and calculated immediately; a calculation failure is
* non-fatal (201 with calculation.ok=false, user lands on the draft).
*/
export const POST = withRouteContext(
'salary_run.create',
async (request, ctx) => {
const { user, supabase, companyId, log, requestId } = ctx
const validation = await validateBody(request, CreateSalaryRunWithDefaultsSchema, {
log,
operation: 'salary_run.create',
})
if (!validation.success) return validation.response
const body = validation.data
// Settings drive the defaults; tolerate a missing row (fresh company).
const { data: settings } = await supabase
.from('company_settings')
.select('salary_pay_day, default_voucher_series_per_source_type')
.eq('company_id', companyId)
.maybeSingle()
let periodYear = body.period_year
let periodMonth = body.period_month
if (!periodYear || !periodMonth) {
const { data: latest } = await supabase
.from('salary_runs')
.select('period_year, period_month')
.eq('company_id', companyId)
.neq('status', 'corrected')
.order('period_year', { ascending: false })
.order('period_month', { ascending: false })
.limit(1)
.maybeSingle()
if (latest) {
// Number() pins the untyped (any) Supabase row values to `number`,
// so periodYear/periodMonth stay narrowed after this block.
const latestYear = Number(latest.period_year)
const latestMonth = Number(latest.period_month)
periodYear = latestMonth === 12 ? latestYear + 1 : latestYear
periodMonth = latestMonth === 12 ? 1 : latestMonth + 1
} else {
const now = new Date()
periodYear = now.getFullYear()
periodMonth = now.getMonth() + 1
}
}
// salary_pay_day is 128 by CHECK, so the date exists in every month.
const payDay = settings?.salary_pay_day ?? 25
const paymentDate =
body.payment_date ??
`${periodYear}-${String(periodMonth).padStart(2, '0')}-${String(payDay).padStart(2, '0')}`
const voucherSeries =
body.voucher_series ?? resolveDefaultSeriesForSource(settings ?? null, 'salary_payment')
// Corrected runs coexist with their correction in the same period (the
// unique index is partial), so exclude them — and use maybeSingle():
// .single() errors on multiple rows and would skip the 409.
const { data: existing } = await supabase
.from('salary_runs')
.select('id')
.eq('company_id', companyId)
.eq('period_year', periodYear)
.eq('period_month', periodMonth)
.neq('status', 'corrected')
.limit(1)
.maybeSingle()
if (existing) {
return errorResponseFromCode('CONFLICT', log, {
requestId,
details: {
reason: 'salary_run_exists_for_period',
existingId: existing.id,
periodYear,
periodMonth,
},
})
}
let run: Record<string, unknown>
let employeeCount: number
try {
const created = await createSalaryRunWithEmployees(supabase, companyId, user.id, {
periodYear,
periodMonth,
paymentDate,
voucherSeries,
notes: body.notes,
})
run = created.run
employeeCount = created.employeeCount
} catch (err) {
const message = err instanceof Error ? err.message : 'unknown error'
// Race with a concurrent create — the lib maps 23505 to this message.
if (message.includes('already exists')) {
return errorResponseFromCode('CONFLICT', log, {
requestId,
details: { reason: 'salary_run_exists_for_period', periodYear, periodMonth },
})
}
log.error('salary run create failed', err as Error)
return errorResponseFromCode('SALARY_RUN_CREATE_FAILED', log, {
requestId,
details: { reason: getErrorMessage(err, { context: 'salary' }) },
})
}
// Chain the calculation so the run lands review-ready. Empty rosters are
// valid (nolldeklaration). Failure is non-fatal: the draft still exists
// and the run page shows Beräkna as the pending step.
const calcResult = await runSalaryCalculation({
supabase,
companyId,
salaryRunId: run.id as string,
log,
requestId,
})
const calculation = calcResult.ok
? { ok: true as const, warnings: calcResult.warnings }
: { ok: false as const, code: calcResult.code }
if (calcResult.ok) {
run = calcResult.run
} else {
log.warn('chained salary calculation failed', { code: calcResult.code })
}
await eventBus.emit({
type: 'salary_run.created',
payload: {
salaryRunId: run.id as string,
periodYear,
periodMonth,
userId: user.id,
companyId: companyId!,
},
})
return NextResponse.json(
{ data: run, employee_count: employeeCount, calculation },
{ status: 201 },
)
},
{ requireWrite: true },
)