* feat(settings): rename learned counterparty templates A user asked why a learned template under Inställningar > Mallar can be deleted but not renamed. Nothing legal or ledger-shaped blocks a rename; the one real obstacle was that the learn path keys templates by the normalized bank description, so a renamed row would stop receiving re-approvals and a duplicate would appear under the old key. Why it occurred: counterparty_name doubles as display name and as the learn/upsert key, and the only write path for it was the learner. There was no rename because every later approval would have forked the row. What was simplified instead of added: no display-label column, no new table, no migration. The rename moves the old key into counterparty_aliases, which the matcher already checks first, and the learn lookup (findTemplateByKey) now resolves name-then-alias so re-approvals and SIE re-imports land on the renamed row. Why this over the proposed shape: a separate label would have kept the key untouched but added a second name field for users to reason about; renaming the key with an alias trail gives the user exactly what they asked for with one fewer concept. Duplicate names are refused with 409 (active twin) or the invisible soft-deleted twin is removed (inactive). Fixes #2453 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CgYn5GEp4N5Dxjc1S9Ljbq * fix(bookkeeping): resolve the normalized-name match tier through aliases after a rename Skeptic refutation on 819894559: the alias tier compares raw lowercased bank descriptors, so the normalized key a rename pushes into aliases ("spotify") never matched there, and the name tier only knew the new label ("musik"). A renamed template kept learning through findTemplateByKey but was never proposed again for the merchant it was learned from. nameMap now also resolves aliases, with a real counterparty_name always winning over another row's alias. Refs #2453 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CgYn5GEp4N5Dxjc1S9Ljbq * fix(bookkeeping): canonical name beats a borrowed alias; unique-name race returns 409 Review findings on #2454: - The alias tier ran before the name tier, so a bank line that is exactly another template's canonical name could resolve to a row holding that string as a rename alias. Aliases claimed by a different template's counterparty_name are now skipped when building the alias map. - The PATCH twin check and the update are separate statements; a learn or a concurrent rename between them surfaced as 500. Postgres 23505 on the update now maps to the same 409 as the pre-check. Refs #2453 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CgYn5GEp4N5Dxjc1S9Ljbq --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
339 lines
14 KiB
TypeScript
339 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import { useTranslations } from 'next-intl'
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import { Button } from '@/components/ui/button'
|
|
import { EmptyState } from '@/components/ui/empty-state'
|
|
import { Input } from '@/components/ui/input'
|
|
import { useToast } from '@/components/ui/use-toast'
|
|
import { SettingsGroup } from '@/components/settings/SettingsRows'
|
|
import { Loader2, Trash2, Users, ChevronDown, Pencil } from 'lucide-react'
|
|
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
|
|
import { formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates'
|
|
import type { CategorizationTemplate } from '@/types'
|
|
|
|
function formatDate(iso: string | null) {
|
|
if (!iso) return '-'
|
|
return new Date(iso).toLocaleDateString('sv-SE', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
})
|
|
}
|
|
|
|
function confidenceColor(c: number): string {
|
|
if (c >= 0.8) return 'text-success'
|
|
if (c >= 0.5) return 'text-warning'
|
|
return 'text-muted-foreground'
|
|
}
|
|
|
|
export function CounterpartyTemplatesPanel() {
|
|
const t = useTranslations('settings_counterparty_templates')
|
|
const { toast } = useToast()
|
|
|
|
const SOURCE_LABELS: Record<string, string> = {
|
|
sie_import: t('source_sie_import'),
|
|
user_approved: t('source_user_approved'),
|
|
auto_learned: t('source_auto_learned'),
|
|
sni_default: t('source_sni_default'),
|
|
}
|
|
|
|
const VAT_LABELS: Record<string, string> = {
|
|
standard_25: '25%',
|
|
reduced_12: '12%',
|
|
reduced_6: '6%',
|
|
reverse_charge: t('vat_reverse_charge'),
|
|
export: t('vat_export'),
|
|
exempt: t('vat_exempt'),
|
|
}
|
|
|
|
const [templates, setTemplates] = useState<CategorizationTemplate[]>([])
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [deletingId, setDeletingId] = useState<string | null>(null)
|
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
|
const [editingId, setEditingId] = useState<string | null>(null)
|
|
const [editName, setEditName] = useState('')
|
|
const [savingId, setSavingId] = useState<string | null>(null)
|
|
|
|
const fetchTemplates = useCallback(async () => {
|
|
try {
|
|
const res = await fetch('/api/settings/counterparty-templates')
|
|
const json = await res.json()
|
|
if (json.data) {
|
|
setTemplates(json.data)
|
|
}
|
|
} catch {
|
|
toast({ title: t('toast_fetch_failed'), variant: 'destructive' })
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}, [toast, t])
|
|
|
|
useEffect(() => {
|
|
fetchTemplates()
|
|
}, [fetchTemplates])
|
|
|
|
async function handleDelete(id: string) {
|
|
setDeletingId(id)
|
|
try {
|
|
const res = await fetch('/api/settings/counterparty-templates', {
|
|
method: 'DELETE',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id }),
|
|
})
|
|
if (!res.ok) {
|
|
toast({ title: t('toast_delete_failed'), variant: 'destructive' })
|
|
return
|
|
}
|
|
setTemplates((prev) => prev.filter((tt) => tt.id !== id))
|
|
if (expandedId === id) setExpandedId(null)
|
|
toast({ title: t('toast_deleted') })
|
|
} catch {
|
|
toast({ title: t('toast_delete_failed'), variant: 'destructive' })
|
|
} finally {
|
|
setDeletingId(null)
|
|
}
|
|
}
|
|
|
|
function startRename(tt: CategorizationTemplate) {
|
|
setEditingId(tt.id)
|
|
setEditName(formatCounterpartyName(tt.counterparty_name))
|
|
}
|
|
|
|
function cancelRename() {
|
|
setEditingId(null)
|
|
setEditName('')
|
|
}
|
|
|
|
async function handleRename(id: string) {
|
|
const name = editName.trim()
|
|
if (!name) return
|
|
setSavingId(id)
|
|
try {
|
|
const res = await fetch('/api/settings/counterparty-templates', {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id, counterparty_name: name }),
|
|
})
|
|
if (res.status === 409) {
|
|
toast({ title: t('toast_duplicate_name'), variant: 'destructive' })
|
|
return
|
|
}
|
|
if (!res.ok) {
|
|
toast({ title: t('toast_rename_failed'), variant: 'destructive' })
|
|
return
|
|
}
|
|
const json = (await res.json()) as { data?: CategorizationTemplate }
|
|
if (json.data) {
|
|
const updated = json.data
|
|
setTemplates((prev) => prev.map((tt) => (tt.id === id ? { ...tt, ...updated } : tt)))
|
|
}
|
|
cancelRename()
|
|
toast({ title: t('toast_renamed') })
|
|
} catch {
|
|
toast({ title: t('toast_rename_failed'), variant: 'destructive' })
|
|
} finally {
|
|
setSavingId(null)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<SettingsGroup label={t('title')} help={t('description')}>
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : templates.length === 0 ? (
|
|
<EmptyState
|
|
icon={Users}
|
|
title={t('empty_title')}
|
|
description={t('empty_help')}
|
|
/>
|
|
) : (
|
|
templates.map((tt) => {
|
|
const isExpanded = expandedId === tt.id
|
|
const isMultiLine = tt.line_pattern && tt.line_pattern.length > 0
|
|
|
|
return (
|
|
<div key={tt.id} className="border-b border-border">
|
|
{/* Clickable summary row: flat hairline, one line. */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setExpandedId(isExpanded ? null : tt.id)}
|
|
aria-expanded={isExpanded}
|
|
className="flex w-full items-center gap-3 px-1 py-3 text-left transition-colors duration-150 hover:bg-secondary/60"
|
|
>
|
|
<div className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-3 gap-y-1">
|
|
<span className="truncate text-sm">{formatCounterpartyName(tt.counterparty_name)}</span>
|
|
<span className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
|
{isMultiLine ? (
|
|
<span className="font-mono">
|
|
{tt.line_pattern!.filter(lp => lp.type === 'business').map(lp => lp.account).join(', ')}
|
|
</span>
|
|
) : (
|
|
<span className="font-mono">
|
|
{tt.debit_account}
|
|
<span className="text-muted-foreground/50"> → </span>
|
|
{tt.credit_account}
|
|
</span>
|
|
)}
|
|
{tt.vat_treatment && (
|
|
<span>· {VAT_LABELS[tt.vat_treatment] || tt.vat_treatment}</span>
|
|
)}
|
|
<span>· {t('times_count', { count: tt.occurrence_count })}</span>
|
|
<span className={`tabular-nums ${confidenceColor(Number(tt.confidence))}`}>
|
|
{Math.round(Number(tt.confidence) * 100)}%
|
|
</span>
|
|
<span>· {SOURCE_LABELS[tt.source] || tt.source}</span>
|
|
</span>
|
|
</div>
|
|
<ChevronDown
|
|
className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
|
/>
|
|
</button>
|
|
|
|
{/* Expanded detail */}
|
|
{isExpanded && (
|
|
<div className="space-y-3 px-1 pb-3">
|
|
{/* Account lines */}
|
|
<div>
|
|
<p className="mb-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">{t('booking_label')}</p>
|
|
{isMultiLine ? (
|
|
<div className="space-y-1">
|
|
{tt.line_pattern!.map((lp, i) => (
|
|
<div key={i} className="flex items-center gap-2 text-xs">
|
|
<span className="w-14 shrink-0 text-[10px] uppercase tracking-wider text-muted-foreground">
|
|
{lp.side === 'debit' ? t('debit_label') : t('credit_label')}
|
|
</span>
|
|
<span className="font-mono">{formatAccountWithName(lp.account)}</span>
|
|
{lp.type === 'vat' && lp.vat_rate && (
|
|
<span className="text-muted-foreground">{t('vat_paren', { rate: Math.round(lp.vat_rate * 100) })}</span>
|
|
)}
|
|
{lp.ratio !== undefined && (
|
|
<span className="text-muted-foreground">({Math.round(lp.ratio * 100)}%)</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<span className="w-14 shrink-0 text-[10px] uppercase tracking-wider text-muted-foreground">{t('debit_label')}</span>
|
|
<span className="font-mono">{formatAccountWithName(tt.debit_account)}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<span className="w-14 shrink-0 text-[10px] uppercase tracking-wider text-muted-foreground">{t('credit_label')}</span>
|
|
<span className="font-mono">{formatAccountWithName(tt.credit_account)}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Metadata */}
|
|
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-xs">
|
|
{tt.vat_treatment && (
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">{t('vat_label')}</span>
|
|
<span>{VAT_LABELS[tt.vat_treatment] || tt.vat_treatment}</span>
|
|
</div>
|
|
)}
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">{t('occurrence_count_label')}</span>
|
|
<span className="tabular-nums">{tt.occurrence_count}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">{t('confidence_label')}</span>
|
|
<span className={`tabular-nums ${confidenceColor(Number(tt.confidence))}`}>
|
|
{Math.round(Number(tt.confidence) * 100)}%
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">{t('last_seen_label')}</span>
|
|
<span>{formatDate(tt.last_seen_date)}</span>
|
|
</div>
|
|
{tt.counterparty_aliases && tt.counterparty_aliases.length > 1 && (
|
|
<div className="col-span-2 flex justify-between">
|
|
<span className="text-muted-foreground">{t('aliases_label')}</span>
|
|
<span className="ml-4 truncate text-right">{tt.counterparty_aliases.slice(0, 3).join(', ')}{tt.counterparty_aliases.length > 3 ? ` +${tt.counterparty_aliases.length - 3}` : ''}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Rename + delete */}
|
|
{editingId === tt.id ? (
|
|
<form
|
|
className="flex items-center gap-2 pt-1"
|
|
onSubmit={(e) => {
|
|
e.preventDefault()
|
|
handleRename(tt.id)
|
|
}}
|
|
>
|
|
<Input
|
|
value={editName}
|
|
onChange={(e) => setEditName(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Escape') cancelRename()
|
|
}}
|
|
maxLength={100}
|
|
autoFocus
|
|
aria-label={t('rename_label')}
|
|
className="h-8 max-w-xs text-sm"
|
|
/>
|
|
<Button
|
|
type="submit"
|
|
size="sm"
|
|
disabled={savingId === tt.id || editName.trim().length < 2}
|
|
className="h-7 text-xs"
|
|
>
|
|
{savingId === tt.id ? <Loader2 className="mr-1.5 h-3 w-3 animate-spin" /> : null}
|
|
{t('rename_save')}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={cancelRename}
|
|
disabled={savingId === tt.id}
|
|
className="h-7 text-xs"
|
|
>
|
|
{t('rename_cancel')}
|
|
</Button>
|
|
</form>
|
|
) : (
|
|
<div className="flex justify-end gap-2 pt-1">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => startRename(tt)}
|
|
disabled={deletingId === tt.id}
|
|
className="h-7 text-xs"
|
|
>
|
|
<Pencil className="mr-1.5 h-3 w-3" />
|
|
{t('rename_button')}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleDelete(tt.id)}
|
|
disabled={deletingId === tt.id}
|
|
className="h-7 text-xs text-destructive hover:text-destructive"
|
|
>
|
|
{deletingId === tt.id ? (
|
|
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
|
|
) : (
|
|
<Trash2 className="mr-1.5 h-3 w-3" />
|
|
)}
|
|
{t('delete_button')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})
|
|
)}
|
|
</SettingsGroup>
|
|
)
|
|
}
|