feat(transactions): show all library templates in picker; fix PSD2 seed-row collision (#596)
* feat(transactions): show all library templates in picker; fix PSD2 seed-row collision - Booking template picker: surface every active library template, not only the convertible 2-account shapes. Multi-leg/complex templates route to the manual journal editor pre-filled via applyTemplate instead of being hidden. Drop direction filtering for user templates (inferred direction is unreliable); the curated static catalog still respects it. Add an "Aktivera och bokfor" recovery toast for TX_CATEGORIZE_INVALID_ACCOUNT mirroring the existing ACCOUNTS_NOT_IN_CHART flow. CreateTemplateForm reflows to one card per line so trash buttons stop colliding on narrow screens. - cash_accounts.upsertFromPsd2: the seed_default_cash_account migration plants a manual (bank_connection_id IS NULL) row on the same ledger_account, so the first PSD2 sync's upsert on (company_id, bank_connection_id, external_uid) cannot match it (NULL != NULL) and falls through to INSERT, tripping the (company_id, ledger_account) UNIQUE constraint. Look up and promote the seed row in place first. - Tests: pin the TX_CATEGORIZE_INVALID_ACCOUNT error shape the recovery toast parses; cover applyTemplate on shapes the converter rejects (split-expense and all-'business'-typed). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(transactions): PR review — currency metadata, MRU ordering, observable promote, BAS validation - TransactionBookingDialog.buildInitialLinesFromTemplate: attach buildCurrencyMetadata to settlement lines for foreign-currency transactions so the journal entry retains the original currency, amount, and exchange_rate. Without this, non-SEK transactions routed through a non-convertible template were recorded in SEK only with no foreign-currency annotation. (Greptile #2) - TemplatePicker.handleSelectLibraryRaw: only bump the MRU after confirming the click will actually do something (i.e. converted OR a callback is wired). Future consumers that omit onPickLibraryTemplate would otherwise corrupt MRU ordering for templates the user never successfully applied. (Greptile #1) - cash_accounts.upsertFromPsd2 promote-seed: add .select('id') so a zero-row UPDATE is observable. If the seed row vanishes between the SELECT and UPDATE (concurrent ops), fall through to the normal upsert instead of silently returning success without persisting anything. (Greptile #3, compliance A.8.9) - transactions/page.tsx TX_CATEGORIZE_INVALID_ACCOUNT toast: validate accountNumber against /^\d{4}$/ before embedding in any fetch URL/body. Defense-in-depth against a malformed server error envelope. (compliance V8.2.1) - categorize route test: switch the not-in-chart fixture from '4535' (Inköp av varor från annat EU-land — reverse-charge) to '5420' (Programvaror) so the example doesn't imply a domestic override against an EU-reverse-charge account would be valid without its paired moms legs. (Swedish compliance review #3) Other review items deliberately not addressed in this PR: - Validate-on-save that every VAT-rated template has a 'vat' line — overrides existing CreateTemplateForm UX, separate PR. - vat_rate enum guard in applyTemplate — defensive; the editor dropdown only surfaces legal rates and is the only write path in production today. - Imbalance UI warning — already handled: JournalEntryForm computes isBalanced and gates submission; DB trigger check_journal_entry_balance enforces BFL 5 kap server-side. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
ccdfed5fea
commit
a586cc8a58
@@ -490,59 +490,63 @@ function CreateTemplateForm({ onCreated, entityLabels }: { onCreated: () => void
|
||||
<Label>{t('lines_label')}</Label>
|
||||
<div className="space-y-2 mt-1">
|
||||
{lines.map((line, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={line.account}
|
||||
onChange={(e) => updateLine(i, 'account', e.target.value.replace(/\D/g, '').slice(0, 4))}
|
||||
placeholder={t('account_placeholder')}
|
||||
className="w-20 font-mono"
|
||||
maxLength={4}
|
||||
/>
|
||||
<Input
|
||||
value={line.label}
|
||||
onChange={(e) => updateLine(i, 'label', e.target.value)}
|
||||
placeholder={t('description_short_placeholder')}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Select value={line.side} onValueChange={(v) => updateLine(i, 'side', v)}>
|
||||
<SelectTrigger className="w-20"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="debit">{t('debit_label')}</SelectItem>
|
||||
<SelectItem value="credit">{t('credit_label')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={line.type} onValueChange={(v) => updateLineType(i, v as BookingTemplateLibraryLine['type'])}>
|
||||
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="business">{t('type_cost')}</SelectItem>
|
||||
<SelectItem value="vat">{t('type_vat')}</SelectItem>
|
||||
<SelectItem value="settlement">{t('type_settlement')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{line.type === 'vat' && (
|
||||
<Select
|
||||
value={String(line.vat_rate ?? 0.25)}
|
||||
onValueChange={(v) => updateLine(i, 'vat_rate', Number(v))}
|
||||
<div key={i} className="rounded-md border border-border p-2 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={line.account}
|
||||
onChange={(e) => updateLine(i, 'account', e.target.value.replace(/\D/g, '').slice(0, 4))}
|
||||
placeholder={t('account_placeholder')}
|
||||
className="w-20 font-mono"
|
||||
maxLength={4}
|
||||
/>
|
||||
<Input
|
||||
value={line.label}
|
||||
onChange={(e) => updateLine(i, 'label', e.target.value)}
|
||||
placeholder={t('description_short_placeholder')}
|
||||
className="flex-1 min-w-0"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeLine(i)}
|
||||
disabled={lines.length <= 2}
|
||||
className="h-8 w-8 p-0 shrink-0"
|
||||
>
|
||||
<SelectTrigger className="w-20" aria-label={t('vat_rate_label')}><SelectValue /></SelectTrigger>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={line.side} onValueChange={(v) => updateLine(i, 'side', v)}>
|
||||
<SelectTrigger className="w-24"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0.25">{t('vat_rate_25')}</SelectItem>
|
||||
<SelectItem value="0.12">{t('vat_rate_12')}</SelectItem>
|
||||
<SelectItem value="0.06">{t('vat_rate_6')}</SelectItem>
|
||||
<SelectItem value="0">{t('vat_rate_0')}</SelectItem>
|
||||
<SelectItem value="debit">{t('debit_label')}</SelectItem>
|
||||
<SelectItem value="credit">{t('credit_label')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeLine(i)}
|
||||
disabled={lines.length <= 2}
|
||||
className="h-8 w-8 p-0 shrink-0"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Select value={line.type} onValueChange={(v) => updateLineType(i, v as BookingTemplateLibraryLine['type'])}>
|
||||
<SelectTrigger className="flex-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="business">{t('type_cost')}</SelectItem>
|
||||
<SelectItem value="vat">{t('type_vat')}</SelectItem>
|
||||
<SelectItem value="settlement">{t('type_settlement')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{line.type === 'vat' && (
|
||||
<Select
|
||||
value={String(line.vat_rate ?? 0.25)}
|
||||
onValueChange={(v) => updateLine(i, 'vat_rate', Number(v))}
|
||||
>
|
||||
<SelectTrigger className="w-24" aria-label={t('vat_rate_label')}><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0.25">{t('vat_rate_25')}</SelectItem>
|
||||
<SelectItem value="0.12">{t('vat_rate_12')}</SelectItem>
|
||||
<SelectItem value="0.06">{t('vat_rate_6')}</SelectItem>
|
||||
<SelectItem value="0">{t('vat_rate_0')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="outline" size="sm" onClick={addLine}>
|
||||
|
||||
@@ -77,6 +77,73 @@ interface TemplateCardProps {
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
interface LibraryTemplateCardProps {
|
||||
raw: BookingTemplateLibrary
|
||||
converted: BookingTemplate | null
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function LibraryTemplateCard({ raw, converted, selected, onClick }: LibraryTemplateCardProps) {
|
||||
const t = useTranslations('tx_template_picker')
|
||||
// Convertible templates render the familiar two-account summary; complex
|
||||
// ones list the business legs (the cost/revenue accounts) so the user can
|
||||
// recognise the template at a glance, and carry an "opens editor" badge.
|
||||
const businessLines = raw.lines.filter((l) => l.type === 'business')
|
||||
const vatLabelKey = converted ? getVatLabelKey(converted) : null
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`w-full text-left rounded-lg border px-3 py-2.5 transition-colors hover:bg-muted/50 ${
|
||||
selected
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
||||
: 'border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-sm leading-tight">{raw.name}</p>
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
{converted ? (
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
D: {formatAccountWithName(converted.debit_account)} · K: {formatAccountWithName(converted.credit_account)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{businessLines.slice(0, 2).map((l) => formatAccountWithName(l.account)).join(' · ') || raw.lines.map((l) => l.account).slice(0, 2).join(' · ')}
|
||||
</span>
|
||||
)}
|
||||
{vatLabelKey && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`text-[10px] px-1.5 py-0 ${
|
||||
converted?.vat_treatment === 'reverse_charge'
|
||||
? 'bg-warning/10 text-warning-foreground'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{t(vatLabelKey)}
|
||||
</Badge>
|
||||
)}
|
||||
{!converted && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
{t('opens_editor_badge')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{raw.description && (
|
||||
<p className="text-[11px] text-muted-foreground mt-1.5 leading-snug">
|
||||
{raw.description}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function TemplateCard({ template, selected, onClick, compact }: TemplateCardProps) {
|
||||
const t = useTranslations('tx_template_picker')
|
||||
const vatLabelKey = getVatLabelKey(template)
|
||||
@@ -140,6 +207,7 @@ interface TemplatePickerProps {
|
||||
recentTemplateIds?: string[]
|
||||
onSelect: (template: BookingTemplate) => void
|
||||
onSelectCounterparty?: (templateId: string) => void
|
||||
onPickLibraryTemplate?: (raw: BookingTemplateLibrary) => void
|
||||
selectedTemplateId?: string
|
||||
}
|
||||
|
||||
@@ -149,19 +217,25 @@ export default function TemplatePicker({
|
||||
suggestedTemplates,
|
||||
onSelect,
|
||||
onSelectCounterparty,
|
||||
onPickLibraryTemplate,
|
||||
selectedTemplateId,
|
||||
}: TemplatePickerProps) {
|
||||
const t = useTranslations('tx_template_picker')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [libraryTemplates, setLibraryTemplates] = useState<BookingTemplate[]>([])
|
||||
const [libraryRaw, setLibraryRaw] = useState<BookingTemplateLibrary[]>([])
|
||||
|
||||
// Map direction to template direction filter (transfers show in both)
|
||||
// Map direction to template direction filter (transfers show in both).
|
||||
// Direction filtering applies only to the static "Vanliga mallar" list —
|
||||
// user-created library templates ignore it (inferred direction is unreliable
|
||||
// and users know what they made).
|
||||
const templateDirection = direction === 'income' ? 'income' : 'expense'
|
||||
|
||||
// Fetch user-created booking templates (company + team scope) and map
|
||||
// convertible ones into BookingTemplate shape. System templates are
|
||||
// already covered by the static list below, so we exclude them here.
|
||||
// Fetch the user's library templates (company + team scope). We keep them
|
||||
// in their raw shape so we can render every template, even ones that don't
|
||||
// fit convertLibraryToBookingTemplate's simple 2-account contract — those
|
||||
// get routed through the manual booking dialog instead of the QuickReview
|
||||
// single-account path.
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
;(async () => {
|
||||
@@ -170,11 +244,7 @@ export default function TemplatePicker({
|
||||
if (!res.ok) return
|
||||
const { data } = await res.json() as { data?: BookingTemplateLibrary[] }
|
||||
if (!data) return
|
||||
const mapped = data
|
||||
.filter((t) => !t.is_system && t.is_active)
|
||||
.map(convertLibraryToBookingTemplate)
|
||||
.filter((t): t is BookingTemplate => t !== null)
|
||||
setLibraryTemplates(mapped)
|
||||
setLibraryRaw(data.filter((tt) => !tt.is_system && tt.is_active))
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
}
|
||||
@@ -182,6 +252,16 @@ export default function TemplatePicker({
|
||||
return () => { controller.abort() }
|
||||
}, [])
|
||||
|
||||
// Lazy convertibility map. A template is "convertible" if it fits the
|
||||
// simple debit/credit pair shape the QuickReview booking path expects.
|
||||
// Non-convertible templates are still shown — they just route to the
|
||||
// full journal-entry editor on click.
|
||||
const convertedById = useMemo(() => {
|
||||
const m = new Map<string, BookingTemplate | null>()
|
||||
for (const raw of libraryRaw) m.set(raw.id, convertLibraryToBookingTemplate(raw))
|
||||
return m
|
||||
}, [libraryRaw])
|
||||
|
||||
const commonTemplates = useMemo(
|
||||
() => getCommonTemplates(entityType, templateDirection),
|
||||
[entityType, templateDirection]
|
||||
@@ -211,43 +291,78 @@ export default function TemplatePicker({
|
||||
[advancedTemplates, advancedTransfers]
|
||||
)
|
||||
|
||||
// User-created library templates filtered by direction + entity
|
||||
const relevantLibraryTemplates = useMemo(() => {
|
||||
return libraryTemplates.filter((t) => {
|
||||
if (entityType && t.entity_applicability !== 'all' && t.entity_applicability !== entityType) {
|
||||
// Library templates filtered by entity_type only. Direction is NOT applied
|
||||
// here — see the comment on convertedById above.
|
||||
const relevantLibraryRaw = useMemo(() => {
|
||||
return libraryRaw.filter((tt) => {
|
||||
if (entityType && tt.entity_type && tt.entity_type !== 'all' && tt.entity_type !== entityType) {
|
||||
return false
|
||||
}
|
||||
return t.direction === templateDirection || t.direction === 'transfer'
|
||||
return true
|
||||
})
|
||||
}, [libraryTemplates, entityType, templateDirection])
|
||||
}, [libraryRaw, entityType])
|
||||
|
||||
// Search results (static + library)
|
||||
const searchResults = useMemo(() => {
|
||||
// Convertible templates surface first; within each group, sort by name.
|
||||
const sortedLibraryRaw = useMemo(() => {
|
||||
return [...relevantLibraryRaw].sort((a, b) => {
|
||||
const ac = convertedById.get(a.id) ? 0 : 1
|
||||
const bc = convertedById.get(b.id) ? 0 : 1
|
||||
if (ac !== bc) return ac - bc
|
||||
return a.name.localeCompare(b.name, 'sv')
|
||||
})
|
||||
}, [relevantLibraryRaw, convertedById])
|
||||
|
||||
// Search results (static + library). Library search ignores direction; the
|
||||
// static catalog still respects it because it's curated content.
|
||||
const searchResults = useMemo<
|
||||
| { library: BookingTemplateLibrary[]; staticTemplates: BookingTemplate[] }
|
||||
| null
|
||||
>(() => {
|
||||
if (!searchQuery.trim()) return null
|
||||
const q = searchQuery.toLowerCase()
|
||||
const libraryMatches = relevantLibraryTemplates.filter((t) =>
|
||||
t.name_sv.toLowerCase().includes(q) || t.description_sv.toLowerCase().includes(q)
|
||||
const libraryMatches = sortedLibraryRaw.filter((tt) =>
|
||||
tt.name.toLowerCase().includes(q) ||
|
||||
(tt.description ?? '').toLowerCase().includes(q)
|
||||
)
|
||||
const staticMatches = searchTemplates(searchQuery, entityType).filter((t) => {
|
||||
if (t.direction === templateDirection || t.direction === 'transfer') return true
|
||||
return false
|
||||
const staticMatches = searchTemplates(searchQuery, entityType).filter((tt) => {
|
||||
return tt.direction === templateDirection || tt.direction === 'transfer'
|
||||
})
|
||||
return [...libraryMatches, ...staticMatches]
|
||||
}, [searchQuery, entityType, templateDirection, relevantLibraryTemplates])
|
||||
return { library: libraryMatches, staticTemplates: staticMatches }
|
||||
}, [searchQuery, entityType, templateDirection, sortedLibraryRaw])
|
||||
|
||||
// Group templates by group for display
|
||||
const commonGrouped = useMemo(() => groupTemplates(allCommon), [allCommon])
|
||||
const advancedGrouped = useMemo(() => groupTemplates(allAdvanced), [allAdvanced])
|
||||
|
||||
const bumpLibraryMru = (libraryId: string) => {
|
||||
fetch(`/api/settings/booking-templates/${libraryId}/touch`, { method: 'POST' }).catch(() => {})
|
||||
}
|
||||
|
||||
const handleSelect = (template: BookingTemplate) => {
|
||||
// For library-backed templates, bump MRU so they surface at the top next time.
|
||||
if (isLibraryTemplateId(template.id)) {
|
||||
const libraryId = template.id.slice(LIBRARY_TEMPLATE_PREFIX.length)
|
||||
fetch(`/api/settings/booking-templates/${libraryId}/touch`, { method: 'POST' }).catch(() => {})
|
||||
bumpLibraryMru(template.id.slice(LIBRARY_TEMPLATE_PREFIX.length))
|
||||
}
|
||||
onSelect(template)
|
||||
}
|
||||
|
||||
// Click a raw library card. Convertible → fast QuickReview path via onSelect.
|
||||
// Non-convertible → route to manual booking dialog pre-filled via the new
|
||||
// onPickLibraryTemplate callback. MRU is only bumped after we confirm the
|
||||
// click will actually do something — otherwise consumers that omit the
|
||||
// callback would corrupt MRU ordering for templates the user never applied.
|
||||
const handleSelectLibraryRaw = (raw: BookingTemplateLibrary) => {
|
||||
const converted = convertedById.get(raw.id) ?? null
|
||||
if (converted) {
|
||||
bumpLibraryMru(raw.id)
|
||||
onSelect(converted)
|
||||
return
|
||||
}
|
||||
if (onPickLibraryTemplate) {
|
||||
bumpLibraryMru(raw.id)
|
||||
onPickLibraryTemplate(raw)
|
||||
}
|
||||
}
|
||||
|
||||
// Split suggestions: counterparty templates vs regular booking templates
|
||||
const counterpartySuggestions = useMemo(() => {
|
||||
if (!suggestedTemplates) return []
|
||||
@@ -277,38 +392,54 @@ export default function TemplatePicker({
|
||||
<div className="flex-1 overflow-auto px-4 pb-4 space-y-4">
|
||||
{/* Search results */}
|
||||
{searchResults !== null ? (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">
|
||||
{searchResults.length === 0 ? t('no_results') : t('n_results', { count: searchResults.length })}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{searchResults.map((t) => (
|
||||
<TemplateCard
|
||||
key={t.id}
|
||||
template={t}
|
||||
selected={selectedTemplateId === t.id}
|
||||
onClick={() => handleSelect(t)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
(() => {
|
||||
const totalResults = searchResults.library.length + searchResults.staticTemplates.length
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">
|
||||
{totalResults === 0 ? t('no_results') : t('n_results', { count: totalResults })}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{searchResults.library.map((raw) => (
|
||||
<LibraryTemplateCard
|
||||
key={raw.id}
|
||||
raw={raw}
|
||||
converted={convertedById.get(raw.id) ?? null}
|
||||
selected={selectedTemplateId === (convertedById.get(raw.id)?.id ?? raw.id)}
|
||||
onClick={() => handleSelectLibraryRaw(raw)}
|
||||
/>
|
||||
))}
|
||||
{searchResults.staticTemplates.map((tt) => (
|
||||
<TemplateCard
|
||||
key={tt.id}
|
||||
template={tt}
|
||||
selected={selectedTemplateId === tt.id}
|
||||
onClick={() => handleSelect(tt)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()
|
||||
) : (
|
||||
<>
|
||||
{/* User-created library templates (company + team scope) */}
|
||||
{relevantLibraryTemplates.length > 0 && (
|
||||
{/* User-created library templates (company + team scope).
|
||||
Direction is intentionally NOT applied here — all the user's
|
||||
own templates are shown regardless of expense/income context. */}
|
||||
{sortedLibraryRaw.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2 flex items-center gap-1.5">
|
||||
<Building2 className="h-3 w-3" />
|
||||
{t('my_templates')}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{relevantLibraryTemplates.map((t) => (
|
||||
<TemplateCard
|
||||
key={t.id}
|
||||
template={t}
|
||||
selected={selectedTemplateId === t.id}
|
||||
onClick={() => handleSelect(t)}
|
||||
compact
|
||||
{sortedLibraryRaw.map((raw) => (
|
||||
<LibraryTemplateCard
|
||||
key={raw.id}
|
||||
raw={raw}
|
||||
converted={convertedById.get(raw.id) ?? null}
|
||||
selected={selectedTemplateId === (convertedById.get(raw.id)?.id ?? raw.id)}
|
||||
onClick={() => handleSelectLibraryRaw(raw)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,8 @@ import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
|
||||
import { resolveSekAmount, buildCurrencyMetadata } from '@/lib/bookkeeping/currency-utils'
|
||||
import { applyTemplate } from '@/lib/bookkeeping/template-library'
|
||||
import type { BookingTemplateLibrary } from '@/types'
|
||||
import type { TransactionWithInvoice } from './transaction-types'
|
||||
|
||||
interface TransactionBookingDialogProps {
|
||||
@@ -20,6 +22,7 @@ interface TransactionBookingDialogProps {
|
||||
onOpenChange: (open: boolean) => void
|
||||
transaction: TransactionWithInvoice | null
|
||||
onBooked: (transactionId: string, journalEntryId: string) => void
|
||||
preselectedTemplate?: BookingTemplateLibrary | null
|
||||
}
|
||||
|
||||
function buildInitialLines(transaction: TransactionWithInvoice, bankLineDescription: string): FormLine[] {
|
||||
@@ -59,11 +62,41 @@ function buildInitialLines(transaction: TransactionWithInvoice, bankLineDescript
|
||||
return isExpense ? [bankLine, counterLine] : [bankLine, counterLine]
|
||||
}
|
||||
|
||||
function buildInitialLinesFromTemplate(
|
||||
transaction: TransactionWithInvoice,
|
||||
template: BookingTemplateLibrary,
|
||||
): FormLine[] {
|
||||
const sekAmount = Math.round(Math.abs(resolveSekAmount(
|
||||
transaction.amount,
|
||||
transaction.amount_sek,
|
||||
transaction.currency,
|
||||
transaction.exchange_rate
|
||||
)) * 100) / 100
|
||||
const lines = applyTemplate(template.lines, sekAmount)
|
||||
|
||||
// Match buildInitialLines's foreign-currency handling: attach original
|
||||
// currency/amount/exchange_rate metadata to the settlement (bank/cash) legs
|
||||
// so the journal entry retains the foreign-currency annotation. Without
|
||||
// this the entry is silently recorded in SEK only.
|
||||
const isForeign = !!transaction.currency && transaction.currency !== 'SEK'
|
||||
if (!isForeign) return lines
|
||||
const currencyMeta = buildCurrencyMetadata(
|
||||
transaction.currency,
|
||||
Math.abs(transaction.amount),
|
||||
transaction.exchange_rate
|
||||
)
|
||||
return lines.map((line, i) => {
|
||||
const raw = template.lines[i]
|
||||
return raw?.type === 'settlement' ? { ...line, ...currencyMeta } : line
|
||||
})
|
||||
}
|
||||
|
||||
export default function TransactionBookingDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
transaction,
|
||||
onBooked,
|
||||
preselectedTemplate,
|
||||
}: TransactionBookingDialogProps) {
|
||||
const t = useTranslations('tx_booking_dialog')
|
||||
const { toast } = useToast()
|
||||
@@ -179,9 +212,13 @@ export default function TransactionBookingDialog({
|
||||
</div>
|
||||
|
||||
<JournalEntryForm
|
||||
key={transaction.id}
|
||||
key={`${transaction.id}-${preselectedTemplate?.id ?? 'default'}`}
|
||||
embedded
|
||||
initialLines={buildInitialLines(transaction, t('bank_line_description'))}
|
||||
initialLines={
|
||||
preselectedTemplate
|
||||
? buildInitialLinesFromTemplate(transaction, preselectedTemplate)
|
||||
: buildInitialLines(transaction, t('bank_line_description'))
|
||||
}
|
||||
initialDate={transaction.date}
|
||||
initialDescription={transaction.description}
|
||||
submitUrl={`/api/transactions/${transaction.id}/book`}
|
||||
|
||||
Reference in New Issue
Block a user