b2d3a3e273
* feat(settings): rename or re-date an existing räkenskapsår from the fiscal-year list (#2287) Inställningar > Bokföring > Räkenskapsår offered Lås, Nollställ and Skapa nytt räkenskapsår but no way to change the name or dates of a year that already exists, although PATCH /api/bookkeeping/fiscal-periods/[id] has supported both (name always on an open year, dates only while the year has no posted vouchers). The only surface over that route was the first-year date editor under Företag, so a later or backfilled year saved with the wrong name or dates (Aisen & Adison AB, #2286) could only be repaired in the database. Every open year row now gets a quiet Ändra action opening one dialog (Namn, Startdatum, Slutdatum) that posts only the changed fields to the existing route. Dates are read-only with the route's own reason when the year has posted vouchers (count from the entry-count endpoint); the name is always editable. Locked and closed years get no Ändra, matching the route's refusal and the row's chip. Route refusals are shown inline so the user can correct and retry. The name follows the dates while it still has the shape fiscalYearName() produces (new isDerivedFiscalYearName, the inverse predicate next to the one naming helper): the customer's "Räkenskapsår 2027" corrects itself to "Räkenskapsår 2022/2023" as the dates are fixed, and a hand-written name is never overwritten. Saving invalidates ref:fiscal-periods so every picker updates; the reset dialog's typed confirmation reads the name live from the reset snapshot, so a rename does not break it. No route change. New strings in both messages/sv.json and messages/en.json. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 * fix(settings): fiscal-year edit dialog is not dirty on open when the stored name has stray whitespace "Changed" now compares the raw input with the stored name and sends the trimmed value only once the user has edited it; before, a stored name with leading or trailing whitespace enabled Spara on open with a trimmed name in the payload (CodeRabbit on #2337). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
261 lines
9.7 KiB
TypeScript
261 lines
9.7 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import { useTranslations } from 'next-intl'
|
|
import { Loader2 } from 'lucide-react'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { useToast } from '@/components/ui/use-toast'
|
|
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
|
import { fiscalYearName, isDerivedFiscalYearName } from '@/lib/bookkeeping/suggest-fiscal-period'
|
|
import type { FiscalPeriod } from '@/types'
|
|
|
|
interface FiscalYearEditDialogProps {
|
|
period: FiscalPeriod
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
/** Called after a successful save so the parent can refetch. */
|
|
onSaved: () => void
|
|
}
|
|
|
|
/** Read a user-facing message from either a legacy `{ error: string }` body
|
|
* (what the fiscal-periods PATCH route returns) or the canonical
|
|
* `{ error: { message } }` envelope. */
|
|
function readApiError(body: unknown, fallback: string): string {
|
|
if (!body || typeof body !== 'object') return fallback
|
|
const error = (body as { error?: unknown }).error
|
|
if (typeof error === 'string') return error
|
|
if (error && typeof error === 'object') {
|
|
const message = (error as { message?: unknown }).message
|
|
if (typeof message === 'string') return message
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
/**
|
|
* Edit the name and dates of one OPEN fiscal year (issue #2287). A thin
|
|
* surface over PATCH /api/bookkeeping/fiscal-periods/[id], which already
|
|
* allows a rename at any time on an open year and a re-date only while the
|
|
* year has no posted vouchers. The dialog mirrors those two rules with the
|
|
* route's own reasons: the posted count comes from the entry-count endpoint
|
|
* on open (dates read-only above zero), and every refusal from the route is
|
|
* shown verbatim so the user can correct and retry without leaving the
|
|
* dialog. Only changed fields are sent, so a pure rename never trips the
|
|
* voucher check. Every guard is re-enforced server-side.
|
|
*/
|
|
export function FiscalYearEditDialog({
|
|
period,
|
|
open,
|
|
onOpenChange,
|
|
onSaved,
|
|
}: FiscalYearEditDialogProps) {
|
|
const t = useTranslations('settings_bookkeeping')
|
|
const { toast } = useToast()
|
|
|
|
const [name, setName] = useState(period.name)
|
|
// The name follows the dates while it is still the app's own derived name
|
|
// ("Räkenskapsår 2027"), the same rule as CreatePeriodDialog: a seed name
|
|
// that never matched its dates is corrected together with them, and the
|
|
// user watches it change. A hand-written name is never overwritten.
|
|
const [nameFollowsDates, setNameFollowsDates] = useState(() =>
|
|
isDerivedFiscalYearName(period.name),
|
|
)
|
|
const [periodStart, setPeriodStart] = useState(period.period_start)
|
|
const [periodEnd, setPeriodEnd] = useState(period.period_end)
|
|
const [postedCount, setPostedCount] = useState<number | null>(null)
|
|
const [countFailed, setCountFailed] = useState(false)
|
|
const [isChecking, setIsChecking] = useState(false)
|
|
const [isSaving, setIsSaving] = useState(false)
|
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
|
|
let cancelled = false
|
|
async function loadPostedCount() {
|
|
setIsChecking(true)
|
|
setCountFailed(false)
|
|
try {
|
|
const response = await fetch(
|
|
`/api/bookkeeping/fiscal-periods/${period.id}/entry-count`,
|
|
{ cache: 'no-store' },
|
|
)
|
|
if (!response.ok) throw new Error('entry-count failed')
|
|
const body = (await response.json()) as { data?: { posted_count?: number } }
|
|
if (!cancelled) setPostedCount(body.data?.posted_count ?? 0)
|
|
} catch {
|
|
if (!cancelled) setCountFailed(true)
|
|
} finally {
|
|
if (!cancelled) setIsChecking(false)
|
|
}
|
|
}
|
|
|
|
void loadPostedCount()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [open, period.id])
|
|
|
|
// Dates open up only once the check has come back with zero posted
|
|
// vouchers; while checking, or if the check failed, they stay read-only
|
|
// and the sentence below the fields says why. The name is always editable.
|
|
const datesEditable = !isChecking && !countFailed && postedCount === 0
|
|
|
|
// A date input yields '' while incomplete and a full YYYY-MM-DD otherwise:
|
|
// only derive a name once both ends are known.
|
|
function updateDates(start: string, end: string) {
|
|
setPeriodStart(start)
|
|
setPeriodEnd(end)
|
|
if (nameFollowsDates && start && end) setName(fiscalYearName(start, end))
|
|
}
|
|
|
|
const trimmedName = name.trim()
|
|
const payload: { name?: string; period_start?: string; period_end?: string } = {}
|
|
// "Changed" compares the raw input with the stored name, so a stored name
|
|
// with stray whitespace does not read as dirty on open; the trimmed value
|
|
// is what gets sent once the user has actually edited it.
|
|
const nameChanged = name !== period.name
|
|
if (nameChanged && trimmedName) payload.name = trimmedName
|
|
if (datesEditable) {
|
|
if (periodStart && periodStart !== period.period_start) payload.period_start = periodStart
|
|
if (periodEnd && periodEnd !== period.period_end) payload.period_end = periodEnd
|
|
}
|
|
const endBeforeStart = datesEditable && !!periodStart && !!periodEnd && periodEnd <= periodStart
|
|
const datesIncomplete = datesEditable && (!periodStart || !periodEnd)
|
|
const isDirty = Object.keys(payload).length > 0
|
|
const canSave =
|
|
isDirty && trimmedName.length > 0 && !endBeforeStart && !datesIncomplete && !isSaving
|
|
|
|
function handleOpenChange(nextOpen: boolean) {
|
|
if (isSaving) return
|
|
onOpenChange(nextOpen)
|
|
}
|
|
|
|
async function handleSave() {
|
|
if (!canSave) return
|
|
setIsSaving(true)
|
|
setSubmitError(null)
|
|
try {
|
|
const response = await fetch(`/api/bookkeeping/fiscal-periods/${period.id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
})
|
|
const body = await response.json().catch(() => ({}))
|
|
if (!response.ok) {
|
|
// The route's refusals (BFL 3 kap. shapes, overlap, posted vouchers)
|
|
// are Swedish domain copy and stay verbatim in both locales.
|
|
setSubmitError(readApiError(body, t('fy_edit_failed_default')))
|
|
return
|
|
}
|
|
toast({ title: t('fy_edit_success') })
|
|
onOpenChange(false)
|
|
onSaved()
|
|
} catch (error) {
|
|
setSubmitError(
|
|
error instanceof Error ? getUserErrorMessage(error) : t('fy_edit_failed_default'),
|
|
)
|
|
} finally {
|
|
setIsSaving(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{t('fy_edit_dialog_title')}</DialogTitle>
|
|
<DialogDescription>{t('fy_edit_dialog_description')}</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fy-edit-name">{t('fy_edit_name_label')}</Label>
|
|
<Input
|
|
id="fy-edit-name"
|
|
value={name}
|
|
onChange={(event) => {
|
|
setName(event.target.value)
|
|
setNameFollowsDates(false)
|
|
}}
|
|
autoComplete="off"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fy-edit-start">{t('fy_edit_start_label')}</Label>
|
|
<Input
|
|
id="fy-edit-start"
|
|
type="date"
|
|
value={periodStart}
|
|
disabled={!datesEditable}
|
|
aria-describedby="fy-edit-dates-note"
|
|
onChange={(event) => updateDates(event.target.value, periodEnd)}
|
|
className="tabular-nums"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="fy-edit-end">{t('fy_edit_end_label')}</Label>
|
|
<Input
|
|
id="fy-edit-end"
|
|
type="date"
|
|
value={periodEnd}
|
|
disabled={!datesEditable}
|
|
aria-describedby="fy-edit-dates-note"
|
|
onChange={(event) => updateDates(periodStart, event.target.value)}
|
|
className="tabular-nums"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* One quiet sentence on why the dates are read-only, in the
|
|
route's own terms (posted vouchers). */}
|
|
<p id="fy-edit-dates-note" className="text-xs text-muted-foreground">
|
|
{isChecking ? (
|
|
<span className="inline-flex items-center gap-2">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
{t('fy_edit_dates_checking')}
|
|
</span>
|
|
) : countFailed ? (
|
|
t('fy_edit_dates_check_failed')
|
|
) : postedCount !== null && postedCount > 0 ? (
|
|
t('fy_edit_dates_blocked_posted', { count: postedCount })
|
|
) : null}
|
|
</p>
|
|
|
|
{endBeforeStart ? (
|
|
<p className="text-xs text-destructive">{t('fy_edit_end_before_start')}</p>
|
|
) : null}
|
|
{submitError ? <p className="text-xs text-destructive">{submitError}</p> : null}
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isSaving}>
|
|
{t('fy_confirm_cancel')}
|
|
</Button>
|
|
<Button onClick={handleSave} disabled={!canSave}>
|
|
{isSaving ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
{t('fy_edit_saving')}
|
|
</>
|
|
) : (
|
|
t('fy_edit_save')
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|