Bug/employees creation (#440)
* Fix/employees page salary display logic and labels * feat: add salary_worked_days table and related functionality - Implemented the salary_worked_days table to track per-day worked hours for hourly employees. - Established row-level security (RLS) policies to ensure tenant isolation for salary_worked_days. - Created unique index on (employee_id, work_date) to enforce uniqueness. - Added trigger to enforce a 24-hour cap across worked and absence days for the same employee and date. - Developed tests to validate RLS, uniqueness, and 24-hour cap logic. * Fix: update hourly_salary calculation and refresh logic in salary run processing
This commit is contained in:
@@ -119,6 +119,11 @@ export async function POST(
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
// The 24h cap trigger raises check_violation when worked + absence > 24h
|
||||
// for the same date. Surface a clean 409 with the Swedish message.
|
||||
if (error.message?.includes('Total tid') || error.code === '23514') {
|
||||
return NextResponse.json({ error: error.message }, { status: 409 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { BatchUpsertWorkedDaysSchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
interface BatchConflict {
|
||||
date: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id: employeeId } = await params
|
||||
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 { data: employee } = await supabase
|
||||
.from('employees')
|
||||
.select('id')
|
||||
.eq('id', employeeId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (!employee) {
|
||||
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
const validation = await validateBody(request, BatchUpsertWorkedDaysSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
// Dedupe dates so the user can pass an array with accidental duplicates
|
||||
// (e.g. shift-clicking over the same date twice).
|
||||
const uniqueDates = Array.from(new Set(body.dates))
|
||||
|
||||
// Bulk delete existing rows on these dates first so the per-row insert step
|
||||
// is a clean replace. Stays within RLS via company_id + employee_id filter.
|
||||
const { error: deleteError } = await supabase
|
||||
.from('salary_worked_days')
|
||||
.delete()
|
||||
.eq('company_id', companyId)
|
||||
.eq('employee_id', employeeId)
|
||||
.in('work_date', uniqueDates)
|
||||
|
||||
if (deleteError) {
|
||||
return NextResponse.json({ error: deleteError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Per-row insert so we can isolate trigger failures (24h cap on a date with
|
||||
// existing absence) without aborting the whole batch. A single multi-row
|
||||
// insert would fail-fast and surface only the first conflict.
|
||||
const conflicts: BatchConflict[] = []
|
||||
let inserted = 0
|
||||
|
||||
for (const date of uniqueDates) {
|
||||
const { error } = await supabase
|
||||
.from('salary_worked_days')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
employee_id: employeeId,
|
||||
work_date: date,
|
||||
hours: body.hours,
|
||||
notes: body.notes ?? null,
|
||||
salary_run_employee_id: body.salary_run_employee_id ?? null,
|
||||
})
|
||||
if (error) {
|
||||
// 24h cap trigger uses ERRCODE check_violation (23514) and a Swedish
|
||||
// message starting with "Total tid". Other failures are unexpected.
|
||||
if (error.message?.includes('Total tid') || error.code === '23514') {
|
||||
conflicts.push({ date, reason: error.message })
|
||||
continue
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: error.message, inserted, conflicts },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
inserted += 1
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ data: { inserted, conflicts } },
|
||||
{ status: conflicts.length > 0 ? 207 : 201 },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { z } from 'zod'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { validateBody, validateQuery } from '@/lib/api/validate'
|
||||
import {
|
||||
UpsertWorkedDaySchema,
|
||||
WorkedHoursRangeQuerySchema,
|
||||
} from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
|
||||
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
async function loadEmployee(
|
||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||
employeeId: string,
|
||||
companyId: string,
|
||||
) {
|
||||
const { data } = await supabase
|
||||
.from('employees')
|
||||
.select('id, salary_type')
|
||||
.eq('id', employeeId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
return data
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id: employeeId } = await params
|
||||
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 employee = await loadEmployee(supabase, employeeId, companyId)
|
||||
if (!employee) {
|
||||
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
const query = validateQuery(request, WorkedHoursRangeQuerySchema)
|
||||
if (!query.success) return query.response
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('salary_worked_days')
|
||||
.select('id, work_date, hours, notes, salary_run_employee_id, created_at, updated_at')
|
||||
.eq('company_id', companyId)
|
||||
.eq('employee_id', employeeId)
|
||||
.gte('work_date', query.data.from)
|
||||
.lte('work_date', query.data.to)
|
||||
.order('work_date', { ascending: true })
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const totalHours = (data ?? []).reduce(
|
||||
(sum, d) => Math.round((sum + Number(d.hours)) * 100) / 100,
|
||||
0,
|
||||
)
|
||||
|
||||
return NextResponse.json({ data, total_hours: totalHours })
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id: employeeId } = await params
|
||||
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 employee = await loadEmployee(supabase, employeeId, companyId)
|
||||
if (!employee) {
|
||||
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
const validation = await validateBody(request, UpsertWorkedDaySchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
// Upsert via DELETE+INSERT on the natural key (employee, date). Worked days
|
||||
// have one row per date — re-marking overwrites. Mirrors the absence route's
|
||||
// pattern so behaviour stays predictable across the two calendars.
|
||||
const { error: deleteError } = await supabase
|
||||
.from('salary_worked_days')
|
||||
.delete()
|
||||
.eq('company_id', companyId)
|
||||
.eq('employee_id', employeeId)
|
||||
.eq('work_date', body.work_date)
|
||||
|
||||
if (deleteError) {
|
||||
return NextResponse.json({ error: deleteError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('salary_worked_days')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
employee_id: employeeId,
|
||||
work_date: body.work_date,
|
||||
hours: body.hours,
|
||||
notes: body.notes ?? null,
|
||||
salary_run_employee_id: body.salary_run_employee_id ?? null,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
// The 24h cap trigger raises check_violation when worked + absence > 24h
|
||||
// for the same date. Surface a clean 409 with a Swedish message.
|
||||
if (error.message?.includes('Total tid') || error.code === '23514') {
|
||||
return NextResponse.json({ error: error.message }, { status: 409 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data }, { status: 201 })
|
||||
}
|
||||
|
||||
// Two modes: ?date=YYYY-MM-DD (single row) or ?from=…&to=… (range).
|
||||
const DeleteQuerySchema = z.object({
|
||||
from: isoDate.optional(),
|
||||
to: isoDate.optional(),
|
||||
date: isoDate.optional(),
|
||||
})
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id: employeeId } = await params
|
||||
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 employee = await loadEmployee(supabase, employeeId, companyId)
|
||||
if (!employee) {
|
||||
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
const query = validateQuery(request, DeleteQuerySchema)
|
||||
if (!query.success) return query.response
|
||||
const { date, from, to } = query.data
|
||||
|
||||
const hasSingle = !!date
|
||||
const hasRange = !!from && !!to
|
||||
if (!hasSingle && !hasRange) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ange antingen ?date=YYYY-MM-DD eller ?from=...&to=...' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
let q = supabase
|
||||
.from('salary_worked_days')
|
||||
.delete()
|
||||
.eq('company_id', companyId)
|
||||
.eq('employee_id', employeeId)
|
||||
|
||||
if (hasSingle) {
|
||||
q = q.eq('work_date', date!)
|
||||
} else {
|
||||
q = q.gte('work_date', from!).lte('work_date', to!)
|
||||
}
|
||||
|
||||
const { error } = await q
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { ok: true } })
|
||||
}
|
||||
@@ -166,6 +166,66 @@ export const POST = withRouteContext(
|
||||
periodEnd,
|
||||
})
|
||||
|
||||
// ── Derive worked hours from per-day records (hourly employees) ────
|
||||
// For timanställda the calendar entries in salary_worked_days are the
|
||||
// authoritative source. Sum the period and pass to the calculator. If
|
||||
// no rows exist (legacy run, or hourly employee added without using the
|
||||
// calendar), fall back to the snapshot column on salary_run_employees.
|
||||
let derivedHoursWorked: number | null = null
|
||||
if (emp.salary_type === 'hourly') {
|
||||
const { data: workedDays, error: workedError } = await supabase
|
||||
.from('salary_worked_days')
|
||||
.select('hours')
|
||||
.eq('company_id', companyId)
|
||||
.eq('employee_id', emp.id)
|
||||
.gte('work_date', periodStart)
|
||||
.lte('work_date', periodEnd)
|
||||
if (workedError) {
|
||||
return errorResponse(workedError, opLog, { requestId })
|
||||
}
|
||||
derivedHoursWorked = (workedDays ?? []).reduce(
|
||||
(sum, d) => Math.round((sum + Number(d.hours)) * 100) / 100,
|
||||
0,
|
||||
)
|
||||
opLog.info('Derived hours_worked from calendar', {
|
||||
employeeId: emp.id,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
rowCount: workedDays?.length ?? 0,
|
||||
derivedHoursWorked,
|
||||
})
|
||||
|
||||
// Refresh the hourly_salary line item so the displayed Lönerader table
|
||||
// matches what the engine actually calculated. Without this, the
|
||||
// placeholder created at employee-add time keeps showing 0 kr even
|
||||
// after the user fills the calendar.
|
||||
if (derivedHoursWorked > 0 && (emp.hourly_rate || 0) > 0) {
|
||||
const baseAmount = Math.round((emp.hourly_rate as number) * derivedHoursWorked * 100) / 100
|
||||
// Delete any existing hourly_salary rows for this sre, then insert a
|
||||
// single fresh one. Avoids the "did the row already exist?" branch.
|
||||
await supabase
|
||||
.from('salary_line_items')
|
||||
.delete()
|
||||
.eq('salary_run_employee_id', sre.id)
|
||||
.eq('item_type', 'hourly_salary')
|
||||
await supabase.from('salary_line_items').insert({
|
||||
salary_run_employee_id: sre.id,
|
||||
company_id: companyId,
|
||||
item_type: 'hourly_salary',
|
||||
description: 'Timlön',
|
||||
quantity: derivedHoursWorked,
|
||||
amount: baseAmount,
|
||||
is_taxable: true,
|
||||
is_avgift_basis: true,
|
||||
is_vacation_basis: true,
|
||||
is_gross_deduction: false,
|
||||
is_net_deduction: false,
|
||||
account_number: getLineItemAccount('hourly_salary'),
|
||||
sort_order: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const employeeName = `${emp.first_name} ${emp.last_name}`
|
||||
if (absenceResult.flagLakarintyg) lakarintygEmployees.push(employeeName)
|
||||
if (absenceResult.flagFkReporting) fkReportingEmployees.push(employeeName)
|
||||
@@ -235,7 +295,12 @@ export const POST = withRouteContext(
|
||||
salaryType: emp.salary_type,
|
||||
monthlySalary: emp.monthly_salary || 0,
|
||||
hourlyRate: emp.hourly_rate || undefined,
|
||||
hoursWorked: sre.hours_worked || undefined,
|
||||
// Calendar-derived hours win when at least one row exists in the
|
||||
// period. The legacy snapshot column (sre.hours_worked) only kicks
|
||||
// in for runs predating the calendar feature.
|
||||
hoursWorked: derivedHoursWorked !== null && derivedHoursWorked > 0
|
||||
? derivedHoursWorked
|
||||
: sre.hours_worked || undefined,
|
||||
employmentDegree: emp.employment_degree,
|
||||
taxTableNumber: emp.tax_table_number,
|
||||
taxColumn: emp.tax_column || 1,
|
||||
@@ -277,9 +342,17 @@ export const POST = withRouteContext(
|
||||
|
||||
// Update salary_run_employee with calculated results. If any individual
|
||||
// update fails we abort so run totals aren't written from partial data.
|
||||
// For hourly employees with calendar rows, also mirror the derived total
|
||||
// into hours_worked so downstream code (reports, storno via correct/route)
|
||||
// sees a consistent snapshot.
|
||||
const snapshotHoursWorked =
|
||||
derivedHoursWorked !== null && derivedHoursWorked > 0
|
||||
? derivedHoursWorked
|
||||
: sre.hours_worked
|
||||
const { error: empUpdateError } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.update({
|
||||
hours_worked: snapshotHoursWorked,
|
||||
gross_salary: result.grossSalary,
|
||||
gross_deductions: result.grossDeductions,
|
||||
benefit_values: result.benefitValues,
|
||||
|
||||
Reference in New Issue
Block a user