feat(bookkeeping): edit & customize booking templates in GUI (#704)
* feat(bookkeeping): edit & customize booking templates in GUI Make bokföringsmallar editable from /settings/templates and surface the ratio (Andel) field, addressing two user requests. - Refactor CreateTemplateForm into a shared TemplateForm (create / edit / duplicate) reusing the existing POST and PUT routes. - Add an Edit (pencil) action on company/team templates, and an Anpassa (customize) action on read-only "Standard" templates that forks a company-scoped copy — letting a company override the standard 1930 settlement account with e.g. 1920 without mutating the shared template. - Show the ratio field progressively (only when a template splits across more than one cost line), with an InfoTooltip, a non-blocking "shares must sum to 1.0" warning, and a live "1 000 kr" split preview. - Add settings_booking_templates i18n keys in both sv and en. Frontend-only: no API, schema, type, or migration changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bookkeeping): show ratio input on cost lines only Addresses PR review: the Andel input also appeared on settlement lines when a template had multiple cost lines, but settlement ratios don't feed the "shares sum to 1.0" check or balance validation. Restrict the editable ratio to cost/revenue lines; the settlement leg (full counter-amount) is shown in the live preview instead. No behavior change to applyTemplate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c0b006fcc1
commit
21cfcbe180
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -17,9 +17,11 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Loader2, Trash2, Plus, ChevronDown, Download, Upload, Building2, Users, Globe } from 'lucide-react'
|
||||
import { TEMPLATE_CATEGORY_LABELS, convertLibraryToBookingTemplate } from '@/lib/bookkeeping/template-library'
|
||||
import { Loader2, Trash2, Plus, ChevronDown, Download, Upload, Building2, Users, Globe, Pencil, Copy } from 'lucide-react'
|
||||
import { TEMPLATE_CATEGORY_LABELS, convertLibraryToBookingTemplate, applyTemplate } from '@/lib/bookkeeping/template-library'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import type { BookingTemplateLibrary, BookingTemplateCategory, BookingTemplateLibraryLine } from '@/types'
|
||||
|
||||
export function BookingTemplatesPanel() {
|
||||
@@ -38,6 +40,9 @@ export function BookingTemplatesPanel() {
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
// Shared dialog for editing a company/team template or customizing (duplicating)
|
||||
// a read-only system template. Mode is derived from is_system.
|
||||
const [activeTemplate, setActiveTemplate] = useState<BookingTemplateLibrary | null>(null)
|
||||
const importRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const fetchTemplates = useCallback(async () => {
|
||||
@@ -119,7 +124,12 @@ export function BookingTemplatesPanel() {
|
||||
const teamTemplates = templates.filter((tt) => tt.team_id && !tt.is_system)
|
||||
const companyTemplates = templates.filter((tt) => tt.company_id && !tt.is_system)
|
||||
|
||||
// Names of existing company templates — used for a soft "name already exists"
|
||||
// hint when creating or customizing (never blocks save).
|
||||
const companyTemplateNames = companyTemplates.map((tt) => tt.name)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
@@ -157,9 +167,11 @@ export function BookingTemplatesPanel() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('create_dialog_title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<CreateTemplateForm
|
||||
<TemplateForm
|
||||
mode="create"
|
||||
entityLabels={ENTITY_LABELS}
|
||||
onCreated={() => {
|
||||
duplicateNamePool={companyTemplateNames}
|
||||
onSaved={() => {
|
||||
setShowCreate(false)
|
||||
fetchTemplates()
|
||||
}}
|
||||
@@ -192,6 +204,9 @@ export function BookingTemplatesPanel() {
|
||||
deletingId={deletingId}
|
||||
onDelete={handleDelete}
|
||||
canDelete={false}
|
||||
canEdit={false}
|
||||
canCustomize={canWrite}
|
||||
onCustomize={setActiveTemplate}
|
||||
entityLabels={ENTITY_LABELS}
|
||||
/>
|
||||
)}
|
||||
@@ -207,6 +222,8 @@ export function BookingTemplatesPanel() {
|
||||
deletingId={deletingId}
|
||||
onDelete={handleDelete}
|
||||
canDelete={canWrite}
|
||||
canEdit={canWrite}
|
||||
onEdit={setActiveTemplate}
|
||||
entityLabels={ENTITY_LABELS}
|
||||
/>
|
||||
)}
|
||||
@@ -222,6 +239,8 @@ export function BookingTemplatesPanel() {
|
||||
deletingId={deletingId}
|
||||
onDelete={handleDelete}
|
||||
canDelete={canWrite}
|
||||
canEdit={canWrite}
|
||||
onEdit={setActiveTemplate}
|
||||
entityLabels={ENTITY_LABELS}
|
||||
/>
|
||||
)}
|
||||
@@ -229,6 +248,34 @@ export function BookingTemplatesPanel() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Shared edit / customize dialog. Editing a company or team template uses
|
||||
PUT; customizing a read-only system template creates a company-scoped
|
||||
copy via POST. The form is keyed by template id so it re-seeds state when
|
||||
switching between rows. */}
|
||||
<Dialog open={!!activeTemplate} onOpenChange={(open) => { if (!open) setActiveTemplate(null) }}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{activeTemplate?.is_system ? t('customize_dialog_title') : t('edit_dialog_title')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{activeTemplate && (
|
||||
<TemplateForm
|
||||
key={activeTemplate.id}
|
||||
mode={activeTemplate.is_system ? 'duplicate' : 'edit'}
|
||||
initialTemplate={activeTemplate}
|
||||
entityLabels={ENTITY_LABELS}
|
||||
duplicateNamePool={companyTemplateNames}
|
||||
onSaved={() => {
|
||||
setActiveTemplate(null)
|
||||
fetchTemplates()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -241,6 +288,10 @@ function TemplateSection({
|
||||
deletingId,
|
||||
onDelete,
|
||||
canDelete,
|
||||
canEdit = false,
|
||||
canCustomize = false,
|
||||
onEdit,
|
||||
onCustomize,
|
||||
entityLabels,
|
||||
}: {
|
||||
title: string
|
||||
@@ -251,6 +302,10 @@ function TemplateSection({
|
||||
deletingId: string | null
|
||||
onDelete: (id: string) => void
|
||||
canDelete: boolean
|
||||
canEdit?: boolean
|
||||
canCustomize?: boolean
|
||||
onEdit?: (template: BookingTemplateLibrary) => void
|
||||
onCustomize?: (template: BookingTemplateLibrary) => void
|
||||
entityLabels: Record<string, string>
|
||||
}) {
|
||||
const t = useTranslations('settings_booking_templates')
|
||||
@@ -296,6 +351,30 @@ function TemplateSection({
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{canCustomize && onCustomize && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onCustomize(tt)}
|
||||
aria-label={t('customize')}
|
||||
title={t('customize')}
|
||||
className="h-8 w-8 p-0 shrink-0"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{canEdit && onEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEdit(tt)}
|
||||
aria-label={t('edit')}
|
||||
title={t('edit')}
|
||||
className="h-8 w-8 p-0 shrink-0"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -353,18 +432,46 @@ function TemplateSection({
|
||||
)
|
||||
}
|
||||
|
||||
function CreateTemplateForm({ onCreated, entityLabels }: { onCreated: () => void; entityLabels: Record<string, string> }) {
|
||||
type TemplateFormMode = 'create' | 'edit' | 'duplicate'
|
||||
|
||||
function TemplateForm({
|
||||
mode,
|
||||
initialTemplate,
|
||||
entityLabels,
|
||||
duplicateNamePool = [],
|
||||
onSaved,
|
||||
}: {
|
||||
mode: TemplateFormMode
|
||||
initialTemplate?: BookingTemplateLibrary
|
||||
entityLabels: Record<string, string>
|
||||
duplicateNamePool?: string[]
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const t = useTranslations('settings_booking_templates')
|
||||
const { toast } = useToast()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [category, setCategory] = useState<BookingTemplateCategory>('other')
|
||||
const [entityType, setEntityType] = useState<'all' | 'enskild_firma' | 'aktiebolag'>('all')
|
||||
const [lines, setLines] = useState<BookingTemplateLibraryLine[]>([
|
||||
{ account: '', label: '', side: 'debit', type: 'business', ratio: 1 },
|
||||
{ account: '', label: '', side: 'credit', type: 'settlement', ratio: 1 },
|
||||
])
|
||||
// When customizing a system template (mode 'duplicate') we suggest a distinct
|
||||
// "(anpassad)" name so the company copy doesn't read as the standard one.
|
||||
const [name, setName] = useState(() =>
|
||||
initialTemplate
|
||||
? mode === 'duplicate'
|
||||
? t('copy_name_suffix', { name: initialTemplate.name })
|
||||
: initialTemplate.name
|
||||
: '',
|
||||
)
|
||||
const [description, setDescription] = useState(initialTemplate?.description ?? '')
|
||||
const [category, setCategory] = useState<BookingTemplateCategory>(initialTemplate?.category ?? 'other')
|
||||
const [entityType, setEntityType] = useState<'all' | 'enskild_firma' | 'aktiebolag'>(
|
||||
initialTemplate?.entity_type ?? 'all',
|
||||
)
|
||||
const [lines, setLines] = useState<BookingTemplateLibraryLine[]>(() =>
|
||||
initialTemplate
|
||||
? initialTemplate.lines.map((l) => ({ ...l }))
|
||||
: [
|
||||
{ account: '', label: '', side: 'debit', type: 'business', ratio: 1 },
|
||||
{ account: '', label: '', side: 'credit', type: 'settlement', ratio: 1 },
|
||||
],
|
||||
)
|
||||
|
||||
function updateLine(index: number, field: keyof BookingTemplateLibraryLine, value: string | number) {
|
||||
setLines((prev) => {
|
||||
@@ -403,12 +510,45 @@ function CreateTemplateForm({ onCreated, entityLabels }: { onCreated: () => void
|
||||
setLines((prev) => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
// Ratio is only load-bearing when a template splits the amount across more
|
||||
// than one cost/revenue line. Hide it for the simple case to keep the form
|
||||
// approachable for non-accountants; it stays 1.0 under the hood.
|
||||
const businessLineCount = lines.filter((l) => l.type === 'business').length
|
||||
const showRatio = businessLineCount > 1
|
||||
// The ratio only validates against cost/revenue lines (businessRatioSum), so
|
||||
// only those get an editable input. The settlement leg is the full counter-
|
||||
// amount (ratio 1.0) and is shown in the live preview, not as a control —
|
||||
// an editable settlement ratio that doesn't feed the sum check would mislead.
|
||||
const firstRatioIndex = showRatio ? lines.findIndex((l) => l.type === 'business') : -1
|
||||
const businessRatioSum = lines
|
||||
.filter((l) => l.type === 'business')
|
||||
.reduce((sum, l) => sum + (l.ratio ?? 1), 0)
|
||||
const ratioSumOff = showRatio && Math.abs(businessRatioSum - 1) > 0.001
|
||||
|
||||
// Live split preview for a 1 000 kr amount. Computed only once every line has
|
||||
// an account so the table doesn't flicker while the form is half-filled.
|
||||
const preview = useMemo(() => {
|
||||
if (lines.some((l) => !l.account)) return null
|
||||
try {
|
||||
return applyTemplate(lines, 1000)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [lines])
|
||||
|
||||
// Soft, non-blocking hint when the chosen name collides with an existing
|
||||
// company template (no DB unique constraint — duplicates are allowed).
|
||||
const nameCollision =
|
||||
mode !== 'edit' &&
|
||||
name.trim().length > 0 &&
|
||||
duplicateNamePool.some((n) => n.trim().toLowerCase() === name.trim().toLowerCase())
|
||||
|
||||
// Real-time check: can this draft be picked from the transaction sheet?
|
||||
// If not, we show a hint — save remains allowed (templates may still be
|
||||
// useful from the journal-entry form).
|
||||
const isConvertible = (() => {
|
||||
const draft: BookingTemplateLibrary = {
|
||||
id: '',
|
||||
id: initialTemplate?.id ?? '',
|
||||
company_id: null,
|
||||
team_id: null,
|
||||
created_by: null,
|
||||
@@ -434,18 +574,24 @@ function CreateTemplateForm({ onCreated, entityLabels }: { onCreated: () => void
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const res = await fetch('/api/settings/booking-templates', {
|
||||
method: 'POST',
|
||||
// Edit updates the existing template in place (PUT); create and duplicate
|
||||
// both write a new company-scoped template (POST).
|
||||
const isEdit = mode === 'edit'
|
||||
const url = isEdit
|
||||
? `/api/settings/booking-templates/${initialTemplate!.id}`
|
||||
: '/api/settings/booking-templates'
|
||||
const res = await fetch(url, {
|
||||
method: isEdit ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, description, category, entity_type: entityType, lines }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const json = await res.json()
|
||||
const json = await res.json().catch(() => ({}))
|
||||
toast({ title: json.error || t('toast_create_failed'), variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
toast({ title: t('toast_created') })
|
||||
onCreated()
|
||||
toast({ title: isEdit ? t('toast_updated') : t('toast_created') })
|
||||
onSaved()
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
@@ -455,7 +601,13 @@ function CreateTemplateForm({ onCreated, entityLabels }: { onCreated: () => void
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label>{t('name_label')}</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder={t('name_placeholder')} />
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('name_placeholder')}
|
||||
autoFocus={mode === 'duplicate'}
|
||||
onFocus={mode === 'duplicate' ? (e) => e.target.select() : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('description_label')} <span className="text-muted-foreground font-normal">{t('optional_suffix')}</span></Label>
|
||||
@@ -489,7 +641,9 @@ function CreateTemplateForm({ onCreated, entityLabels }: { onCreated: () => void
|
||||
<div>
|
||||
<Label>{t('lines_label')}</Label>
|
||||
<div className="space-y-2 mt-1">
|
||||
{lines.map((line, i) => (
|
||||
{lines.map((line, i) => {
|
||||
const showRatioInput = showRatio && line.type === 'business'
|
||||
return (
|
||||
<div key={i} className="rounded-md border border-border p-2 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
@@ -546,9 +700,28 @@ function CreateTemplateForm({ onCreated, entityLabels }: { onCreated: () => void
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{showRatioInput && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.1"
|
||||
min={0}
|
||||
max={10}
|
||||
value={String(line.ratio ?? 1)}
|
||||
onChange={(e) => {
|
||||
const n = Number(e.target.value)
|
||||
if (!Number.isNaN(n)) updateLine(i, 'ratio', n)
|
||||
}}
|
||||
aria-label={t('ratio_label')}
|
||||
className="w-16 font-mono tabular-nums text-right"
|
||||
/>
|
||||
{i === firstRatioIndex && <InfoTooltip content={t('ratio_help')} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)})}
|
||||
<Button type="button" variant="outline" size="sm" onClick={addLine}>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
{t('add_line')}
|
||||
@@ -556,6 +729,52 @@ function CreateTemplateForm({ onCreated, entityLabels }: { onCreated: () => void
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ratioSumOff && (
|
||||
<div className="rounded-lg border border-warning/30 bg-warning/[0.03] px-3 py-2">
|
||||
<p className="text-xs text-warning-foreground leading-snug">
|
||||
{t('ratio_sum_warning')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<div>
|
||||
<Label>{t('preview_label')}</Label>
|
||||
<table className="w-full text-xs mt-1">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-1 w-14">{t('th_account')}</th>
|
||||
<th className="text-left py-1">{t('th_description')}</th>
|
||||
<th className="text-right py-1 w-20">{t('th_debit')}</th>
|
||||
<th className="text-right py-1 w-20">{t('th_credit')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.map((pl, i) => (
|
||||
<tr key={i} className="border-b last:border-0">
|
||||
<td className="py-1 font-mono">{pl.account_number}</td>
|
||||
<td className="py-1">{pl.line_description}</td>
|
||||
<td className="py-1 text-right tabular-nums">
|
||||
{pl.debit_amount ? formatCurrency(Number(pl.debit_amount)) : ''}
|
||||
</td>
|
||||
<td className="py-1 text-right tabular-nums">
|
||||
{pl.credit_amount ? formatCurrency(Number(pl.credit_amount)) : ''}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nameCollision && (
|
||||
<div className="rounded-lg border border-warning/30 bg-warning/[0.03] px-3 py-2">
|
||||
<p className="text-xs text-warning-foreground leading-snug">
|
||||
{t('duplicate_name_warning')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isConvertible && (
|
||||
<div className="rounded-lg border border-warning/30 bg-warning/[0.03] px-3 py-2">
|
||||
<p className="text-xs text-warning-foreground leading-snug">
|
||||
@@ -566,7 +785,7 @@ function CreateTemplateForm({ onCreated, entityLabels }: { onCreated: () => void
|
||||
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full">
|
||||
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
{t('create_button')}
|
||||
{mode === 'create' ? t('create_button') : t('save_button')}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
|
||||
+13
-1
@@ -1305,7 +1305,19 @@
|
||||
"vat_rate_6": "6%",
|
||||
"vat_rate_0": "0% (export/reverse-charge/exempt)",
|
||||
"unconvertible_hint": "This template can't be picked directly when booking a transaction — it's used from a journal entry. To appear in the transaction list, it needs exactly one cost/revenue line and one settlement line on opposite sides.",
|
||||
"unconvertible_badge": "Journal entries only"
|
||||
"unconvertible_badge": "Journal entries only",
|
||||
"ratio_label": "Share",
|
||||
"ratio_help": "Share of the total amount booked on this line. The shares should sum to 1.0.",
|
||||
"ratio_sum_warning": "The shares for cost lines do not sum to 1.0.",
|
||||
"preview_label": "Preview for 1,000 kr",
|
||||
"edit": "Edit",
|
||||
"edit_dialog_title": "Edit template",
|
||||
"customize": "Customize",
|
||||
"customize_dialog_title": "Customize standard template",
|
||||
"copy_name_suffix": "{name} (customized)",
|
||||
"save_button": "Save",
|
||||
"toast_updated": "Template saved",
|
||||
"duplicate_name_warning": "A template with this name already exists."
|
||||
},
|
||||
"settings_counterparty_templates": {
|
||||
"title": "Bookkeeping templates",
|
||||
|
||||
+13
-1
@@ -1305,7 +1305,19 @@
|
||||
"vat_rate_6": "6 %",
|
||||
"vat_rate_0": "0 % (export/omvänd/momsfri)",
|
||||
"unconvertible_hint": "Den här mallen kan inte väljas direkt vid bokföring av en transaktion — den används från ett verifikat. För att visas i transaktionslistan krävs exakt en kostnads-/intäktsrad och en betalningsrad på motsatt sida.",
|
||||
"unconvertible_badge": "Endast i verifikat"
|
||||
"unconvertible_badge": "Endast i verifikat",
|
||||
"ratio_label": "Andel",
|
||||
"ratio_help": "Andel av totalbeloppet som bokförs på raden. Summan av andelarna ska bli 1,0.",
|
||||
"ratio_sum_warning": "Andelarna för kostnadsrader summerar inte till 1,0.",
|
||||
"preview_label": "Förhandsvisning för 1 000 kr",
|
||||
"edit": "Redigera",
|
||||
"edit_dialog_title": "Redigera mall",
|
||||
"customize": "Anpassa",
|
||||
"customize_dialog_title": "Anpassa standardmall",
|
||||
"copy_name_suffix": "{name} (anpassad)",
|
||||
"save_button": "Spara",
|
||||
"toast_updated": "Mallen sparades",
|
||||
"duplicate_name_warning": "Det finns redan en mall med det här namnet."
|
||||
},
|
||||
"settings_counterparty_templates": {
|
||||
"title": "Bokföringsmallar",
|
||||
|
||||
Reference in New Issue
Block a user