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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
237b77a366
commit
100a4d1291
@@ -100,4 +100,4 @@ Don't duplicate these here — they auto-load when you touch matching paths:
|
||||
|
||||
## Decision Log
|
||||
|
||||
When you make a non-obvious choice — picked approach A over B, declined a dependency, stopped because a rule here forbade something — append one line to `dev_docs/DECISIONS.md`: `[YYYY-MM-DD] <decision> — <why>`. Check that file before re-litigating a past decision.
|
||||
When you make a non-obvious choice — picked approach A over B, declined a dependency, stopped because a rule here forbade something — append one line to `DECISIONS.md` (repo root): `[YYYY-MM-DD] <decision> — <why>`. Check that file before re-litigating a past decision.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Decision Log
|
||||
|
||||
One line per decision: `[YYYY-MM-DD] <decision> — <why>`. Appended by agents and humans when a non-obvious choice is made (approach picked over an alternative, dependency declined, action stopped by a CLAUDE.md rule). Read before re-litigating a past decision.
|
||||
|
||||
[2026-07-02] Adopted this decision log — CLAUDE.md rewritten per config-over-prompt principles; decisions persist here instead of being re-derived each session.
|
||||
[2026-07-03] Prod constraint clobber (self-inflicted, repaired in ~10 min): applied pending_operations link_document_to_voucher migration from a checkout predating 20260702171000 (retag_line_dimensions) — hand-copied CHECK lists clobber concurrent adds. Zero impact (no retag ops in window). Rule: before applying any expand-types migration to prod, diff the list against the LIVE prod constraint, not the local file history. Long-term fix queued in mcp_optimization_plan P0-1 follow-up (audit test now guards CI).
|
||||
[2026-07-03] Archived 4 completed/superseded plans to dev_docs/archive/ (dimensions_implementation_plan, specialized-agent-plan, api_ai_architecture/PLAN, mcp-apps-architecture-reference) — moved, not deleted, because dev_docs is gitignored (no git history to recover from). Live remnants relocated first: PR10 backlog → dimensions_architecture.md; eval-harness spec → claude_surface_plan.md §2.1. agent_first_vision.md §8 marked superseded by claude_surface_plan.md (Skatteverket filing is BUILT, contra its P0 item 6).
|
||||
[2026-07-03] Moved this log from dev_docs/DECISIONS.md to repo root — dev_docs/ is gitignored, so the log was invisible to other developers; root matches the existing convention (CONTRIBUTING.md, SECURITY.md).
|
||||
[2026-07-03] Converted the last three full-page create flows (salary run, employee, recurring schedule) to ?new=1 URL-driven modals matching the verifikat/invoice pattern (#861); old /new routes survive as redirects for bookmarks/agent intents. Moved forms keep their existing hardcoded-Swedish strings — translating them is out of scope for the modal conversion.
|
||||
@@ -1,385 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useForm, useFieldArray, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { ArrowLeft, Plus, Trash2 } from 'lucide-react'
|
||||
import type { Customer, Currency } from '@/types'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
|
||||
const currencies: Currency[] = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']
|
||||
const units = ['st', 'tim', 'dag', 'månad', 'km', 'kg']
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
// Recurring-schedule creation now happens in a modal on the schedule list
|
||||
// (matching the verifikat pattern) — the form itself lives in
|
||||
// components/invoices/NewRecurringScheduleDialog.tsx. This route survives as
|
||||
// a redirect so old links, bookmarks, and agent intents keep working.
|
||||
export default function NewRecurringSchedulePage() {
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const { company } = useCompany()
|
||||
const supabase = createClient()
|
||||
const t = useTranslations('invoice_recurring_new')
|
||||
const [customers, setCustomers] = useState<Customer[]>([])
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const schema = useMemo(() => {
|
||||
const itemSchema = z.object({
|
||||
description: z.string().min(1, t('validation_description_required')),
|
||||
quantity: z.number().min(0.01, t('validation_quantity_min')),
|
||||
unit: z.string().min(1, t('validation_unit_required')),
|
||||
unit_price: z.number(),
|
||||
vat_rate: z
|
||||
.union([z.literal(0), z.literal(6), z.literal(12), z.literal(25)])
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
return z.object({
|
||||
customer_id: z.string().uuid(t('validation_customer_required')),
|
||||
name: z.string().min(1, t('validation_name_required')),
|
||||
day_of_month: z.number().int().min(1).max(31),
|
||||
payment_terms_days: z.number().int().min(0).max(90),
|
||||
currency: z.enum(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']),
|
||||
auto_send: z.boolean(),
|
||||
your_reference: z.string().optional(),
|
||||
our_reference: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
items: z.array(itemSchema).min(1, t('validation_min_one_row')),
|
||||
})
|
||||
}, [t])
|
||||
|
||||
type FormData = z.infer<typeof schema>
|
||||
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
customer_id: '',
|
||||
name: '',
|
||||
day_of_month: 15,
|
||||
payment_terms_days: 30,
|
||||
currency: 'SEK',
|
||||
auto_send: false,
|
||||
items: [{ description: '', quantity: 1, unit: 'st', unit_price: 0, vat_rate: 25 }],
|
||||
},
|
||||
})
|
||||
|
||||
const { fields, append, remove } = useFieldArray({ control, name: 'items' })
|
||||
|
||||
useEffect(() => {
|
||||
if (!company) return
|
||||
supabase
|
||||
.from('customers')
|
||||
.select('*')
|
||||
.eq('company_id', company.id)
|
||||
.order('name')
|
||||
.then(({ data }) => setCustomers(data ?? []))
|
||||
}, [company])
|
||||
|
||||
async function onSubmit(data: FormData) {
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const res = await fetch('/api/invoices/recurring', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error || t('create_failed_fallback'))
|
||||
}
|
||||
toast({ title: t('created_title') })
|
||||
router.push('/invoices/recurring')
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t('create_failed_title'),
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const items = watch('items')
|
||||
const watchCurrency = watch('currency')
|
||||
const subtotalRaw = items.reduce(
|
||||
(sum, it) => sum + (it.quantity || 0) * (it.unit_price || 0),
|
||||
0,
|
||||
)
|
||||
// Round to öre using the project monetary rule, then format.
|
||||
const subtotal = Math.round(subtotalRaw * 100) / 100
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Link
|
||||
href="/invoices/recurring"
|
||||
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
{t('back')}
|
||||
</Link>
|
||||
|
||||
<PageHeader title={t('title')} />
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('schedule_card_title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">{t('name_label')}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder={t('name_placeholder')}
|
||||
{...register('name')}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="customer_id">{t('customer_label')}</Label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="customer_id"
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger id="customer_id">
|
||||
<SelectValue placeholder={t('customer_placeholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{customers.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
{errors.customer_id && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.customer_id.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="day_of_month">{t('day_label')}</Label>
|
||||
<Input
|
||||
id="day_of_month"
|
||||
type="number"
|
||||
min={1}
|
||||
max={31}
|
||||
className="tabular-nums"
|
||||
{...register('day_of_month', { valueAsNumber: true })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t('day_hint')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="payment_terms_days">{t('payment_terms_label')}</Label>
|
||||
<Input
|
||||
id="payment_terms_days"
|
||||
type="number"
|
||||
min={0}
|
||||
max={90}
|
||||
className="tabular-nums"
|
||||
{...register('payment_terms_days', { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="currency">{t('currency_label')}</Label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="currency"
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger id="currency">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{currencies.map((c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
{c}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Controller
|
||||
control={control}
|
||||
name="auto_send"
|
||||
render={({ field }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
id="auto_send"
|
||||
checked={field.value}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
className="mt-1 h-4 w-4"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="auto_send" className="font-medium">
|
||||
{t('auto_send_label')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t('auto_send_description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('items_card_title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="grid grid-cols-12 gap-2 items-start"
|
||||
>
|
||||
<div className="col-span-12 sm:col-span-5">
|
||||
<Input
|
||||
placeholder={t('description_placeholder')}
|
||||
{...register(`items.${index}.description`)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-3 sm:col-span-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder={t('quantity_placeholder')}
|
||||
className="tabular-nums"
|
||||
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-3 sm:col-span-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`items.${index}.unit`}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{units.map((u) => (
|
||||
<SelectItem key={u} value={u}>
|
||||
{u}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-4 sm:col-span-3">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder={t('unit_price_placeholder')}
|
||||
className="tabular-nums"
|
||||
{...register(`items.${index}.unit_price`, { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 sm:col-span-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => fields.length > 1 && remove(index)}
|
||||
aria-label={t('remove_row')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
append({ description: '', quantity: 1, unit: 'st', unit_price: 0, vat_rate: 25 })
|
||||
}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('add_row')}
|
||||
</Button>
|
||||
<div className="pt-2 text-sm text-muted-foreground tabular-nums">
|
||||
{t('subtotal_ex_vat', { amount: formatCurrency(subtotal, watchCurrency) })}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('other_card_title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="your_reference">{t('your_reference_label')}</Label>
|
||||
<Input id="your_reference" {...register('your_reference')} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="our_reference">{t('our_reference_label')}</Label>
|
||||
<Input id="our_reference" {...register('our_reference')} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="notes">{t('notes_label')}</Label>
|
||||
<Textarea id="notes" rows={3} {...register('notes')} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Link href="/invoices/recurring">
|
||||
<Button type="button" variant="secondary">
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
</Link>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? t('creating') : t('create_schedule')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
redirect('/invoices/recurring?new=1')
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
@@ -21,6 +20,7 @@ import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { Plus, Repeat, Lock, AlertTriangle } from 'lucide-react'
|
||||
import NewRecurringScheduleDialog from '@/components/invoices/NewRecurringScheduleDialog'
|
||||
import type { RecurringInvoiceSchedule, Customer } from '@/types'
|
||||
|
||||
type ScheduleRow = RecurringInvoiceSchedule & {
|
||||
@@ -33,8 +33,17 @@ export default function RecurringInvoicesPage() {
|
||||
const { canWrite } = useCanWrite()
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const t = useTranslations('invoice_recurring')
|
||||
|
||||
// The "Nytt schema" modal is driven by the URL (?new=1) so every entry
|
||||
// point — the header button, the empty state, and the legacy
|
||||
// /invoices/recurring/new redirect — opens the same dialog, and the
|
||||
// browser back button closes it. Same pattern as /invoices.
|
||||
const showNewSchedule = searchParams.has('new')
|
||||
const closeNewSchedule = () => router.replace('/invoices/recurring', { scroll: false })
|
||||
const openNewSchedule = () => router.push('/invoices/recurring?new=1', { scroll: false })
|
||||
|
||||
async function fetchSchedules() {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
@@ -95,12 +104,10 @@ export default function RecurringInvoicesPage() {
|
||||
title={t('title')}
|
||||
action={
|
||||
canWrite ? (
|
||||
<Link href="/invoices/recurring/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('new_schedule')}
|
||||
</Button>
|
||||
</Link>
|
||||
<Button onClick={openNewSchedule}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('new_schedule')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button disabled title={t('viewer_disabled_tooltip')}>
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
@@ -124,7 +131,7 @@ export default function RecurringInvoicesPage() {
|
||||
title={t('empty_title')}
|
||||
description={t('empty_description')}
|
||||
actionLabel={canWrite ? t('new_schedule') : undefined}
|
||||
actionHref={canWrite ? '/invoices/recurring/new' : undefined}
|
||||
onAction={canWrite ? openNewSchedule : undefined}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -208,6 +215,17 @@ export default function RecurringInvoicesPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<NewRecurringScheduleDialog
|
||||
open={showNewSchedule}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeNewSchedule()
|
||||
}}
|
||||
onCreated={() => {
|
||||
closeNewSchedule()
|
||||
fetchSchedules()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,351 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { ArrowLeft, Save } from 'lucide-react'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import EmployeeTaxCard, { type EmployeeTaxValue } from '@/components/salary/EmployeeTaxCard'
|
||||
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
|
||||
|
||||
function RequiredMark() {
|
||||
return <span className="text-destructive ml-0.5">*</span>
|
||||
}
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
// Employee creation now happens in a modal on the employee list (matching the
|
||||
// verifikat pattern) — the form itself lives in
|
||||
// components/salary/NewEmployeeDialog.tsx. This route survives as a redirect
|
||||
// so old links, bookmarks, and agent intents keep working.
|
||||
export default function NewEmployeePage() {
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [employmentType, setEmploymentType] = useState('employee')
|
||||
const [salaryType, setSalaryType] = useState('monthly')
|
||||
const [personnummer, setPersonnummer] = useState('')
|
||||
const [vacationRule, setVacationRule] = useState('procentregeln')
|
||||
// Default dimensions bag ({sie_dim_no: object_code}) proposed on the
|
||||
// employee's salary-cost lines at booking. The fields render only when
|
||||
// company_settings.dimensions_enabled — same UI gate as the voucher form.
|
||||
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
|
||||
const [dimensions, setDimensions] = useState<Record<string, string>>({})
|
||||
const [tax, setTax] = useState<EmployeeTaxValue>({
|
||||
f_skatt_status: 'a_skatt',
|
||||
is_sidoinkomst: false,
|
||||
tax_table_number: null,
|
||||
tax_column: 1,
|
||||
tax_municipality: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/settings')
|
||||
.then((r) => r.json())
|
||||
.then(({ data }) => setDimensionsEnabled(data?.dimensions_enabled === true))
|
||||
.catch(() => {/* keep the dimension fields hidden */})
|
||||
}, [])
|
||||
|
||||
function setDimension(dimNo: string, code: string | null) {
|
||||
setDimensions((prev) => {
|
||||
const next = { ...prev }
|
||||
const value = code?.trim()
|
||||
if (value) next[dimNo] = value
|
||||
else delete next[dimNo]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
|
||||
const form = new FormData(e.currentTarget)
|
||||
const body = {
|
||||
first_name: form.get('first_name') as string,
|
||||
last_name: form.get('last_name') as string,
|
||||
personnummer: personnummer.replace(/\D/g, ''),
|
||||
employment_type: employmentType,
|
||||
employment_start: form.get('employment_start') as string,
|
||||
employment_end: form.get('employment_end') as string || undefined,
|
||||
employment_degree: parseFloat(form.get('employment_degree') as string) || 100,
|
||||
salary_type: salaryType,
|
||||
monthly_salary: salaryType === 'monthly' ? (parseFloat(form.get('monthly_salary') as string) || undefined) : undefined,
|
||||
hourly_rate: salaryType === 'hourly' ? (parseFloat(form.get('hourly_rate') as string) || undefined) : undefined,
|
||||
f_skatt_status: tax.f_skatt_status,
|
||||
is_sidoinkomst: tax.is_sidoinkomst,
|
||||
tax_table_number: tax.tax_table_number ?? undefined,
|
||||
tax_column: tax.tax_column,
|
||||
tax_municipality: tax.tax_municipality || undefined,
|
||||
email: form.get('email') as string || undefined,
|
||||
phone: form.get('phone') as string || undefined,
|
||||
address_line1: form.get('address_line1') as string || undefined,
|
||||
postal_code: form.get('postal_code') as string || undefined,
|
||||
city: form.get('city') as string || undefined,
|
||||
clearing_number: form.get('clearing_number') as string || undefined,
|
||||
bank_account_number: form.get('bank_account_number') as string || undefined,
|
||||
vacation_rule: vacationRule,
|
||||
vacation_days_per_year: parseInt(form.get('vacation_days_per_year') as string) || 25,
|
||||
// Always sent — {} means no default dimensions.
|
||||
default_dimensions: dimensions,
|
||||
}
|
||||
|
||||
const res = await fetch('/api/salary/employees', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
toast({ title: 'Anställd skapad' })
|
||||
router.push('/salary/employees')
|
||||
} else {
|
||||
const result = await res.json()
|
||||
toast({
|
||||
title: 'Kunde inte skapa anställd',
|
||||
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href="/salary/employees" aria-label="Tillbaka till anställda"><ArrowLeft className="h-4 w-4" /></Link>
|
||||
</Button>
|
||||
<h1 className="font-display text-2xl md:text-3xl tracking-tight">Ny anställd</h1>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Personal info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Personuppgifter</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="first_name">Förnamn<RequiredMark /></Label>
|
||||
<Input id="first_name" name="first_name" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="last_name">Efternamn<RequiredMark /></Label>
|
||||
<Input id="last_name" name="last_name" required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="personnummer">Personnummer (12 siffror)<RequiredMark /></Label>
|
||||
<Input
|
||||
id="personnummer"
|
||||
name="personnummer"
|
||||
placeholder="ÅÅÅÅMMDDNNNN"
|
||||
required
|
||||
maxLength={13}
|
||||
value={personnummer}
|
||||
onChange={(e) => setPersonnummer(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Krypteras vid lagring</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-post</Label>
|
||||
<Input id="email" name="email" type="email" />
|
||||
<p className="text-xs text-muted-foreground">Krävs för att skicka lönebesked</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">Telefon</Label>
|
||||
<Input id="phone" name="phone" className="max-w-xs" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Address */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Adress</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address_line1">Gatuadress</Label>
|
||||
<Input id="address_line1" name="address_line1" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="postal_code">Postnummer</Label>
|
||||
<Input id="postal_code" name="postal_code" className="max-w-[160px]" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city">Ort</Label>
|
||||
<Input id="city" name="city" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Employment */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Anställning</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="employment_type">Typ</Label>
|
||||
<Select value={employmentType} onValueChange={setEmploymentType}>
|
||||
<SelectTrigger id="employment_type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="employee">Anställd</SelectItem>
|
||||
<SelectItem value="company_owner">Företagsledare</SelectItem>
|
||||
<SelectItem value="board_member">Styrelseledamot</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="employment_start">Anställningsdatum<RequiredMark /></Label>
|
||||
<Input id="employment_start" name="employment_start" type="date" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="employment_end">Slutdatum</Label>
|
||||
<Input id="employment_end" name="employment_end" type="date" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="employment_degree">Sysselsättningsgrad (%)</Label>
|
||||
<Input id="employment_degree" name="employment_degree" type="number" defaultValue="100" min="1" max="100" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Salary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Lön</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="salary_type">Löneform<RequiredMark /></Label>
|
||||
<Select value={salaryType} onValueChange={setSalaryType}>
|
||||
<SelectTrigger id="salary_type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="monthly">Månadslön</SelectItem>
|
||||
<SelectItem value="hourly">Timlön</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{salaryType === 'monthly' ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="monthly_salary">Månadslön (brutto, SEK)<RequiredMark /></Label>
|
||||
<Input id="monthly_salary" name="monthly_salary" type="number" step="1" min="1" required />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hourly_rate">Timlön (SEK)<RequiredMark /></Label>
|
||||
<Input id="hourly_rate" name="hourly_rate" type="number" step="0.01" min="0.01" required />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Default dimensions (kostnadsställe/projekt) */}
|
||||
{dimensionsEnabled && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Kostnadsställe / Projekt (standard)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<LineDimensionFields dimensions={dimensions} onChange={setDimension} />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Föreslås på lönekostnadsrader vid bokföring av lönekörningar.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tax */}
|
||||
<EmployeeTaxCard personnummer={personnummer} onChange={setTax} />
|
||||
|
||||
{/* Vacation */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Semester</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vacation_rule">Semesterregel</Label>
|
||||
<Select value={vacationRule} onValueChange={setVacationRule}>
|
||||
<SelectTrigger id="vacation_rule">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="procentregeln">Procentregeln (12 %)</SelectItem>
|
||||
<SelectItem value="sammaloneregeln">Sammalöneregeln</SelectItem>
|
||||
<SelectItem value="semesterersattning">Semesterersättning (betalas ut direkt)</SelectItem>
|
||||
<SelectItem value="none">Ingen semesteravsättning</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{vacationRule === 'none' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ingen avsättning till 2920 bokas. Vanligt för ägare som är enda anställd.
|
||||
</p>
|
||||
)}
|
||||
{vacationRule === 'semesterersattning' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
12 % läggs på varje lönekörning och bokas mot 7285. Ingen semesterlöneskuld byggs upp — vanligt för tim- och visstidsanställda.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vacation_days_per_year">Semesterdagar per år</Label>
|
||||
<Input id="vacation_days_per_year" name="vacation_days_per_year" type="number" min="25" max="40" defaultValue="25" />
|
||||
<p className="text-xs text-muted-foreground">Lagstadgat minimum: 25 dagar</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bank */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Bankkonto</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clearing_number">Clearingnummer</Label>
|
||||
<Input id="clearing_number" name="clearing_number" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bank_account_number">Kontonummer</Label>
|
||||
<Input id="bank_account_number" name="bank_account_number" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">Krävs innan lönekörning kan godkännas</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/salary/employees">Avbryt</Link>
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{saving ? 'Sparar...' : 'Spara'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
redirect('/salary/employees?new=1')
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
@@ -11,6 +12,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
|
||||
import { Plus, ArrowLeft, UserCircle } from 'lucide-react'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import NewEmployeeDialog from '@/components/salary/NewEmployeeDialog'
|
||||
import type { Employee } from '@/types'
|
||||
|
||||
const EMPLOYMENT_LABEL_KEYS: Record<string, string> = {
|
||||
@@ -24,6 +26,19 @@ export default function EmployeesPage() {
|
||||
const [employees, setEmployees] = useState<Employee[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const { canWrite } = useCanWrite()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
// The "Ny anställd" modal is driven by the URL (?new=1) so every entry
|
||||
// point — the header button, the empty state, and the legacy
|
||||
// /salary/employees/new redirect — opens the same dialog, and the browser
|
||||
// back button closes it. Same pattern as /invoices.
|
||||
const showNewEmployee = searchParams.has('new')
|
||||
const closeNewEmployee = () => router.replace('/salary/employees', { scroll: false })
|
||||
const openNewEmployee = () => router.push('/salary/employees?new=1', { scroll: false })
|
||||
|
||||
// Bumped after a create in the dialog so the effect refetches the list.
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
@@ -35,7 +50,7 @@ export default function EmployeesPage() {
|
||||
setLoading(false)
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
}, [refreshKey])
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
@@ -50,11 +65,9 @@ export default function EmployeesPage() {
|
||||
</div>
|
||||
</div>
|
||||
{canWrite && (
|
||||
<Button asChild>
|
||||
<Link href="/salary/employees/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('new_employee')}
|
||||
</Link>
|
||||
<Button onClick={openNewEmployee}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('new_employee')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -73,7 +86,7 @@ export default function EmployeesPage() {
|
||||
title={t('empty_title')}
|
||||
description={t('empty_description')}
|
||||
actionLabel={canWrite ? t('add_employee') : undefined}
|
||||
actionHref={canWrite ? '/salary/employees/new' : undefined}
|
||||
onAction={canWrite ? openNewEmployee : undefined}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -125,6 +138,17 @@ export default function EmployeesPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<NewEmployeeDialog
|
||||
open={showNewEmployee}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeNewEmployee()
|
||||
}}
|
||||
onCreated={() => {
|
||||
closeNewEmployee()
|
||||
setRefreshKey((k) => k + 1)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
@@ -13,6 +14,7 @@ import { Plus, Users, HandCoins, CalendarDays, ArrowRight } from 'lucide-react'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import NewSalaryRunDialog from '@/components/salary/NewSalaryRunDialog'
|
||||
import type { SalaryRun } from '@/types'
|
||||
|
||||
const STATUS_LABEL_KEYS: Record<string, string> = {
|
||||
@@ -36,8 +38,18 @@ export default function SalaryPage() {
|
||||
const [employeeCount, setEmployeeCount] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const { canWrite } = useCanWrite()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const t = useTranslations('salary')
|
||||
|
||||
// The "Ny lönekörning" modal is driven by the URL (?new=1) so every entry
|
||||
// point — the header button, the empty state, and the legacy
|
||||
// /salary/runs/new redirect — opens the same dialog, and the browser back
|
||||
// button closes it. Same pattern as /invoices.
|
||||
const showNewRun = searchParams.has('new')
|
||||
const closeNewRun = () => router.replace('/salary', { scroll: false })
|
||||
const openNewRun = () => router.push('/salary?new=1', { scroll: false })
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const [runsRes, empRes] = await Promise.all([
|
||||
@@ -93,11 +105,9 @@ export default function SalaryPage() {
|
||||
</Link>
|
||||
</Button>
|
||||
{canWrite && (
|
||||
<Button asChild>
|
||||
<Link href="/salary/runs/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('new_run')}
|
||||
</Link>
|
||||
<Button onClick={openNewRun}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('new_run')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -153,7 +163,7 @@ export default function SalaryPage() {
|
||||
title={t('empty_runs_title')}
|
||||
description={t('empty_runs_description')}
|
||||
actionLabel={canWrite ? t('create_run') : undefined}
|
||||
actionHref={canWrite ? '/salary/runs/new' : undefined}
|
||||
onAction={canWrite ? openNewRun : undefined}
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
@@ -203,6 +213,13 @@ export default function SalaryPage() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<NewSalaryRunDialog
|
||||
open={showNewRun}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeNewRun()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,107 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ArrowLeft, ArrowRight } from 'lucide-react'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
// Salary run creation now happens in a modal on the salary overview (matching
|
||||
// the verifikat pattern) — the form itself lives in
|
||||
// components/salary/NewSalaryRunDialog.tsx. This route survives as a redirect
|
||||
// so old links, bookmarks, and agent intents keep working.
|
||||
export default function NewSalaryRunPage() {
|
||||
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 (
|
||||
<div className="space-y-8 max-w-2xl mx-auto">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href="/salary" aria-label="Tillbaka till löner"><ArrowLeft className="h-4 w-4" /></Link>
|
||||
</Button>
|
||||
<h1 className="font-display text-2xl md:text-3xl tracking-tight">Ny lönekörning</h1>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Period och utbetalning</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent 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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/salary">Avbryt</Link>
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? 'Skapar...' : 'Skapa och fortsätt'}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
redirect('/salary?new=1')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useForm, useFieldArray, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import type { Customer, Currency } from '@/types'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
|
||||
const currencies: Currency[] = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']
|
||||
const units = ['st', 'tim', 'dag', 'månad', 'km', 'kg']
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** Fired after a successful create. Hosts close the dialog and refresh their list. */
|
||||
onCreated: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* "Nytt schema" as a modal — mirrors NewInvoiceDialog now that regular
|
||||
* invoice creation opens in one. Card sections carry over from the old
|
||||
* /invoices/recurring/new page.
|
||||
*/
|
||||
export default function NewRecurringScheduleDialog({ open, onOpenChange, onCreated }: Props) {
|
||||
const t = useTranslations('invoice_recurring_new')
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="sm:max-w-4xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto"
|
||||
// A half-typed schedule must survive an accidental backdrop click or
|
||||
// a stray Escape (the customer/currency selects portal outside the
|
||||
// dialog). Closing is explicit — the header X or Avbryt. Same
|
||||
// convention as NewInvoiceDialog.
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<NewRecurringScheduleForm onCreated={onCreated} onCancel={() => onOpenChange(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// Inner component so form state resets whenever the dialog reopens (Radix
|
||||
// unmounts DialogContent children on close).
|
||||
function NewRecurringScheduleForm({ onCreated, onCancel }: { onCreated: () => void; onCancel: () => void }) {
|
||||
const { toast } = useToast()
|
||||
const { company } = useCompany()
|
||||
const supabase = createClient()
|
||||
const t = useTranslations('invoice_recurring_new')
|
||||
const [customers, setCustomers] = useState<Customer[]>([])
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const schema = useMemo(() => {
|
||||
const itemSchema = z.object({
|
||||
description: z.string().min(1, t('validation_description_required')),
|
||||
quantity: z.number().min(0.01, t('validation_quantity_min')),
|
||||
unit: z.string().min(1, t('validation_unit_required')),
|
||||
unit_price: z.number(),
|
||||
vat_rate: z
|
||||
.union([z.literal(0), z.literal(6), z.literal(12), z.literal(25)])
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
return z.object({
|
||||
customer_id: z.string().uuid(t('validation_customer_required')),
|
||||
name: z.string().min(1, t('validation_name_required')),
|
||||
day_of_month: z.number().int().min(1).max(31),
|
||||
payment_terms_days: z.number().int().min(0).max(90),
|
||||
currency: z.enum(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']),
|
||||
auto_send: z.boolean(),
|
||||
your_reference: z.string().optional(),
|
||||
our_reference: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
items: z.array(itemSchema).min(1, t('validation_min_one_row')),
|
||||
})
|
||||
}, [t])
|
||||
|
||||
type FormData = z.infer<typeof schema>
|
||||
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
customer_id: '',
|
||||
name: '',
|
||||
day_of_month: 15,
|
||||
payment_terms_days: 30,
|
||||
currency: 'SEK',
|
||||
auto_send: false,
|
||||
items: [{ description: '', quantity: 1, unit: 'st', unit_price: 0, vat_rate: 25 }],
|
||||
},
|
||||
})
|
||||
|
||||
const { fields, append, remove } = useFieldArray({ control, name: 'items' })
|
||||
|
||||
useEffect(() => {
|
||||
if (!company) return
|
||||
supabase
|
||||
.from('customers')
|
||||
.select('*')
|
||||
.eq('company_id', company.id)
|
||||
.order('name')
|
||||
.then(({ data }) => setCustomers(data ?? []))
|
||||
}, [company])
|
||||
|
||||
async function onSubmit(data: FormData) {
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const res = await fetch('/api/invoices/recurring', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error || t('create_failed_fallback'))
|
||||
}
|
||||
toast({ title: t('created_title') })
|
||||
onCreated()
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t('create_failed_title'),
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const items = watch('items')
|
||||
const watchCurrency = watch('currency')
|
||||
const subtotalRaw = items.reduce(
|
||||
(sum, it) => sum + (it.quantity || 0) * (it.unit_price || 0),
|
||||
0,
|
||||
)
|
||||
// Round to öre using the project monetary rule, then format.
|
||||
const subtotal = Math.round(subtotalRaw * 100) / 100
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('schedule_card_title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">{t('name_label')}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder={t('name_placeholder')}
|
||||
{...register('name')}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="customer_id">{t('customer_label')}</Label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="customer_id"
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger id="customer_id">
|
||||
<SelectValue placeholder={t('customer_placeholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{customers.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
{errors.customer_id && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.customer_id.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="day_of_month">{t('day_label')}</Label>
|
||||
<Input
|
||||
id="day_of_month"
|
||||
type="number"
|
||||
min={1}
|
||||
max={31}
|
||||
className="tabular-nums"
|
||||
{...register('day_of_month', { valueAsNumber: true })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t('day_hint')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="payment_terms_days">{t('payment_terms_label')}</Label>
|
||||
<Input
|
||||
id="payment_terms_days"
|
||||
type="number"
|
||||
min={0}
|
||||
max={90}
|
||||
className="tabular-nums"
|
||||
{...register('payment_terms_days', { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="currency">{t('currency_label')}</Label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="currency"
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger id="currency">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{currencies.map((c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
{c}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Controller
|
||||
control={control}
|
||||
name="auto_send"
|
||||
render={({ field }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
id="auto_send"
|
||||
checked={field.value}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
className="mt-1 h-4 w-4"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="auto_send" className="font-medium">
|
||||
{t('auto_send_label')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t('auto_send_description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('items_card_title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="grid grid-cols-12 gap-2 items-start"
|
||||
>
|
||||
<div className="col-span-12 sm:col-span-5">
|
||||
<Input
|
||||
placeholder={t('description_placeholder')}
|
||||
{...register(`items.${index}.description`)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-3 sm:col-span-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder={t('quantity_placeholder')}
|
||||
className="tabular-nums"
|
||||
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-3 sm:col-span-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`items.${index}.unit`}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{units.map((u) => (
|
||||
<SelectItem key={u} value={u}>
|
||||
{u}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-4 sm:col-span-3">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder={t('unit_price_placeholder')}
|
||||
className="tabular-nums"
|
||||
{...register(`items.${index}.unit_price`, { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 sm:col-span-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => fields.length > 1 && remove(index)}
|
||||
aria-label={t('remove_row')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
append({ description: '', quantity: 1, unit: 'st', unit_price: 0, vat_rate: 25 })
|
||||
}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('add_row')}
|
||||
</Button>
|
||||
<div className="pt-2 text-sm text-muted-foreground tabular-nums">
|
||||
{t('subtotal_ex_vat', { amount: formatCurrency(subtotal, watchCurrency) })}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('other_card_title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="your_reference">{t('your_reference_label')}</Label>
|
||||
<Input id="your_reference" {...register('your_reference')} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="our_reference">{t('our_reference_label')}</Label>
|
||||
<Input id="our_reference" {...register('our_reference')} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="notes">{t('notes_label')}</Label>
|
||||
<Textarea id="notes" rows={3} {...register('notes')} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={onCancel}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? t('creating') : t('create_schedule')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Save } from 'lucide-react'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import EmployeeTaxCard, { type EmployeeTaxValue } from '@/components/salary/EmployeeTaxCard'
|
||||
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
|
||||
|
||||
function RequiredMark() {
|
||||
return <span className="text-destructive ml-0.5">*</span>
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** Fired after a successful create. Hosts close the dialog and refresh their list. */
|
||||
onCreated: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* "Ny anställd" as a modal — mirrors NewSupplierInvoiceDialog. The last
|
||||
* register entity (after customers/suppliers/articles) to move off a full
|
||||
* page. Card sections carry over from the old /salary/employees/new page.
|
||||
*/
|
||||
export default function NewEmployeeDialog({ open, onOpenChange, onCreated }: Props) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto"
|
||||
// A half-typed employee must survive an accidental backdrop click or
|
||||
// a stray Escape (the municipality combobox and dimension pickers
|
||||
// portal outside the dialog). Closing is explicit — the header X or
|
||||
// Avbryt. Same convention as NewJournalEntryDialog.
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ny anställd</DialogTitle>
|
||||
</DialogHeader>
|
||||
<NewEmployeeForm onCreated={onCreated} onCancel={() => onOpenChange(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// Inner component so form state resets whenever the dialog reopens (Radix
|
||||
// unmounts DialogContent children on close).
|
||||
function NewEmployeeForm({ onCreated, onCancel }: { onCreated: () => void; onCancel: () => void }) {
|
||||
const { toast } = useToast()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [employmentType, setEmploymentType] = useState('employee')
|
||||
const [salaryType, setSalaryType] = useState('monthly')
|
||||
const [personnummer, setPersonnummer] = useState('')
|
||||
const [vacationRule, setVacationRule] = useState('procentregeln')
|
||||
// Default dimensions bag ({sie_dim_no: object_code}) proposed on the
|
||||
// employee's salary-cost lines at booking. The fields render only when
|
||||
// company_settings.dimensions_enabled — same UI gate as the voucher form.
|
||||
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
|
||||
const [dimensions, setDimensions] = useState<Record<string, string>>({})
|
||||
const [tax, setTax] = useState<EmployeeTaxValue>({
|
||||
f_skatt_status: 'a_skatt',
|
||||
is_sidoinkomst: false,
|
||||
tax_table_number: null,
|
||||
tax_column: 1,
|
||||
tax_municipality: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/settings')
|
||||
.then((r) => r.json())
|
||||
.then(({ data }) => setDimensionsEnabled(data?.dimensions_enabled === true))
|
||||
.catch(() => {/* keep the dimension fields hidden */})
|
||||
}, [])
|
||||
|
||||
function setDimension(dimNo: string, code: string | null) {
|
||||
setDimensions((prev) => {
|
||||
const next = { ...prev }
|
||||
const value = code?.trim()
|
||||
if (value) next[dimNo] = value
|
||||
else delete next[dimNo]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
|
||||
const form = new FormData(e.currentTarget)
|
||||
const body = {
|
||||
first_name: form.get('first_name') as string,
|
||||
last_name: form.get('last_name') as string,
|
||||
personnummer: personnummer.replace(/\D/g, ''),
|
||||
employment_type: employmentType,
|
||||
employment_start: form.get('employment_start') as string,
|
||||
employment_end: form.get('employment_end') as string || undefined,
|
||||
employment_degree: parseFloat(form.get('employment_degree') as string) || 100,
|
||||
salary_type: salaryType,
|
||||
monthly_salary: salaryType === 'monthly' ? (parseFloat(form.get('monthly_salary') as string) || undefined) : undefined,
|
||||
hourly_rate: salaryType === 'hourly' ? (parseFloat(form.get('hourly_rate') as string) || undefined) : undefined,
|
||||
f_skatt_status: tax.f_skatt_status,
|
||||
is_sidoinkomst: tax.is_sidoinkomst,
|
||||
tax_table_number: tax.tax_table_number ?? undefined,
|
||||
tax_column: tax.tax_column,
|
||||
tax_municipality: tax.tax_municipality || undefined,
|
||||
email: form.get('email') as string || undefined,
|
||||
phone: form.get('phone') as string || undefined,
|
||||
address_line1: form.get('address_line1') as string || undefined,
|
||||
postal_code: form.get('postal_code') as string || undefined,
|
||||
city: form.get('city') as string || undefined,
|
||||
clearing_number: form.get('clearing_number') as string || undefined,
|
||||
bank_account_number: form.get('bank_account_number') as string || undefined,
|
||||
vacation_rule: vacationRule,
|
||||
vacation_days_per_year: parseInt(form.get('vacation_days_per_year') as string) || 25,
|
||||
// Always sent — {} means no default dimensions.
|
||||
default_dimensions: dimensions,
|
||||
}
|
||||
|
||||
const res = await fetch('/api/salary/employees', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
toast({ title: 'Anställd skapad' })
|
||||
onCreated()
|
||||
} else {
|
||||
const result = await res.json()
|
||||
toast({
|
||||
title: 'Kunde inte skapa anställd',
|
||||
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Personal info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Personuppgifter</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="first_name">Förnamn<RequiredMark /></Label>
|
||||
<Input id="first_name" name="first_name" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="last_name">Efternamn<RequiredMark /></Label>
|
||||
<Input id="last_name" name="last_name" required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="personnummer">Personnummer (12 siffror)<RequiredMark /></Label>
|
||||
<Input
|
||||
id="personnummer"
|
||||
name="personnummer"
|
||||
placeholder="ÅÅÅÅMMDDNNNN"
|
||||
required
|
||||
maxLength={13}
|
||||
value={personnummer}
|
||||
onChange={(e) => setPersonnummer(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Krypteras vid lagring</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-post</Label>
|
||||
<Input id="email" name="email" type="email" />
|
||||
<p className="text-xs text-muted-foreground">Krävs för att skicka lönebesked</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">Telefon</Label>
|
||||
<Input id="phone" name="phone" className="max-w-xs" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Address */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Adress</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address_line1">Gatuadress</Label>
|
||||
<Input id="address_line1" name="address_line1" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="postal_code">Postnummer</Label>
|
||||
<Input id="postal_code" name="postal_code" className="max-w-[160px]" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city">Ort</Label>
|
||||
<Input id="city" name="city" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Employment */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Anställning</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="employment_type">Typ</Label>
|
||||
<Select value={employmentType} onValueChange={setEmploymentType}>
|
||||
<SelectTrigger id="employment_type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="employee">Anställd</SelectItem>
|
||||
<SelectItem value="company_owner">Företagsledare</SelectItem>
|
||||
<SelectItem value="board_member">Styrelseledamot</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="employment_start">Anställningsdatum<RequiredMark /></Label>
|
||||
<Input id="employment_start" name="employment_start" type="date" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="employment_end">Slutdatum</Label>
|
||||
<Input id="employment_end" name="employment_end" type="date" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="employment_degree">Sysselsättningsgrad (%)</Label>
|
||||
<Input id="employment_degree" name="employment_degree" type="number" defaultValue="100" min="1" max="100" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Salary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Lön</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="salary_type">Löneform<RequiredMark /></Label>
|
||||
<Select value={salaryType} onValueChange={setSalaryType}>
|
||||
<SelectTrigger id="salary_type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="monthly">Månadslön</SelectItem>
|
||||
<SelectItem value="hourly">Timlön</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{salaryType === 'monthly' ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="monthly_salary">Månadslön (brutto, SEK)<RequiredMark /></Label>
|
||||
<Input id="monthly_salary" name="monthly_salary" type="number" step="1" min="1" required />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hourly_rate">Timlön (SEK)<RequiredMark /></Label>
|
||||
<Input id="hourly_rate" name="hourly_rate" type="number" step="0.01" min="0.01" required />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Default dimensions (kostnadsställe/projekt) */}
|
||||
{dimensionsEnabled && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Kostnadsställe / Projekt (standard)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<LineDimensionFields dimensions={dimensions} onChange={setDimension} />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Föreslås på lönekostnadsrader vid bokföring av lönekörningar.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tax */}
|
||||
<EmployeeTaxCard personnummer={personnummer} onChange={setTax} />
|
||||
|
||||
{/* Vacation */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Semester</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vacation_rule">Semesterregel</Label>
|
||||
<Select value={vacationRule} onValueChange={setVacationRule}>
|
||||
<SelectTrigger id="vacation_rule">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="procentregeln">Procentregeln (12 %)</SelectItem>
|
||||
<SelectItem value="sammaloneregeln">Sammalöneregeln</SelectItem>
|
||||
<SelectItem value="semesterersattning">Semesterersättning (betalas ut direkt)</SelectItem>
|
||||
<SelectItem value="none">Ingen semesteravsättning</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{vacationRule === 'none' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ingen avsättning till 2920 bokas. Vanligt för ägare som är enda anställd.
|
||||
</p>
|
||||
)}
|
||||
{vacationRule === 'semesterersattning' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
12 % läggs på varje lönekörning och bokas mot 7285. Ingen semesterlöneskuld byggs upp — vanligt för tim- och visstidsanställda.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vacation_days_per_year">Semesterdagar per år</Label>
|
||||
<Input id="vacation_days_per_year" name="vacation_days_per_year" type="number" min="25" max="40" defaultValue="25" />
|
||||
<p className="text-xs text-muted-foreground">Lagstadgat minimum: 25 dagar</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bank */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Bankkonto</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clearing_number">Clearingnummer</Label>
|
||||
<Input id="clearing_number" name="clearing_number" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bank_account_number">Kontonummer</Label>
|
||||
<Input id="bank_account_number" name="bank_account_number" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">Krävs innan lönekörning kan godkännas</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{saving ? 'Sparar...' : 'Spara'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -40,6 +40,10 @@ const eslintConfig = defineConfig([
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
// Claude Code session worktrees are full repo copies — without this,
|
||||
// local `npm run lint` / `check:lint` walks them (and their node_modules
|
||||
// siblings), inflating the report until the ratchet's JSON parse fails.
|
||||
".claude/worktrees/**",
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user