diff --git a/components/settings/BookingTemplatesPanel.tsx b/components/settings/BookingTemplatesPanel.tsx index a63619b4..e78ae6e5 100644 --- a/components/settings/BookingTemplatesPanel.tsx +++ b/components/settings/BookingTemplatesPanel.tsx @@ -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(null) const [expandedId, setExpandedId] = useState(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(null) const importRef = useRef(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 ( + <>
@@ -157,9 +167,11 @@ export function BookingTemplatesPanel() { {t('create_dialog_title')} - { + 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() { )} + + {/* 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. */} + { if (!open) setActiveTemplate(null) }}> + + + + {activeTemplate?.is_system ? t('customize_dialog_title') : t('edit_dialog_title')} + + + {activeTemplate && ( + { + setActiveTemplate(null) + fetchTemplates() + }} + /> + )} + + + ) } @@ -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 }) { const t = useTranslations('settings_booking_templates') @@ -296,6 +351,30 @@ function TemplateSection({
+ {canCustomize && onCustomize && ( + + )} + {canEdit && onEdit && ( + + )} {canDelete && ( ) diff --git a/messages/en.json b/messages/en.json index f66033fd..eb0ac172 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index 6f30457d..705d4270 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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",