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>
889 lines
37 KiB
TypeScript
889 lines
37 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useMemo, useState } from 'react'
|
||
import { useCashAccounts, useCompanySettings } from '@/lib/reference-data/hooks'
|
||
import { useTranslations } from 'next-intl'
|
||
import { createClient } from '@/lib/supabase/client'
|
||
import { useCompany } from '@/contexts/CompanyContext'
|
||
import { resolveAccount } from '@/lib/cash-accounts/resolve-account'
|
||
import type { CashAccount } from '@/types'
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from '@/components/ui/dialog'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Label } from '@/components/ui/label'
|
||
import { Skeleton } from '@/components/ui/skeleton'
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||
import { useToast } from '@/components/ui/use-toast'
|
||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||
import { applyTemplate } from '@/lib/bookkeeping/template-library'
|
||
import { formatCurrency, formatDate, cn } from '@/lib/utils'
|
||
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
|
||
import DuplicateBookingDialog from '@/components/transactions/DuplicateBookingDialog'
|
||
import { Loader2, FileText, AlertTriangle, Check, Plus, Trash2, Paperclip } from 'lucide-react'
|
||
import type { BookingTemplateLibrary, BookingTemplateLibraryLine } from '@/types'
|
||
import type { BookedDuplicateCandidate } from '@/lib/transactions/booking-duplicate-detection'
|
||
import type { TransactionWithInvoice } from './transaction-types'
|
||
|
||
interface BulkBookDialogProps {
|
||
open: boolean
|
||
onOpenChange: (open: boolean) => void
|
||
transactions: TransactionWithInvoice[]
|
||
onSuccess: () => void
|
||
}
|
||
|
||
type Mode = 'one_line_per_tx' | 'sum_per_account'
|
||
type Tab = 'template' | 'manual'
|
||
|
||
interface PreviewLine {
|
||
account_number: string
|
||
debit_amount: number
|
||
credit_amount: number
|
||
line_description: string | undefined
|
||
}
|
||
|
||
interface ManualLine {
|
||
id: string
|
||
account_number: string
|
||
debit_amount: string // form-state strings; parsed on send
|
||
credit_amount: string
|
||
line_description: string
|
||
}
|
||
|
||
function round2(n: number): number {
|
||
return Math.round(n * 100) / 100
|
||
}
|
||
|
||
function parseAmount(s: string): number {
|
||
if (!s) return 0
|
||
const cleaned = s.replace(/\s/g, '').replace(',', '.')
|
||
const n = Number.parseFloat(cleaned)
|
||
return Number.isFinite(n) ? n : 0
|
||
}
|
||
|
||
function newManualLineId(): string {
|
||
return `ml-${Math.random().toString(36).slice(2, 10)}`
|
||
}
|
||
|
||
export default function BulkBookDialog({
|
||
open,
|
||
onOpenChange,
|
||
transactions,
|
||
onSuccess,
|
||
}: BulkBookDialogProps) {
|
||
const { toast } = useToast()
|
||
const { company } = useCompany()
|
||
const supabase = useMemo(() => createClient(), [])
|
||
const t = useTranslations('tx_bulk_book')
|
||
|
||
const [tab, setTab] = useState<Tab>('template')
|
||
const [templates, setTemplates] = useState<BookingTemplateLibrary[]>([])
|
||
const [loadingTemplates, setLoadingTemplates] = useState(true)
|
||
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null)
|
||
// null = fetch pending; array = loaded (may be empty on error: falls back to '1930')
|
||
// Session-cached (lib/reference-data); null only while the list is still
|
||
// loading, which the pre-fill below reads as "not resolved yet".
|
||
const { cashAccounts: cachedCashAccounts, isLoading: cashAccountsLoading } = useCashAccounts()
|
||
const cashAccounts: CashAccount[] | null = cashAccountsLoading ? null : cachedCashAccounts
|
||
const { settings: companySettings } = useCompanySettings()
|
||
const [mode, setMode] = useState<Mode>('one_line_per_tx')
|
||
const [description, setDescription] = useState('')
|
||
const [manualLines, setManualLines] = useState<ManualLine[]>([])
|
||
const [submitting, setSubmitting] = useState(false)
|
||
// Booking-time duplicate guard fired for one of the selected txs
|
||
// (TRANSACTION_BOOK_POSSIBLE_DUPLICATE): surface the candidate for review
|
||
// with "Bokför ändå" (re-runs the whole batch with force=true) instead of
|
||
// dead-ending in a toast. Match/ignore are not offered here: resolving one
|
||
// row differently belongs on the transaction list, outside the batch.
|
||
const [duplicateCandidate, setDuplicateCandidate] = useState<BookedDuplicateCandidate | null>(null)
|
||
// Dimension tagging (kostnadsställe/projekt): the pair renders only when
|
||
// company_settings.dimensions_enabled, same gate as JournalEntryForm. One
|
||
// header-level default bag applies to both tabs; the server tags the
|
||
// generated voucher's lines with it.
|
||
const dimensionsEnabled = companySettings?.dimensions_enabled === true
|
||
const [defaultDims, setDefaultDims] = useState<Record<string, string>>({})
|
||
|
||
// Documents that will inherit onto the new verifikat. Computed from
|
||
// transactions.document_id; the RPC reads these and updates each doc's
|
||
// journal_entry_id atomically with the verifikat commit.
|
||
const docCount = useMemo(
|
||
() => transactions.filter((tx) => tx.document_id).length,
|
||
[transactions],
|
||
)
|
||
|
||
const txCount = transactions.length
|
||
const sharedDate = transactions[0]?.date
|
||
const sharedCurrency = transactions[0]?.currency ?? 'SEK'
|
||
const direction: 'income' | 'expense' = useMemo(() => {
|
||
if (transactions.length === 0) return 'income'
|
||
return transactions[0]!.amount > 0 ? 'income' : 'expense'
|
||
}, [transactions])
|
||
const txSumAbs = useMemo(
|
||
() => round2(transactions.reduce((s, tx) => s + Math.abs(tx.amount), 0)),
|
||
[transactions],
|
||
)
|
||
|
||
// Currency homogeneity. A samlingsverifikation must be expressed in one
|
||
// redovisningsvaluta (BFL 4 kap 6 §), so `txSumAbs` above is only a
|
||
// meaningful number while every selected tx shares a currency: across
|
||
// currencies it would add 100 EUR to 100 SEK as if they were one unit.
|
||
// We therefore split the selection per currency and, when it spans more
|
||
// than one, never render the combined scalar and never let confirm fire.
|
||
// NULL currency is legacy for the column default 'SEK'.
|
||
const currencyTotals = useMemo(() => {
|
||
const byCurrency = new Map<string, number>()
|
||
for (const tx of transactions) {
|
||
const code = tx.currency ?? 'SEK'
|
||
byCurrency.set(code, round2((byCurrency.get(code) ?? 0) + Math.abs(tx.amount)))
|
||
}
|
||
return Array.from(byCurrency.entries()).sort(([a], [b]) => a.localeCompare(b))
|
||
}, [transactions])
|
||
const isMixedCurrency = currencyTotals.length > 1
|
||
|
||
const selectedTemplate = useMemo(
|
||
() => templates.find((tpl) => tpl.id === selectedTemplateId) ?? null,
|
||
[templates, selectedTemplateId],
|
||
)
|
||
|
||
// Load templates when the dialog opens. RLS scopes to user's companies +
|
||
// system templates; no company_id filter needed. Templates the company hid
|
||
// (booking_template_hidden, per-company opt-in) are excluded like in the
|
||
// other pickers; if that lookup fails we show everything (safe direction).
|
||
useEffect(() => {
|
||
if (!open || !company) return
|
||
const companyId = company.id
|
||
let cancelled = false
|
||
async function load() {
|
||
setLoadingTemplates(true)
|
||
try {
|
||
const [templatesRes, hiddenRes] = await Promise.all([
|
||
supabase
|
||
.from('booking_template_library')
|
||
.select('*')
|
||
.eq('is_active', true)
|
||
.order('is_system', { ascending: false })
|
||
.order('name', { ascending: true }),
|
||
supabase
|
||
.from('booking_template_hidden')
|
||
.select('template_id')
|
||
.eq('company_id', companyId),
|
||
])
|
||
if (cancelled) return
|
||
const hiddenIds = new Set(
|
||
(hiddenRes.error ? [] : hiddenRes.data ?? []).map((r) => r.template_id as string),
|
||
)
|
||
const rows = (templatesRes.data ?? []) as BookingTemplateLibrary[]
|
||
setTemplates(rows.filter((tpl) => !hiddenIds.has(tpl.id)))
|
||
} finally {
|
||
if (!cancelled) setLoadingTemplates(false)
|
||
}
|
||
}
|
||
load()
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [open, company, supabase])
|
||
|
||
|
||
// Reset state when dialog closes so the next open starts clean.
|
||
useEffect(() => {
|
||
if (!open) {
|
||
setTab('template')
|
||
setSelectedTemplateId(null)
|
||
setMode('one_line_per_tx')
|
||
setDescription('')
|
||
setManualLines([])
|
||
setDefaultDims({})
|
||
} else if (sharedDate) {
|
||
// Pre-fill description with a sensible default the user can edit.
|
||
setDescription(t('default_description', { date: sharedDate }))
|
||
}
|
||
}, [open, sharedDate, t])
|
||
|
||
// Pre-fill the bank side from the txs (one line per tx with the resolved
|
||
// ledger account and correct Dr/Cr direction). We intentionally do NOT
|
||
// pre-fill a counterpart account: swedish-compliance flagged that a
|
||
// hardcoded 3001/5800 prefill nudges users into submitting verifikat
|
||
// without a VAT line (26xx) for momsregistrerade affärshändelser. The
|
||
// bank side is the unambiguous part the user always wants; the
|
||
// counterpart (and any VAT split) is the user's responsibility.
|
||
// Gate on cashAccounts !== null so lines are only built after the account
|
||
// fetch resolves: this prevents the form from briefly showing '1930' when
|
||
// the resolved account differs.
|
||
useEffect(() => {
|
||
if (tab !== 'manual') return
|
||
if (manualLines.length > 0) return
|
||
if (transactions.length === 0) return
|
||
if (cashAccounts === null) return
|
||
const isIncome = direction === 'income'
|
||
const bankLines: ManualLine[] = transactions.map((tx) => {
|
||
const { account } = resolveAccount(
|
||
cashAccounts,
|
||
tx.cash_account_id ?? null,
|
||
tx.currency ?? 'SEK',
|
||
)
|
||
return {
|
||
id: newManualLineId(),
|
||
account_number: account,
|
||
debit_amount: isIncome ? Math.abs(tx.amount).toFixed(2).replace('.', ',') : '',
|
||
credit_amount: isIncome ? '' : Math.abs(tx.amount).toFixed(2).replace('.', ','),
|
||
line_description: (tx.description || '').slice(0, 40).trim(),
|
||
}
|
||
})
|
||
// One empty counterpart row to scaffold the next entry. Account
|
||
// left blank: user must choose, which avoids the no-VAT trap.
|
||
const counterpart: ManualLine = {
|
||
id: newManualLineId(),
|
||
account_number: '',
|
||
debit_amount: '',
|
||
credit_amount: '',
|
||
line_description: '',
|
||
}
|
||
setManualLines([...bankLines, counterpart])
|
||
}, [tab, manualLines.length, transactions, direction, cashAccounts])
|
||
|
||
// Live line preview: driven by either the template/mode pair (template
|
||
// tab) or the user-edited manual lines (manual tab). Same downstream
|
||
// invariants (balance + bank-leg match) apply to both paths.
|
||
const previewLines = useMemo<PreviewLine[]>(() => {
|
||
if (tab === 'manual') {
|
||
return manualLines
|
||
.map<PreviewLine>((ml) => ({
|
||
account_number: ml.account_number,
|
||
debit_amount: round2(parseAmount(ml.debit_amount)),
|
||
credit_amount: round2(parseAmount(ml.credit_amount)),
|
||
line_description: ml.line_description.trim() || undefined,
|
||
}))
|
||
.filter((l) => l.debit_amount > 0 || l.credit_amount > 0)
|
||
}
|
||
if (!selectedTemplate) return []
|
||
const templateLines = (selectedTemplate.lines ?? []) as BookingTemplateLibraryLine[]
|
||
const lines: PreviewLine[] = []
|
||
if (mode === 'sum_per_account') {
|
||
const applied = applyTemplate(templateLines, txSumAbs)
|
||
for (const fl of applied) {
|
||
const debit = parseFloat(fl.debit_amount || '0') || 0
|
||
const credit = parseFloat(fl.credit_amount || '0') || 0
|
||
if (debit === 0 && credit === 0) continue
|
||
lines.push({
|
||
account_number: fl.account_number,
|
||
debit_amount: round2(debit),
|
||
credit_amount: round2(credit),
|
||
line_description: fl.line_description,
|
||
})
|
||
}
|
||
} else {
|
||
for (const tx of transactions) {
|
||
const applied = applyTemplate(templateLines, Math.abs(tx.amount))
|
||
for (const fl of applied) {
|
||
const debit = parseFloat(fl.debit_amount || '0') || 0
|
||
const credit = parseFloat(fl.credit_amount || '0') || 0
|
||
if (debit === 0 && credit === 0) continue
|
||
const tag = (tx.description || '').slice(0, 40).trim()
|
||
lines.push({
|
||
account_number: fl.account_number,
|
||
debit_amount: round2(debit),
|
||
credit_amount: round2(credit),
|
||
line_description: tag
|
||
? `${fl.line_description ?? ''}, ${tag}`.trim()
|
||
: fl.line_description,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
return lines
|
||
}, [tab, manualLines, selectedTemplate, mode, transactions, txSumAbs])
|
||
|
||
const previewTotals = useMemo(() => {
|
||
const debit = previewLines.reduce((s, l) => s + l.debit_amount, 0)
|
||
const credit = previewLines.reduce((s, l) => s + l.credit_amount, 0)
|
||
return { debit: round2(debit), credit: round2(credit) }
|
||
}, [previewLines])
|
||
|
||
// Balance + bank-leg match are the two invariants the RPC will check; we
|
||
// surface them here so the user knows whether confirm will succeed.
|
||
const isBalanced = Math.abs(previewTotals.debit - previewTotals.credit) < 0.005
|
||
const bankLineNet = previewLines
|
||
.filter((l) => l.account_number >= '1900' && l.account_number <= '1999')
|
||
.reduce((s, l) => s + l.debit_amount - l.credit_amount, 0)
|
||
const expectedBankNet = direction === 'income' ? txSumAbs : -txSumAbs
|
||
const bankMatches = Math.abs(bankLineNet - expectedBankNet) < 0.005
|
||
|
||
// The active tab gates which selector must be valid. Both paths still
|
||
// need a non-empty description, ≥2 lines, balance, bank-leg match,
|
||
// and (for manual mode) valid 4-digit account numbers: without this,
|
||
// a 1-3-digit entry escapes the lexicographic bank-account range
|
||
// check ('193' < '1900' is true), bank match could pass, and the
|
||
// server's Zod schema rejects with a 400 only after submit.
|
||
const tabReady = tab === 'template' ? selectedTemplate !== null : manualLines.length > 0
|
||
const allAccountsValid = previewLines.every((l) => /^\d{4}$/.test(l.account_number))
|
||
const canConfirm =
|
||
!submitting &&
|
||
!isMixedCurrency &&
|
||
tabReady &&
|
||
description.trim().length > 0 &&
|
||
previewLines.length >= 2 &&
|
||
isBalanced &&
|
||
bankMatches &&
|
||
allAccountsValid
|
||
|
||
function setDefaultDimension(dimNo: string, code: string | null) {
|
||
setDefaultDims((prev) => {
|
||
const next = { ...prev }
|
||
const trimmed = code?.trim()
|
||
if (trimmed) next[dimNo] = trimmed
|
||
else delete next[dimNo]
|
||
return next
|
||
})
|
||
}
|
||
|
||
// Compact display, e.g. "KS01 · P001" (dim-number order) for the preview badge.
|
||
const dimsSummary = Object.entries(defaultDims)
|
||
.filter(([, v]) => v)
|
||
.sort(([a], [b]) => Number(a) - Number(b))
|
||
.map(([, v]) => v)
|
||
.join(' · ')
|
||
|
||
function updateManualLine(id: string, patch: Partial<Omit<ManualLine, 'id'>>) {
|
||
setManualLines((prev) => prev.map((l) => (l.id === id ? { ...l, ...patch } : l)))
|
||
}
|
||
|
||
function removeManualLine(id: string) {
|
||
setManualLines((prev) => prev.filter((l) => l.id !== id))
|
||
}
|
||
|
||
function addManualLine() {
|
||
setManualLines((prev) => [
|
||
...prev,
|
||
{
|
||
id: newManualLineId(),
|
||
account_number: '',
|
||
debit_amount: '',
|
||
credit_amount: '',
|
||
line_description: '',
|
||
},
|
||
])
|
||
}
|
||
|
||
// `opts` is only ever passed by the duplicate-dialog retry; the footer
|
||
// button's onClick hands over a click event, which carries no `force`.
|
||
async function handleConfirm(opts?: { force?: boolean }) {
|
||
if (!canConfirm) return
|
||
const force = opts?.force === true
|
||
setSubmitting(true)
|
||
try {
|
||
// Build the payload per the active tab. Template path uses the
|
||
// existing schema branch (template_id + mode). Manual path sends
|
||
// the user-edited lines directly.
|
||
// Header-level default dimensions ride as a top-level field on both
|
||
// branches; only sent when the user actually picked something.
|
||
const defaultDimensions =
|
||
dimensionsEnabled && Object.keys(defaultDims).length > 0
|
||
? { default_dimensions: defaultDims }
|
||
: {}
|
||
const payload =
|
||
tab === 'manual'
|
||
? {
|
||
tx_ids: transactions.map((tx) => tx.id),
|
||
entry_description: description.trim(),
|
||
manual_lines: previewLines.map((l) => ({
|
||
account_number: l.account_number,
|
||
debit_amount: l.debit_amount,
|
||
credit_amount: l.credit_amount,
|
||
currency: sharedCurrency,
|
||
line_description: l.line_description ?? undefined,
|
||
})),
|
||
...defaultDimensions,
|
||
...(force ? { force: true } : {}),
|
||
}
|
||
: {
|
||
tx_ids: transactions.map((tx) => tx.id),
|
||
template_id: selectedTemplateId,
|
||
mode,
|
||
entry_description: description.trim(),
|
||
...defaultDimensions,
|
||
...(force ? { force: true } : {}),
|
||
}
|
||
const response = await fetch('/api/transactions/bulk-book', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
})
|
||
if (!response.ok) {
|
||
const body = await response.json().catch(() => null)
|
||
const candidate = body?.error?.details?.candidate as BookedDuplicateCandidate | undefined
|
||
if (body?.error?.code === 'TRANSACTION_BOOK_POSSIBLE_DUPLICATE' && candidate) {
|
||
// One of the selected txs already looks booked: open the review
|
||
// dialog instead of a dead-end toast. "Bokför ändå" re-runs the
|
||
// batch with force=true.
|
||
setDuplicateCandidate(candidate)
|
||
return
|
||
}
|
||
toast({
|
||
title: t('error_title'),
|
||
description: getErrorMessage(body, { statusCode: response.status }),
|
||
variant: 'destructive',
|
||
})
|
||
return
|
||
}
|
||
const body = (await response.json()) as {
|
||
data: { voucher_series: string | null; voucher_number: number | null }
|
||
}
|
||
const voucherLabel =
|
||
body.data.voucher_series && body.data.voucher_number != null
|
||
? `${body.data.voucher_series}-${body.data.voucher_number}`
|
||
: t('unknown_voucher')
|
||
toast({
|
||
title: t('success_title'),
|
||
description: t('success_description', { count: txCount, voucher: voucherLabel }),
|
||
variant: 'success',
|
||
})
|
||
onSuccess()
|
||
onOpenChange(false)
|
||
} catch (err) {
|
||
toast({
|
||
title: t('error_title'),
|
||
description: getErrorMessage(err),
|
||
variant: 'destructive',
|
||
})
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
if (transactions.length === 0) return null
|
||
|
||
// Mixed-currency dead end. This is deliberately NOT the advisory-guard
|
||
// pattern with a "book anyway" escape: a verifikat spanning two
|
||
// redovisningsvalutor is not representable at all, so there is no correct
|
||
// outcome to bypass to. We show the honest per-currency subtotals instead
|
||
// of one summed scalar, and the booking UI never renders, so the request
|
||
// is blocked before it is built. The route and the RPC refuse the same
|
||
// selection with BULK_BOOK_MIXED_CURRENCY.
|
||
if (isMixedCurrency) {
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="sm:max-w-[520px]">
|
||
<DialogHeader>
|
||
<DialogTitle>
|
||
{t('title', { count: txCount, date: sharedDate ? formatDate(sharedDate) : '' })}
|
||
</DialogTitle>
|
||
<DialogDescription>{t('mixed_currency_description')}</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-4">
|
||
<div className="rounded-lg border bg-card p-3">
|
||
<p className="text-xs font-medium text-muted-foreground">
|
||
{t('mixed_currency_totals_label')}
|
||
</p>
|
||
<ul className="mt-2 space-y-1">
|
||
{currencyTotals.map(([code, total]) => (
|
||
<li
|
||
key={code}
|
||
className="flex items-center justify-between text-sm tabular-nums"
|
||
>
|
||
<span className="font-mono text-xs text-muted-foreground">{code}</span>
|
||
<span>{formatCurrency(total, code)}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
|
||
<div className="flex items-start gap-2 text-xs text-destructive">
|
||
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0" />
|
||
<span>{t('mixed_currency_blocked')}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||
{t('close')}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="sm:max-w-[680px] max-h-[85vh] overflow-y-auto">
|
||
<DialogHeader>
|
||
<DialogTitle>
|
||
{t('title', { count: txCount, date: sharedDate ? formatDate(sharedDate) : '' })}
|
||
</DialogTitle>
|
||
<DialogDescription>
|
||
{direction === 'income' ? t('description_income') : t('description_expense')}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-4">
|
||
{/* Selection summary */}
|
||
<div className="rounded-lg border bg-card p-3 flex items-center justify-between">
|
||
<div className="text-sm">
|
||
<p className="font-medium">
|
||
{t('summary_count', { count: txCount })}
|
||
</p>
|
||
<p className="text-xs text-muted-foreground tabular-nums">
|
||
{sharedDate ? formatDate(sharedDate) : ''}
|
||
</p>
|
||
</div>
|
||
<p className="font-medium tabular-nums">
|
||
{direction === 'income' ? '+' : '−'}
|
||
{formatCurrency(txSumAbs, sharedCurrency)}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Tab: Mall (template) / Manuell (hand-built lines). Default
|
||
template; manual is the "I want to book it myself" escape
|
||
hatch the user asked for after PR #606. */}
|
||
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)} className="space-y-4">
|
||
<TabsList className="grid grid-cols-2 w-full">
|
||
<TabsTrigger value="template">{t('tab_template')}</TabsTrigger>
|
||
<TabsTrigger value="manual">{t('tab_manual')}</TabsTrigger>
|
||
</TabsList>
|
||
|
||
<TabsContent value="template" className="space-y-4 mt-0">
|
||
{/* Template picker */}
|
||
<div className="space-y-2">
|
||
<Label>{t('template_label')}</Label>
|
||
{loadingTemplates ? (
|
||
<div className="space-y-2">
|
||
<Skeleton className="h-12 w-full" />
|
||
<Skeleton className="h-12 w-full" />
|
||
</div>
|
||
) : templates.length === 0 ? (
|
||
<div className="rounded-lg border border-dashed bg-muted/30 p-4 text-center text-sm text-muted-foreground">
|
||
{t('no_templates')}
|
||
</div>
|
||
) : (
|
||
<ul className="space-y-1 max-h-[180px] overflow-y-auto">
|
||
{templates.map((tpl) => (
|
||
<li key={tpl.id}>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelectedTemplateId(tpl.id)}
|
||
className={cn(
|
||
'w-full rounded-lg border bg-card p-3 text-left transition-colors hover:bg-secondary/60',
|
||
selectedTemplateId === tpl.id
|
||
? 'border-foreground'
|
||
: 'border-border',
|
||
)}
|
||
>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-center gap-2">
|
||
<FileText className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||
<span className="text-sm font-medium truncate">{tpl.name}</span>
|
||
{tpl.is_system && (
|
||
<span className="flex-shrink-0 text-[10px] text-muted-foreground">
|
||
{t('system_badge')}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{tpl.description && (
|
||
<p className="mt-1 text-xs text-muted-foreground truncate">
|
||
{tpl.description}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
|
||
{/* Mode toggle: segmented control pattern (no RadioGroup primitive
|
||
in the design system; two outlined buttons act as a selectable
|
||
pair) */}
|
||
{selectedTemplate && (
|
||
<div className="space-y-2">
|
||
<Label>{t('mode_label')}</Label>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setMode('one_line_per_tx')}
|
||
className={cn(
|
||
'rounded-lg border bg-card p-3 text-left transition-colors hover:bg-secondary/60',
|
||
mode === 'one_line_per_tx' ? 'border-foreground' : 'border-border',
|
||
)}
|
||
>
|
||
<p className="text-sm font-medium">{t('mode_per_tx')}</p>
|
||
<p className="mt-1 text-xs text-muted-foreground">{t('mode_per_tx_hint')}</p>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setMode('sum_per_account')}
|
||
className={cn(
|
||
'rounded-lg border bg-card p-3 text-left transition-colors hover:bg-secondary/60',
|
||
mode === 'sum_per_account' ? 'border-foreground' : 'border-border',
|
||
)}
|
||
>
|
||
<p className="text-sm font-medium">{t('mode_sum')}</p>
|
||
<p className="mt-1 text-xs text-muted-foreground">{t('mode_sum_hint')}</p>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</TabsContent>
|
||
|
||
<TabsContent value="manual" className="space-y-4 mt-0">
|
||
{/* Manual line editor. Lines are pre-filled from txs on first
|
||
switch to this tab (one line per tx on 1930 + counterpart
|
||
line on 3001/5800). User adjusts accounts, amounts, and
|
||
descriptions. Live balance + bank-leg checks below drive
|
||
the confirm button. */}
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<Label>{t('manual_lines_label')}</Label>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={addManualLine}
|
||
>
|
||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||
{t('manual_add_line')}
|
||
</Button>
|
||
</div>
|
||
<div className="rounded-lg border bg-card overflow-hidden">
|
||
<table className="w-full text-xs tabular-nums">
|
||
<thead>
|
||
<tr className="border-b text-muted-foreground bg-muted/30">
|
||
<th className="px-2 py-2 text-left font-medium w-[90px]">{t('col_account')}</th>
|
||
<th className="px-2 py-2 text-left font-medium">{t('col_description')}</th>
|
||
<th className="px-2 py-2 text-right font-medium w-[110px]">{t('col_debit')}</th>
|
||
<th className="px-2 py-2 text-right font-medium w-[110px]">{t('col_credit')}</th>
|
||
<th className="w-8" />
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{manualLines.map((line) => (
|
||
<tr key={line.id} className="border-b border-border/40 last:border-b-0">
|
||
<td className="px-2 py-1">
|
||
<Input
|
||
value={line.account_number}
|
||
onChange={(e) =>
|
||
updateManualLine(line.id, { account_number: e.target.value.replace(/\D/g, '').slice(0, 4) })
|
||
}
|
||
placeholder="1930"
|
||
className="h-8 text-xs font-mono"
|
||
/>
|
||
</td>
|
||
<td className="px-2 py-1">
|
||
<Input
|
||
value={line.line_description}
|
||
onChange={(e) =>
|
||
updateManualLine(line.id, { line_description: e.target.value.slice(0, 200) })
|
||
}
|
||
placeholder={t('manual_description_placeholder')}
|
||
className="h-8 text-xs"
|
||
/>
|
||
</td>
|
||
<td className="px-2 py-1">
|
||
<Input
|
||
inputMode="decimal"
|
||
value={line.debit_amount}
|
||
onChange={(e) =>
|
||
updateManualLine(line.id, { debit_amount: e.target.value })
|
||
}
|
||
placeholder="0,00"
|
||
className="h-8 text-xs text-right"
|
||
/>
|
||
</td>
|
||
<td className="px-2 py-1">
|
||
<Input
|
||
inputMode="decimal"
|
||
value={line.credit_amount}
|
||
onChange={(e) =>
|
||
updateManualLine(line.id, { credit_amount: e.target.value })
|
||
}
|
||
placeholder="0,00"
|
||
className="h-8 text-xs text-right"
|
||
/>
|
||
</td>
|
||
<td className="px-1 py-1">
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||
onClick={() => removeManualLine(line.id)}
|
||
aria-label={t('manual_remove_line')}
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</TabsContent>
|
||
</Tabs>
|
||
|
||
{/* Description: shared by both tabs once the user has either a
|
||
template selected or manual lines drafted. */}
|
||
{tabReady && (
|
||
<div className="space-y-2">
|
||
<Label htmlFor="bulk-description">{t('description_label')}</Label>
|
||
<Input
|
||
id="bulk-description"
|
||
value={description}
|
||
onChange={(e) => setDescription(e.target.value)}
|
||
maxLength={500}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Header default dims (kostnadsställe/projekt): one bag applied to
|
||
the whole verifikat, shared by both tabs. */}
|
||
{dimensionsEnabled && tabReady && (
|
||
<div className="space-y-1">
|
||
<LineDimensionFields
|
||
dimensions={defaultDims}
|
||
onChange={setDefaultDimension}
|
||
inputClassName="h-8"
|
||
/>
|
||
<p className="text-xs text-muted-foreground">{t('dimensions_hint')}</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Document inheritance hint: informs the user which receipts
|
||
follow the txs onto the combined verifikat. Zero is fine
|
||
(txs without docs don't break anything); we only render
|
||
when the count is non-zero to avoid clutter. */}
|
||
{docCount > 0 && tabReady && (
|
||
<div className="flex items-center gap-2 rounded-lg border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||
<Paperclip className="h-3.5 w-3.5 flex-shrink-0" />
|
||
<span>{t('docs_inherit_hint', { count: docCount })}</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Live preview */}
|
||
{tabReady && previewLines.length > 0 && (
|
||
<div className="space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<Label>
|
||
{t('preview_label', { count: previewLines.length })}
|
||
</Label>
|
||
{dimensionsEnabled && dimsSummary && (
|
||
<Badge data-ph-mask="" variant="secondary" className="font-mono tabular-nums">
|
||
{dimsSummary}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
<div className="rounded-lg border bg-muted/30 overflow-hidden">
|
||
<table className="w-full text-xs tabular-nums">
|
||
<thead>
|
||
<tr className="border-b text-muted-foreground">
|
||
<th className="px-3 py-2 text-left font-medium">{t('col_account')}</th>
|
||
<th className="px-3 py-2 text-left font-medium">{t('col_description')}</th>
|
||
<th className="px-3 py-2 text-right font-medium">{t('col_debit')}</th>
|
||
<th className="px-3 py-2 text-right font-medium">{t('col_credit')}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{previewLines.slice(0, 30).map((line, i) => (
|
||
<tr key={i} className="border-b border-border/40 last:border-b-0">
|
||
<td className="px-3 py-1.5 font-mono">{line.account_number}</td>
|
||
<td className="px-3 py-1.5 text-muted-foreground truncate max-w-[240px]">
|
||
{line.line_description ?? '-'}
|
||
</td>
|
||
<td className="px-3 py-1.5 text-right">
|
||
{line.debit_amount > 0 ? formatCurrency(line.debit_amount) : ''}
|
||
</td>
|
||
<td className="px-3 py-1.5 text-right">
|
||
{line.credit_amount > 0 ? formatCurrency(line.credit_amount) : ''}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{previewLines.length > 30 && (
|
||
<tr>
|
||
<td colSpan={4} className="px-3 py-1.5 text-center text-muted-foreground">
|
||
{t('preview_truncated', { remaining: previewLines.length - 30 })}
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
<tfoot>
|
||
<tr className="border-t bg-card font-medium">
|
||
<td colSpan={2} className="px-3 py-2 text-right">{t('total_label')}</td>
|
||
<td className="px-3 py-2 text-right">{formatCurrency(previewTotals.debit)}</td>
|
||
<td className="px-3 py-2 text-right">{formatCurrency(previewTotals.credit)}</td>
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Invariant indicators */}
|
||
<div className="space-y-1">
|
||
{isBalanced ? (
|
||
<div className="flex items-center gap-2 text-xs text-success">
|
||
<Check className="h-3.5 w-3.5" />
|
||
<span>{t('balance_ok')}</span>
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center gap-2 text-xs text-destructive">
|
||
<AlertTriangle className="h-3.5 w-3.5" />
|
||
<span>{t('balance_off', {
|
||
delta: formatCurrency(Math.abs(previewTotals.debit - previewTotals.credit)),
|
||
})}</span>
|
||
</div>
|
||
)}
|
||
{bankMatches ? (
|
||
<div className="flex items-center gap-2 text-xs text-success">
|
||
<Check className="h-3.5 w-3.5" />
|
||
<span>{t('bank_ok')}</span>
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center gap-2 text-xs text-destructive">
|
||
<AlertTriangle className="h-3.5 w-3.5" />
|
||
<span>{t('bank_off')}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||
{t('cancel')}
|
||
</Button>
|
||
<Button onClick={() => handleConfirm()} disabled={!canConfirm}>
|
||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
{t('confirm')}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
|
||
{/* Booking-time duplicate guard review. Rendered inside the bulk dialog
|
||
so cancelling it returns to the batch as-is; "Bokför ändå" re-runs
|
||
the whole batch with force=true (the server re-detects and records
|
||
the dismissal in behandlingshistorik). */}
|
||
{duplicateCandidate && (
|
||
<DuplicateBookingDialog
|
||
candidate={duplicateCandidate}
|
||
processing={submitting}
|
||
onCancel={() => setDuplicateCandidate(null)}
|
||
onBookAnyway={() => {
|
||
setDuplicateCandidate(null)
|
||
void handleConfirm({ force: true })
|
||
}}
|
||
/>
|
||
)}
|
||
</Dialog>
|
||
)
|
||
}
|