diff --git a/components/settings/FiscalYearEditDialog.tsx b/components/settings/FiscalYearEditDialog.tsx new file mode 100644 index 00000000..3f262048 --- /dev/null +++ b/components/settings/FiscalYearEditDialog.tsx @@ -0,0 +1,260 @@ +'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(null) + const [countFailed, setCountFailed] = useState(false) + const [isChecking, setIsChecking] = useState(false) + const [isSaving, setIsSaving] = useState(false) + const [submitError, setSubmitError] = useState(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 ( + + + + {t('fy_edit_dialog_title')} + {t('fy_edit_dialog_description')} + + +
+
+ + { + setName(event.target.value) + setNameFollowsDates(false) + }} + autoComplete="off" + /> +
+ +
+
+ + updateDates(event.target.value, periodEnd)} + className="tabular-nums" + /> +
+
+ + updateDates(periodStart, event.target.value)} + className="tabular-nums" + /> +
+
+ + {/* One quiet sentence on why the dates are read-only, in the + route's own terms (posted vouchers). */} +

+ {isChecking ? ( + + + {t('fy_edit_dates_checking')} + + ) : countFailed ? ( + t('fy_edit_dates_check_failed') + ) : postedCount !== null && postedCount > 0 ? ( + t('fy_edit_dates_blocked_posted', { count: postedCount }) + ) : null} +

+ + {endBeforeStart ? ( +

{t('fy_edit_end_before_start')}

+ ) : null} + {submitError ?

{submitError}

: null} +
+ + + + + +
+
+ ) +} diff --git a/components/settings/FiscalYearsManager.tsx b/components/settings/FiscalYearsManager.tsx index b3db2ced..5d0a1a27 100644 --- a/components/settings/FiscalYearsManager.tsx +++ b/components/settings/FiscalYearsManager.tsx @@ -14,11 +14,12 @@ import { useToast } from '@/components/ui/use-toast' import { useCompany } from '@/contexts/CompanyContext' import { useFiscalPeriods } from '@/lib/reference-data/hooks' import { invalidateReferenceData } from '@/lib/reference-data/invalidate' -import { Plus, Lock, Unlock, Loader2, Eraser } from 'lucide-react' +import { Plus, Lock, Unlock, Loader2, Eraser, Pencil } from 'lucide-react' import { formatDate } from '@/lib/utils' import type { FiscalPeriod } from '@/types' import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog' import { FiscalYearResetDialog } from '@/components/settings/FiscalYearResetDialog' +import { FiscalYearEditDialog } from '@/components/settings/FiscalYearEditDialog' import { suggestSeedDate } from '@/lib/bookkeeping/suggest-fiscal-period' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' @@ -49,6 +50,7 @@ export function FiscalYearsManager() { const [dialogOpen, setDialogOpen] = useState(false) const [mutatingId, setMutatingId] = useState(null) const [resetTarget, setResetTarget] = useState(null) + const [editTarget, setEditTarget] = useState(null) // Only owners/admins may change a period's lock state. The API enforces this // too (requireWrite); this just hides controls a viewer/member can't use. @@ -165,6 +167,21 @@ export function FiscalYearsManager() { {closedExternally ? t('fy_status_closed_external') : t(`fy_status_${status}`)} )} + {/* Ändra is offered on open years only, like Lås and + Nollställ: the PATCH route refuses locked and closed years + outright, and the row already carries that chip. */} + {canManage && status === 'open' && ( + + )} {canManage && status === 'open' && (