57d4359d1a
* feat(booking-templates): per-company opt-in hiding of system templates Users cannot delete or hide the 26 standard konteringspaket, which clutter the settings panel and every template picker. Deletion stays off the table (shared global rows); instead a company can now hide individual system templates for itself only. - New booking_template_hidden table (insert=hide, delete=unhide), RLS gated on active company + write role; nothing hidden by default - POST/DELETE /api/settings/booking-templates/[id]/hide (system templates only; company/team templates keep their real delete path) - List route decorates rows with per-company is_hidden; pickers filter them out; the settings panel shows hidden ones in a collapsed restore section so hiding is never silent - Classified in full-archive-export exclusions (UI preference, not rakenskapsinformation) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PU1KN431c9gp5zKvFaa1NL * fix(booking-templates): idempotent re-hide, system-only RLS insert, hidden filter in bulk-book Skeptic + CodeRabbit findings on #2004, one pass: - hide upsert now passes ignoreDuplicates (DO NOTHING): the table has no UPDATE policy on purpose, so the DO UPDATE conflict arm turned a concurrent re-hide into an RLS 42501/500; pg test pins the conflict shape - bth_insert policy additionally requires the referenced template to be an active system template (migration is unmerged, edited in place); negative pg test for company templates - BulkBookDialog excludes templates hidden by the company (was reading the table directly and ignoring hides) - panel shows the failure toast when the hide/unhide fetch itself rejects - picker category chips built from the hidden-filtered list Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PU1KN431c9gp5zKvFaa1NL --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
299 lines
12 KiB
TypeScript
299 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
import { useTranslations } from 'next-intl'
|
|
import { cn, formatCurrency } from '@/lib/utils'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { useToast } from '@/components/ui/use-toast'
|
|
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
|
import {
|
|
applyTemplate,
|
|
getTemplateScope,
|
|
SCOPE_LABELS,
|
|
TEMPLATE_CATEGORY_LABELS,
|
|
} from '@/lib/bookkeeping/template-library'
|
|
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
|
|
import { roundOre } from '@/lib/money'
|
|
import { ArrowLeft, Check, ChevronRight, Loader2, Search } from 'lucide-react'
|
|
import type { BookingTemplateLibrary } from '@/types'
|
|
import { useBookingTemplates, useFiscalPeriods } from '@/lib/reference-data/hooks'
|
|
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
|
|
|
|
interface Props {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
/** Fired after a verifikat is booked from a template. */
|
|
onCreated: () => void
|
|
}
|
|
|
|
/** Sum a side of the computed lines in öre-safe steps. */
|
|
function sumSide(lines: FormLine[], side: 'debit_amount' | 'credit_amount'): number {
|
|
return lines.reduce((acc, l) => roundOre(acc + (parseFloat(l[side]) || 0)), 0)
|
|
}
|
|
|
|
/**
|
|
* "Bokför från mall" (UI-migration plan PR 4, scene 9): a centered modal
|
|
* with the template list (existing booking_template_library data, MRU
|
|
* ordering from the API), then date + editable amount that recomputes the
|
|
* kontering live via applyTemplate, a "Balanserar" row, and direct booking
|
|
* (user action, so no Granskning detour).
|
|
*/
|
|
export default function TemplateBookDialog({ open, onOpenChange, onCreated }: Props) {
|
|
const t = useTranslations('bookkeeping')
|
|
const { toast } = useToast()
|
|
|
|
// Session-cached (lib/reference-data): opening the dialog costs no
|
|
// requests once the lists are in the cache. null = still loading.
|
|
const { templates: cachedTemplates, isLoading: templatesLoading } = useBookingTemplates()
|
|
// Templates hidden for this company (settings panel opt-in) never show in
|
|
// the booking flow; the settings panel is the only surface that lists them.
|
|
const templates: BookingTemplateLibrary[] | null = templatesLoading
|
|
? null
|
|
: cachedTemplates.filter((tt) => !tt.is_hidden)
|
|
const { periods } = useFiscalPeriods()
|
|
const [search, setSearch] = useState('')
|
|
const [selected, setSelected] = useState<BookingTemplateLibrary | null>(null)
|
|
const [entryDate, setEntryDate] = useState(() => new Date().toISOString().split('T')[0])
|
|
const [amountInput, setAmountInput] = useState('')
|
|
const [submitting, setSubmitting] = useState(false)
|
|
|
|
// Reset per open so yesterday's half-typed amount never leaks into today.
|
|
useEffect(() => {
|
|
if (open) return
|
|
setSelected(null)
|
|
setSearch('')
|
|
setAmountInput('')
|
|
setEntryDate(new Date().toISOString().split('T')[0])
|
|
}, [open])
|
|
|
|
const amount = useMemo(() => {
|
|
const parsed = parseFloat(amountInput.replace(/\s/g, '').replace(',', '.'))
|
|
return Number.isFinite(parsed) && parsed > 0 ? roundOre(parsed) : 0
|
|
}, [amountInput])
|
|
|
|
// The live kontering: recomputed from the template's line pattern on
|
|
// every amount change (momssplit etc. handled by applyTemplate).
|
|
const lines = useMemo<FormLine[]>(
|
|
() => (selected && amount > 0 ? applyTemplate(selected.lines, amount) : []),
|
|
[selected, amount],
|
|
)
|
|
const totalDebit = sumSide(lines, 'debit_amount')
|
|
const totalCredit = sumSide(lines, 'credit_amount')
|
|
const balanced = lines.length >= 2 && totalDebit === totalCredit && totalDebit > 0
|
|
|
|
const filteredTemplates = (templates ?? []).filter((tpl) =>
|
|
tpl.name.toLowerCase().includes(search.trim().toLowerCase()),
|
|
)
|
|
|
|
const periodForDate = periods.find(
|
|
(p) => p.period_start <= entryDate && entryDate <= p.period_end,
|
|
)
|
|
|
|
const handleBook = async () => {
|
|
if (!selected || !balanced) return
|
|
if (!periodForDate) {
|
|
toast({ title: t('tpl_no_period'), variant: 'destructive' })
|
|
return
|
|
}
|
|
setSubmitting(true)
|
|
try {
|
|
const res = await fetch('/api/bookkeeping/journal-entries', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
fiscal_period_id: periodForDate.id,
|
|
entry_date: entryDate,
|
|
description: selected.name,
|
|
lines: lines.map((l) => ({
|
|
account_number: l.account_number,
|
|
debit_amount: parseFloat(l.debit_amount) || 0,
|
|
credit_amount: parseFloat(l.credit_amount) || 0,
|
|
line_description: l.line_description || undefined,
|
|
})),
|
|
}),
|
|
})
|
|
const result = await res.json()
|
|
if (!res.ok) {
|
|
toast({
|
|
title: t('toast_post_failed'),
|
|
description: getErrorMessage(result, { context: 'journal_entry' }),
|
|
variant: 'destructive',
|
|
})
|
|
return
|
|
}
|
|
// MRU ordering for the next open; fire-and-forget.
|
|
void fetch(`/api/settings/booking-templates/${selected.id}/touch`, {
|
|
method: 'POST',
|
|
}).catch(() => {})
|
|
toast({
|
|
title: t('toast_posted_title'),
|
|
description: t('toast_posted_description', {
|
|
voucher: formatVoucher(result.data ?? {}),
|
|
}),
|
|
})
|
|
onOpenChange(false)
|
|
onCreated()
|
|
} catch {
|
|
toast({ title: t('toast_post_failed_generic'), variant: 'destructive' })
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={(next) => !submitting && onOpenChange(next)}>
|
|
<DialogContent className="sm:min-w-[460px] sm:max-w-lg">
|
|
<DialogHeader>
|
|
{/* data-ph-mask: the template name is user data */}
|
|
<DialogTitle data-ph-mask="" className="font-display text-lg tracking-tight">
|
|
{selected ? selected.name : t('tpl_dialog_title')}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
{!selected ? (
|
|
<>
|
|
<div className="flex items-center gap-2 border-b border-border/60 pb-2">
|
|
<Search className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
|
<input
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder={t('tpl_search_placeholder')}
|
|
className="w-full bg-transparent text-[13px] text-foreground placeholder:text-muted-foreground/60 focus:outline-none"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<div className="-mx-2 max-h-80 overflow-y-auto px-1">
|
|
{templates === null ? (
|
|
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
</div>
|
|
) : filteredTemplates.length === 0 ? (
|
|
<p className="px-2.5 py-6 text-center text-[13px] text-muted-foreground">
|
|
{t('tpl_empty')}
|
|
</p>
|
|
) : (
|
|
filteredTemplates.map((tpl) => (
|
|
<button
|
|
key={tpl.id}
|
|
type="button"
|
|
onClick={() => setSelected(tpl)}
|
|
className="flex w-full items-center gap-2.5 rounded-sm px-2.5 py-2.5 text-left transition-colors hover:bg-secondary/60"
|
|
>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-[13px] text-foreground">
|
|
{tpl.name}
|
|
</span>
|
|
<span className="block truncate text-[11px] text-muted-foreground">
|
|
{TEMPLATE_CATEGORY_LABELS[tpl.category] ?? tpl.category}
|
|
{' · '}
|
|
{SCOPE_LABELS[getTemplateScope(tpl)]}
|
|
</span>
|
|
</span>
|
|
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/60" />
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="space-y-4">
|
|
<button
|
|
type="button"
|
|
onClick={() => setSelected(null)}
|
|
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
|
>
|
|
<ArrowLeft className="h-3.5 w-3.5" />
|
|
{t('tpl_back')}
|
|
</button>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="tpl-date">{t('tpl_date_label')}</Label>
|
|
<Input
|
|
id="tpl-date"
|
|
type="date"
|
|
value={entryDate}
|
|
onChange={(e) => setEntryDate(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="tpl-amount">{t('tpl_amount_label')}</Label>
|
|
<Input
|
|
id="tpl-amount"
|
|
inputMode="decimal"
|
|
placeholder="0,00"
|
|
value={amountInput}
|
|
onChange={(e) => setAmountInput(e.target.value)}
|
|
autoFocus
|
|
className="tabular-nums"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Live kontering preview */}
|
|
<div className="rounded-lg border border-border">
|
|
{lines.length === 0 ? (
|
|
<p className="px-3 py-4 text-center text-[13px] text-muted-foreground">
|
|
{t('tpl_enter_amount')}
|
|
</p>
|
|
) : (
|
|
<>
|
|
{lines.map((l, i) => (
|
|
<div
|
|
key={i}
|
|
className="flex items-center gap-3 border-b border-border/60 px-3 py-2 text-[13px] last:border-b-0"
|
|
>
|
|
<span className="w-12 font-mono text-muted-foreground">
|
|
{l.account_number}
|
|
</span>
|
|
<span className="min-w-0 flex-1 truncate">{l.line_description}</span>
|
|
<span className="w-24 text-right tabular-nums">
|
|
{l.debit_amount ? formatCurrency(parseFloat(l.debit_amount)) : ''}
|
|
</span>
|
|
<span className="w-24 text-right tabular-nums text-muted-foreground">
|
|
{l.credit_amount ? formatCurrency(parseFloat(l.credit_amount)) : ''}
|
|
</span>
|
|
</div>
|
|
))}
|
|
<div
|
|
className={cn(
|
|
'flex items-center justify-between px-3 py-2 text-[12.5px]',
|
|
balanced ? 'text-success' : 'text-destructive',
|
|
)}
|
|
>
|
|
<span className="inline-flex items-center gap-1.5">
|
|
{balanced && <Check className="h-3.5 w-3.5" />}
|
|
{balanced ? t('tpl_balances') : t('tpl_not_balancing')}
|
|
</span>
|
|
<span className="tabular-nums">
|
|
{formatCurrency(totalDebit)} / {formatCurrency(totalCredit)}
|
|
</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={submitting}>
|
|
{t('tpl_cancel')}
|
|
</Button>
|
|
<Button onClick={() => void handleBook()} disabled={!balanced || submitting}>
|
|
{submitting && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
|
{t('tpl_book')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|