Files
accounted/components/dimensions/DimensionValueForm.tsx
T
Jakob Wennberg 8bb49c07a2 feat(dimensions): PR2 registry — CRUD API, register UI, settings toggle, SIE export on the new registry (#858)
* feat(dimensions): PR2 registry — CRUD API, register UI, settings toggle, SIE export on the new registry

Phase 2 of dev_docs/dimensions_implementation_plan.md. Companies with
dimensions_enabled=false (default) see zero change.

API:
- Dashboard CRUD: GET /api/dimensions (lazy-seeds system dims 1/6 via the
  ensure_company_dimensions RPC), PATCH /api/dimensions/[id] (is_system
  rename blocked), POST/PATCH/DELETE values (code immutable after creation;
  strict Fortnox code format ^[A-Za-z0-9ÅÄÖåäö_+\-]{1,20}$ at the API layer;
  retention-trigger deletes surface the Swedish "arkivera istället" message
  as 409 DIMENSION_VALUE_REFERENCED).
- POST /api/dimensions/import-existing — scans journal_entry_lines.dimensions
  for unregistered codes and mints inactive placeholder registry rows.
- v1 public API: GET dimensions + POST values (Idempotency-Key, dry-run),
  registered in the OpenAPI spec (102→104 endpoints).
- dimensions_enabled boolean on company_settings (new migration,
  UI-visibility only, never correctness-bearing) exposed through the
  existing settings read/update path.

SIE export (lib/reports/sie-export.ts):
- Reads the new dimensions/dimension_values registry; legacy
  cost_centers/projects tables now have zero readers (drop migration next).
- Fixes the latent Visma-rejection bug: #OBJEKT now declared for INACTIVE
  values referenced by lines.
- Generic-N: #DIM/#UNDERDIM loop sorted by sie_dim_no; #TRANS object lists
  serialize from the line JSONB map (sorted, '01'→'1' collapse); orphan
  codes/dims synthesize declarations from the SIE reserved-number seed —
  every referenced (dim, code) pair is guaranteed declared.

UI:
- /dimensions register (Register-recipe): tabs per dimension, search,
  sortable table, value dialog (code immutable on edit, projekt dates on
  dim 6), archive-not-delete affordances.
- DimensionCombobox shipped (mounts in the tagging PR).
- Settings toggle "Aktivera kostnadsställen & projekt" — toggle-on runs the
  import-existing scan and links to the register.
- Nav row in redovisning, rendered only when dimensions_enabled (same
  mechanism as pays_salaries).
- dimensions.* i18n namespace (51 keys, sv/en parity).
- Sandbox seed: demo dims + values, revenue line tagged {"1":"BUTIK","6":"P001"}.

Verified: 6328/6328 unit tests, guard + coverage gate green, tsc parity with
main (210=210), production build passes.

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

* fix(dimensions): PR2 review round — atomic archived-create, UNDERDIM ordering, import robustness, date semantics

- POST values accepts is_active so "create as archived" is atomic; the UI's
  fragile create-then-PATCH fallback is deleted (PR Agent finding 1).
- DimensionCombobox blur revert reads the committed value/values through refs
  so a selection landing inside the 150ms window always wins (finding 2).
- import-existing sanitizes candidate codes like the PR1 backfill and upserts
  with ignoreDuplicates — one bad/duplicate code can no longer abort the
  batch; created counted from returned rows (finding 3).
- SIE export emits all root #DIM before any #UNDERDIM so a parent always
  precedes a lower-numbered child (SIE4 declaration order — Swedish review);
  synthesized placeholder declarations now log one structured warning
  (BFNAR 2013:2 behandlingshistorik) + defence-in-depth comment.
- Value dates rejected (400 DIMENSION_VALUE_DATES_NOT_ALLOWED) when the
  parent dimension is flow-period (resets_annually=true); explicit null
  still clears (Swedish review).
- Sandbox seed logs seeded dimension codes; GET /api/dimensions documents
  the deliberate absence of dimensions_enabled gating (UI-visibility flag,
  not a security boundary — compliance-swarm V8.2.1 rejected by design).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:26:42 +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>
)
}