Files
accounted/app/(dashboard)/mileage/page.tsx
T
MattssonandClaude Fable 5 7411a0171b feat(mileage): körjournal with milersättning booking, MCP tools and CSV export (#1448)
* feat(mileage): körjournal with milersättning booking, MCP tools and CSV export

New mileage_trips table (RLS, booked-delete trigger per BFL retention),
lib/mileage service reusing the payroll schablon rates, /api/mileage routes
(trips CRUD, period booking to 7331, salary-run push, körjournal CSV),
Körjournal dashboard page + nav, and three staged MCP tools (search-only
catalog). Trips book as one verifikat per period via the engine; salary
path inserts mileage_taxfree line items. mileage_trips classified in the
full-archive export.

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

* refactor(mileage): use shared roundOre helper per tightened ratchet baseline

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

* fix(mileage): pending_operations op-type migration + Swedish review findings

- New migration pair adds log_mileage_trip/book_mileage_period to the
  pending_operations operation_type CHECK (pg-real audit).
- bookMileagePeriod refuses a period spanning several employees and names
  the employee in the verifikationstext when scoped (BFL motpart).
- vehicle_registration required for förmånsbil trips (schema, service,
  MCP staging, UI surfaces the field).

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

* fix(mileage): claim-first booking, CSV injection guard and driver column

- bookMileagePeriod claims trips (draft to booked CAS) before creating the
  verifikat, so a concurrent second booking loses the race instead of
  double-booking; claim reverts if verifikat creation fails.
- Körjournal CSV neutralizes formula-injection triggers (OWASP) and adds a
  Förare column naming the employee per trip.

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

* fix(mileage): resolve CodeRabbit + Swedish review round: race, drift and hardening

- Copying a round trip no longer re-doubles the stored distance.
- pushMileageToSalaryRun claims trips before inserting line items (retry can
  no longer double-pay); CLAIM_LOST replaces misleading NO_TRIPS on lost races.
- Booked trips are DB-immutable via a BEFORE UPDATE trigger (new migration
  20260807113215): only claim/link/revert transitions and notes edits pass.
- Cross-year periods rejected (schablon rates are per calendar year); payroll
  config year read from the date string, not TZ-dependent getFullYear().
- MCP staged bookings freeze the previewed trip set (trip_ids in params) and
  the commit fails on drift; validation errors return 400, not 500.
- PATCH enforces the förmånsbil regnr rule on the effective row; export
  validates dates before they reach the Content-Disposition header; employee_id
  is verified company-scoped on trip creation; stale orphaned claims released.
- UI: fetch flags reset in finally; ICU plural for draft summary; distance
  stored at the column's 1-decimal precision.
- Tests: [id] route suite, pushMileageToSalaryRun suite, claim-race, drift,
  cross-year and update-trigger pg cases.

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

* fix(mileage): revert-to-draft must clear salary_run_id at the trigger level

New migration 20260807114924 replaces the booked-immutability function: a
booked -> draft revert now rejects rows keeping salary_run_id, closing the
DB-level double-pay path CodeRabbit flagged. pg test pins both directions;
the CLAIM_LOST unit test now asserts the revert.

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

* fix(mileage): company-scope employee_id on PATCH (Superagent P2)

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

* test(mileage): valid v4 uuid in cross-company employee PATCH test

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 14:17:21 +02:00

650 lines
24 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.
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { TH_CLASS, TD_CLASS, HOVER_REVEAL_CLASS } from '@/components/ui/dry-table'
import { useToast } from '@/components/ui/use-toast'
import { EmptyState } from '@/components/ui/empty-state'
import { ContextPicker } from '@/components/common/ContextPicker'
import { PageHeader } from '@/components/ui/page-header'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { formatCurrency, formatDate } from '@/lib/utils'
import { Car, Copy, Download, Plus, Trash2, Pencil, ChevronDown, ChevronUp } from 'lucide-react'
import type { MileageTrip, MileageVehicleType } from '@/types'
interface TripFormState {
trip_date: string
vehicle_type: MileageVehicleType
vehicle_registration: string
odometer_start: string
odometer_end: string
distance_km: string
from_location: string
to_location: string
purpose: string
visited: string
is_round_trip: boolean
notes: string
}
function today(): string {
return new Date().toISOString().slice(0, 10)
}
function monthBounds(month: string): { from: string; to: string } {
const [y, m] = month.split('-').map(Number)
const last = new Date(Date.UTC(y, m, 0)).getUTCDate()
return { from: `${month}-01`, to: `${month}-${String(last).padStart(2, '0')}` }
}
function emptyForm(): TripFormState {
return {
trip_date: today(),
vehicle_type: 'own_car',
vehicle_registration: '',
odometer_start: '',
odometer_end: '',
distance_km: '',
from_location: '',
to_location: '',
purpose: '',
visited: '',
is_round_trip: false,
notes: '',
}
}
function formFromTrip(trip: MileageTrip, keepDate: boolean): TripFormState {
// The create form takes ONE-WAY km with a round-trip toggle that doubles on
// save. The stored distance is always the full logged distance, so a copy
// of a round trip must present the halved (one-way) value or saving would
// double it again. Edit mode (keepDate) shows the stored total and never
// re-doubles.
const displayKm =
!keepDate && trip.is_round_trip ? Number(trip.distance_km) / 2 : Number(trip.distance_km)
return {
trip_date: keepDate ? trip.trip_date : today(),
vehicle_type: trip.vehicle_type,
vehicle_registration: trip.vehicle_registration ?? '',
odometer_start: keepDate && trip.odometer_start != null ? String(trip.odometer_start) : '',
odometer_end: keepDate && trip.odometer_end != null ? String(trip.odometer_end) : '',
distance_km: String(displayKm),
from_location: trip.from_location,
to_location: trip.to_location,
purpose: trip.purpose,
visited: trip.visited ?? '',
is_round_trip: trip.is_round_trip,
notes: trip.notes ?? '',
}
}
export default function MileagePage() {
const t = useTranslations('mileage')
const { toast } = useToast()
const { canWrite } = useCanWrite()
const [trips, setTrips] = useState<MileageTrip[]>([])
const [loading, setLoading] = useState(true)
const [month, setMonth] = useState<string>('all')
const [formOpen, setFormOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [form, setForm] = useState<TripFormState>(emptyForm())
const [showMore, setShowMore] = useState(false)
const [saving, setSaving] = useState(false)
const [bookOpen, setBookOpen] = useState(false)
const [bookFrom, setBookFrom] = useState('')
const [bookTo, setBookTo] = useState('')
const [bookEntryDate, setBookEntryDate] = useState(today())
const [counterAccount, setCounterAccount] = useState<'2820' | '2893' | '1930'>('2820')
const [booking, setBooking] = useState(false)
const loadTrips = useCallback(async () => {
try {
const res = await fetch('/api/mileage/trips')
if (!res.ok) {
toast({ title: t('load_error'), variant: 'destructive' })
return
}
const body = await res.json()
setTrips(body.data || [])
} catch {
toast({ title: t('load_error'), variant: 'destructive' })
} finally {
setLoading(false)
}
}, [t, toast])
useEffect(() => {
loadTrips()
}, [loadTrips])
const months = useMemo(() => {
const set = new Set(trips.map((trip) => trip.trip_date.slice(0, 7)))
set.add(today().slice(0, 7))
return [...set].sort().reverse()
}, [trips])
const visibleTrips = useMemo(
() => (month === 'all' ? trips : trips.filter((trip) => trip.trip_date.startsWith(month))),
[trips, month]
)
const draftTrips = useMemo(
() => visibleTrips.filter((trip) => trip.status === 'draft'),
[visibleTrips]
)
const draftKm = useMemo(
() => Math.round(draftTrips.reduce((sum, trip) => sum + Number(trip.distance_km), 0) * 10) / 10,
[draftTrips]
)
const openCreate = () => {
setEditingId(null)
setForm(emptyForm())
setShowMore(false)
setFormOpen(true)
}
const openEdit = (trip: MileageTrip) => {
setEditingId(trip.id)
setForm(formFromTrip(trip, true))
setShowMore(Boolean(trip.vehicle_registration || trip.odometer_start || trip.visited || trip.notes))
setFormOpen(true)
}
const openCopy = (trip: MileageTrip) => {
setEditingId(null)
setForm(formFromTrip(trip, false))
setShowMore(false)
setFormOpen(true)
}
const submitForm = async () => {
const km = Number(form.distance_km.replace(',', '.'))
if (!(km > 0) || !form.from_location.trim() || !form.to_location.trim() || !form.purpose.trim()) {
toast({ title: t('form_incomplete'), variant: 'destructive' })
return
}
setSaving(true)
try {
const payload = {
trip_date: form.trip_date,
vehicle_type: form.vehicle_type,
vehicle_registration: form.vehicle_registration.trim() || null,
odometer_start: form.odometer_start ? Number(form.odometer_start) : null,
odometer_end: form.odometer_end ? Number(form.odometer_end) : null,
// The round-trip toggle doubles the one-way distance on save; the stored
// km always covers the full logged distance.
distance_km: editingId ? km : form.is_round_trip ? km * 2 : km,
from_location: form.from_location.trim(),
to_location: form.to_location.trim(),
purpose: form.purpose.trim(),
visited: form.visited.trim() || null,
is_round_trip: form.is_round_trip,
notes: form.notes.trim() || null,
}
const res = await fetch(editingId ? `/api/mileage/trips/${editingId}` : '/api/mileage/trips', {
method: editingId ? 'PATCH' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
const message = typeof body?.error === 'string' ? body.error : body?.error?.message
toast({ title: message || t('save_error'), variant: 'destructive' })
return
}
setFormOpen(false)
toast({ title: editingId ? t('trip_updated') : t('trip_saved') })
await loadTrips()
} catch {
toast({ title: t('save_error'), variant: 'destructive' })
} finally {
setSaving(false)
}
}
const deleteTrip = async (trip: MileageTrip) => {
const res = await fetch(`/api/mileage/trips/${trip.id}`, { method: 'DELETE' })
if (!res.ok) {
toast({ title: t('delete_error'), variant: 'destructive' })
return
}
toast({ title: t('trip_deleted') })
await loadTrips()
}
const openBook = () => {
const base = month === 'all' ? today().slice(0, 7) : month
const bounds = monthBounds(base)
setBookFrom(bounds.from)
setBookTo(bounds.to)
setBookEntryDate(bounds.to <= today() ? bounds.to : today())
setBookOpen(true)
}
const submitBook = async () => {
setBooking(true)
try {
const res = await fetch('/api/mileage/book', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
from: bookFrom,
to: bookTo,
entry_date: bookEntryDate,
counter_account: counterAccount,
}),
})
const body = await res.json().catch(() => null)
if (!res.ok) {
const message = typeof body?.error === 'string' ? body.error : body?.error?.message
toast({ title: message || t('book_error'), variant: 'destructive' })
return
}
setBookOpen(false)
toast({
title: t('booked_title', {
voucher: `${body.data.voucher_series ?? ''}${body.data.voucher_number ?? ''}`,
}),
description: t('booked_description', {
count: body.data.trip_count,
amount: formatCurrency(body.data.total_amount),
}),
})
await loadTrips()
} catch {
toast({ title: t('book_error'), variant: 'destructive' })
} finally {
setBooking(false)
}
}
const exportHref = useMemo(() => {
if (month === 'all') return '/api/mileage/export'
const bounds = monthBounds(month)
return `/api/mileage/export?from=${bounds.from}&to=${bounds.to}`
}, [month])
const monthLabel = month === 'all' ? t('all_months') : month
return (
<div className="space-y-8">
<PageHeader
title={t('title')}
action={
canWrite ? (
<Button onClick={openCreate}>
<Plus className="mr-2 h-4 w-4" />
{t('new_trip')}
</Button>
) : undefined
}
/>
<div className="flex flex-wrap items-center gap-3">
{draftTrips.length > 0 && (
<p className="text-sm text-muted-foreground">
{t('draft_summary', { count: draftTrips.length, km: draftKm })}
</p>
)}
<div className="ml-auto flex items-center gap-2">
{canWrite && draftTrips.length > 0 && (
<Button variant="secondary" onClick={openBook}>
{t('book_period')}
</Button>
)}
{trips.length > 0 && (
<Button variant="ghost" asChild>
<a href={exportHref} download>
<Download className="mr-2 h-4 w-4" />
{t('export_csv')}
</a>
</Button>
)}
<ContextPicker
items={[
{ id: 'all', label: t('all_months') },
...months.map((m) => ({ id: m, label: m })),
]}
value={month}
onChange={setMonth}
triggerLabel={monthLabel}
ariaLabel={t('month_filter')}
/>
</div>
</div>
{loading ? (
<div className="space-y-2" role="status">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : visibleTrips.length === 0 ? (
<EmptyState
icon={Car}
title={t('empty_title')}
description={t('empty_description')}
actionLabel={canWrite ? t('new_trip') : undefined}
onAction={canWrite ? openCreate : undefined}
/>
) : (
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={TH_CLASS}>{t('col_date')}</th>
<th className={TH_CLASS}>{t('col_route')}</th>
<th className={TH_CLASS}>{t('col_purpose')}</th>
<th className={`${TH_CLASS} text-right`}>{t('col_km')}</th>
<th className={TH_CLASS}>{t('col_status')}</th>
<th className={TH_CLASS} />
</tr>
</thead>
<tbody className="stagger-enter">
{visibleTrips.map((trip) => (
<tr key={trip.id} className="group hover:bg-secondary/35">
<td className={`${TD_CLASS} tabular-nums whitespace-nowrap`}>
{formatDate(trip.trip_date)}
</td>
<td className={TD_CLASS}>
{trip.from_location} – {trip.to_location}
{trip.is_round_trip ? ` ${t('round_trip_suffix')}` : ''}
</td>
<td className={`${TD_CLASS} text-muted-foreground`}>{trip.purpose}</td>
<td className={`${TD_CLASS} text-right tabular-nums`}>
{Number(trip.distance_km).toLocaleString('sv-SE')}
</td>
<td className={TD_CLASS}>
{trip.status === 'draft' ? (
<Badge variant="outline">{t('status_draft')}</Badge>
) : (
<span className="text-xs text-muted-foreground">{t('status_booked')}</span>
)}
</td>
<td className={`${TD_CLASS} text-right whitespace-nowrap`}>
{canWrite && (
<span className={HOVER_REVEAL_CLASS}>
<Button
variant="ghost"
size="icon"
aria-label={t('copy_trip')}
onClick={() => openCopy(trip)}
>
<Copy className="h-4 w-4" />
</Button>
{trip.status === 'draft' && (
<>
<Button
variant="ghost"
size="icon"
aria-label={t('edit_trip')}
onClick={() => openEdit(trip)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label={t('delete_trip')}
onClick={() => deleteTrip(trip)}
>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
<Dialog open={formOpen} onOpenChange={setFormOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{editingId ? t('edit_trip') : t('new_trip')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="trip_date">{t('field_date')}</Label>
<Input
id="trip_date"
type="date"
value={form.trip_date}
onChange={(e) => setForm({ ...form, trip_date: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="vehicle_type">{t('field_vehicle')}</Label>
<Select
value={form.vehicle_type}
onValueChange={(v) => {
setForm({ ...form, vehicle_type: v as MileageVehicleType })
// Regnr is required for a förmånsbil; surface the field.
if (v !== 'own_car') setShowMore(true)
}}
>
<SelectTrigger id="vehicle_type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="own_car">{t('vehicle_own_car')}</SelectItem>
<SelectItem value="company_car_fossil">{t('vehicle_company_fossil')}</SelectItem>
<SelectItem value="company_car_electric">{t('vehicle_company_electric')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="from_location">{t('field_from')}</Label>
<Input
id="from_location"
value={form.from_location}
onChange={(e) => setForm({ ...form, from_location: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="to_location">{t('field_to')}</Label>
<Input
id="to_location"
value={form.to_location}
onChange={(e) => setForm({ ...form, to_location: e.target.value })}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="purpose">{t('field_purpose')}</Label>
<Input
id="purpose"
value={form.purpose}
placeholder={t('purpose_placeholder')}
onChange={(e) => setForm({ ...form, purpose: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="distance_km">
{editingId ? t('field_km_total') : t('field_km')}
</Label>
<Input
id="distance_km"
inputMode="decimal"
value={form.distance_km}
onChange={(e) => setForm({ ...form, distance_km: e.target.value })}
/>
</div>
{!editingId && (
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={form.is_round_trip}
onCheckedChange={(v) => setForm({ ...form, is_round_trip: v === true })}
/>
{t('field_round_trip')}
</label>
</div>
)}
</div>
<button
type="button"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors duration-150"
onClick={() => setShowMore(!showMore)}
>
{showMore ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
{t('more_fields')}
</button>
{showMore && (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="vehicle_registration">{t('field_regnr')}</Label>
<Input
id="vehicle_registration"
value={form.vehicle_registration}
onChange={(e) => setForm({ ...form, vehicle_registration: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="odometer_start">{t('field_odometer_start')}</Label>
<Input
id="odometer_start"
inputMode="numeric"
value={form.odometer_start}
onChange={(e) => setForm({ ...form, odometer_start: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="odometer_end">{t('field_odometer_end')}</Label>
<Input
id="odometer_end"
inputMode="numeric"
value={form.odometer_end}
onChange={(e) => setForm({ ...form, odometer_end: e.target.value })}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="visited">{t('field_visited')}</Label>
<Input
id="visited"
value={form.visited}
onChange={(e) => setForm({ ...form, visited: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="notes">{t('field_notes')}</Label>
<Input
id="notes"
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
/>
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={() => setFormOpen(false)}>
{t('cancel')}
</Button>
<Button onClick={submitForm} disabled={saving}>
{saving ? t('saving') : t('save_trip')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={bookOpen} onOpenChange={setBookOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('book_period')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="book_from">{t('field_period_from')}</Label>
<Input
id="book_from"
type="date"
value={bookFrom}
onChange={(e) => setBookFrom(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="book_to">{t('field_period_to')}</Label>
<Input
id="book_to"
type="date"
value={bookTo}
onChange={(e) => setBookTo(e.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="book_entry_date">{t('field_entry_date')}</Label>
<Input
id="book_entry_date"
type="date"
value={bookEntryDate}
onChange={(e) => setBookEntryDate(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="counter_account">{t('field_counter_account')}</Label>
<Select
value={counterAccount}
onValueChange={(v) => setCounterAccount(v as '2820' | '2893' | '1930')}
>
<SelectTrigger id="counter_account">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="2820">{t('counter_2820')}</SelectItem>
<SelectItem value="2893">{t('counter_2893')}</SelectItem>
<SelectItem value="1930">{t('counter_1930')}</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-sm text-muted-foreground">{t('book_explainer')}</p>
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={() => setBookOpen(false)}>
{t('cancel')}
</Button>
<Button onClick={submitBook} disabled={booking}>
{booking ? t('booking') : t('confirm_book')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
)
}