Files
accounted/components/dimensions/DimensionValueForm.tsx
T
Jakob Wennberg ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00

224 lines
7.2 KiB
TypeScript

'use client'
import { useMemo, useState } from 'react'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { DestructiveConfirmDialog } from '@/components/ui/destructive-confirm-dialog'
import { Loader2, Trash2 } from 'lucide-react'
import {
DIMENSION_CODE_PATTERN,
PROJECT_DIM_NO,
type DimensionDto,
type DimensionValueDto,
} from '@/components/dimensions/types'
export interface DimensionValueFormInput {
code: string
name: string
is_active: boolean
start_date: string | null
end_date: string | null
}
interface DimensionValueFormProps {
/** The dimension the value belongs to (drives the Projekt date fields). */
dimension: DimensionDto
/** When set, the form edits this value (code becomes immutable). */
value?: DimensionValueDto | null
isSaving?: boolean
onSubmit: (input: DimensionValueFormInput) => void | Promise<void>
/** Rendered only when editing. The caller performs the DELETE and surfaces
* the retention-trigger error ("…arkivera det istället") as a toast. */
onDelete?: () => void | Promise<void>
}
/**
* Create/edit form for a dimension value (#OBJEKT), hosted in the
* DimensionsManager dialog. Code is immutable in v1: the field is disabled
* when editing. Start/end dates appear only for Projekt (dim 6), matching the
* SIE model where projects span a date range while cost centres do not.
*/
export default function DimensionValueForm({
dimension,
value,
isSaving = false,
onSubmit,
onDelete,
}: DimensionValueFormProps) {
const t = useTranslations('dimensions')
const isEditing = Boolean(value)
const isProject = dimension.sie_dim_no === PROJECT_DIM_NO
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
const schema = useMemo(
() =>
z
.object({
// Legacy/backfilled codes can be looser than the strict format, so
// the pattern is only enforced when the code is user-typed (create).
code: isEditing
? z.string()
: z.string().regex(DIMENSION_CODE_PATTERN, t('form_code_invalid')),
name: z.string().min(1, t('form_name_invalid')).max(120, t('form_name_invalid')),
is_active: z.boolean(),
start_date: z.string().optional(),
end_date: z.string().optional(),
})
.refine(
(data) =>
!data.start_date || !data.end_date || data.end_date >= data.start_date,
{ message: t('form_date_order_invalid'), path: ['end_date'] },
),
[isEditing, t],
)
type FormData = z.infer<typeof schema>
const {
register,
handleSubmit,
control,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
code: value?.code ?? '',
name: value?.name ?? '',
is_active: value?.is_active ?? true,
start_date: value?.start_date ?? '',
end_date: value?.end_date ?? '',
},
})
function submit(data: FormData) {
return onSubmit({
code: data.code.trim(),
name: data.name.trim(),
is_active: data.is_active,
start_date: isProject && data.start_date ? data.start_date : null,
end_date: isProject && data.end_date ? data.end_date : null,
})
}
return (
<form onSubmit={handleSubmit(submit)} className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="dimension-value-code">{t('form_code_label')}</Label>
<Input
id="dimension-value-code"
{...register('code')}
disabled={isEditing}
className="font-mono"
autoComplete="off"
autoFocus={!isEditing}
/>
<p className="text-xs text-muted-foreground">
{isEditing ? t('form_code_immutable_help') : t('form_code_help')}
</p>
{errors.code && (
<p className="text-xs text-destructive">{errors.code.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="dimension-value-name">{t('form_name_label')}</Label>
<Input
id="dimension-value-name"
{...register('name')}
autoComplete="off"
autoFocus={isEditing}
/>
{errors.name && (
<p className="text-xs text-destructive">{errors.name.message}</p>
)}
</div>
</div>
{isProject && (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="dimension-value-start">{t('form_start_label')}</Label>
<Input
id="dimension-value-start"
type="date"
{...register('start_date')}
className="tabular-nums"
/>
</div>
<div className="space-y-2">
<Label htmlFor="dimension-value-end">{t('form_end_label')}</Label>
<Input
id="dimension-value-end"
type="date"
{...register('end_date')}
className="tabular-nums"
/>
{errors.end_date && (
<p className="text-xs text-destructive">{errors.end_date.message}</p>
)}
</div>
</div>
)}
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="dimension-value-active">{t('form_active_label')}</Label>
<p className="text-xs text-muted-foreground max-w-md">
{t('form_active_help')}
</p>
</div>
<Controller
name="is_active"
control={control}
render={({ field }) => (
<Switch
id="dimension-value-active"
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
/>
</div>
<div className="flex items-center justify-between gap-3 pt-2">
{isEditing && onDelete ? (
<Button
type="button"
variant="outline"
className="text-destructive hover:text-destructive"
disabled={isSaving}
onClick={() => setConfirmDeleteOpen(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
{t('form_delete')}
</Button>
) : (
<span />
)}
<Button type="submit" disabled={isSaving}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isEditing ? t('form_save') : t('form_create')}
</Button>
</div>
{onDelete && (
<DestructiveConfirmDialog
open={confirmDeleteOpen}
onOpenChange={setConfirmDeleteOpen}
title={t('delete_confirm_title')}
description={t('delete_confirm_description', { code: value?.code ?? '' })}
confirmLabel={t('delete_confirm_label')}
cancelLabel={t('delete_cancel_label')}
onConfirm={onDelete}
/>
)}
</form>
)
}