Files
accounted/components/salary/NewSalaryRunDialog.tsx
T
Jakob WennbergandClaude Fable 5 100a4d1291 feat(ux): create salary runs, employees & recurring schedules in modals (#883)
* feat(ux): create salary runs, employees & recurring schedules in modals

The last three full-page create flows move to ?new=1 URL-driven dialogs,
matching the verifikat/invoice pattern (#861):

- Salary run: 4-field form on /salary — creation was pure interruption
  before landing on the run-detail workspace.
- Employee: the last register entity still page-based after customers,
  suppliers, and articles.
- Recurring schedule: consistency with the invoice modal it feeds.

Old /new routes survive as redirects so bookmarks and agent intents keep
working. Dialogs close explicitly (header X / Avbryt) so half-typed forms
survive stray Escape or backdrop clicks.

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

* docs: move DECISIONS.md to repo root

dev_docs/ is gitignored, so the decision log was invisible to other
developers. Root matches the existing convention (CONTRIBUTING.md,
SECURITY.md). CLAUDE.md pointer updated.

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

* chore(lint): ignore Claude Code worktrees in eslint walk

.claude/worktrees/ holds full repo copies; without the ignore, local
npm run lint / check:lint walks them until the ratchet's 64 MB JSON
parse buffer overflows. CI is unaffected (no worktrees there).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:18:20 +02:00

130 lines
4.5 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 { useState } from 'react'
import { useRouter } from 'next/navigation'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ArrowRight } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
}
/**
* "Ny lönekörning" as a modal — mirrors NewJournalEntryDialog. A successful
* create navigates straight to the run detail page (the real workspace),
* unmounting the host list page and this dialog with it.
*/
export default function NewSalaryRunDialog({ open, onOpenChange }: Props) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="sm:max-w-lg max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto"
// Same convention as NewJournalEntryDialog: closing is explicit (the
// header X or Avbryt), never an accidental Escape or backdrop click.
onEscapeKeyDown={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>Ny lönekörning</DialogTitle>
</DialogHeader>
<NewSalaryRunForm onCancel={() => onOpenChange(false)} />
</DialogContent>
</Dialog>
)
}
// Inner component so form state resets whenever the dialog reopens (Radix
// unmounts DialogContent children on close).
function NewSalaryRunForm({ onCancel }: { onCancel: () => void }) {
const router = useRouter()
const { toast } = useToast()
const [saving, setSaving] = useState(false)
const now = new Date()
const defaultYear = now.getFullYear()
const defaultMonth = now.getMonth() + 1
const defaultPayDate = `${defaultYear}-${String(defaultMonth).padStart(2, '0')}-25`
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setSaving(true)
const form = new FormData(e.currentTarget)
const body = {
period_year: parseInt(form.get('period_year') as string),
period_month: parseInt(form.get('period_month') as string),
payment_date: form.get('payment_date') as string,
voucher_series: form.get('voucher_series') as string || 'A',
}
const res = await fetch('/api/salary/runs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (res.ok) {
const { data } = await res.json()
toast({ title: 'Lönekörning skapad' })
router.push(`/salary/runs/${data.id}`)
} else {
const result = await res.json()
toast({
title: 'Kunde inte skapa lönekörning',
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
}
setSaving(false)
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="period_year">År</Label>
<Input id="period_year" name="period_year" type="number" defaultValue={defaultYear} required />
</div>
<div className="space-y-2">
<Label htmlFor="period_month">Månad (1-12)</Label>
<Input id="period_month" name="period_month" type="number" min="1" max="12" defaultValue={defaultMonth} required />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="payment_date">Utbetalningsdag</Label>
<Input id="payment_date" name="payment_date" type="date" defaultValue={defaultPayDate} required />
</div>
<div className="space-y-2">
<Label htmlFor="voucher_series">Verifikationsserie</Label>
<Input id="voucher_series" name="voucher_series" defaultValue="A" maxLength={1} className="max-w-20" />
<p className="text-xs text-muted-foreground">En bokstav A–Z. Standard: A</p>
</div>
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={onCancel}>
Avbryt
</Button>
<Button type="submit" disabled={saving}>
{saving ? 'Skapar...' : 'Skapa och fortsätt'}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</form>
)
}