Fix/footer UI (#296)
* feat: enhance journal entry handling with follow-up entries and related RPC * fix: improve validation for journal entry lines to ensure proper submission criteria * feat: add commit_method and rubric_version columns to journal_entries for enhanced tracking * fix: ensure conditional addition of commit_method and rubric_version columns in journal_entries * Update supabase/migrations/20260421120000_journal_entries_with_related_rpc.sql Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
@@ -418,7 +418,7 @@ export default function NewInvoicePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 pb-28 md:pb-0">
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Main content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
@@ -771,10 +771,10 @@ export default function NewInvoicePage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions — desktop only */}
|
||||
{/* Actions — desktop/tablet only */}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full hidden lg:block"
|
||||
className="w-full hidden md:block"
|
||||
size="lg"
|
||||
disabled={isSubmitting || !canWrite}
|
||||
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
|
||||
@@ -786,7 +786,7 @@ export default function NewInvoicePage() {
|
||||
</div>
|
||||
|
||||
{/* Mobile sticky total bar */}
|
||||
<div className="lg:hidden fixed left-0 right-0 z-40 bg-card/98 backdrop-blur-sm border-t border-border/40 px-5 py-3" style={{ bottom: 'calc(4rem + env(safe-area-inset-bottom, 0px))' }}>
|
||||
<div className="md:hidden fixed left-0 right-0 z-40 bg-card/98 backdrop-blur-sm border-t border-border/40 px-5 py-3" style={{ bottom: 'calc(4rem + env(safe-area-inset-bottom, 0px))' }}>
|
||||
<div className="max-w-5xl mx-auto flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Totalt</p>
|
||||
|
||||
@@ -76,6 +76,8 @@ describe('GET /api/bookkeeping/journal-entries', () => {
|
||||
date_to: '2024-12-31',
|
||||
limit: '10',
|
||||
offset: '5',
|
||||
// Strict period filtering — exercises the PostgREST path, not the RPC.
|
||||
include_related: 'false',
|
||||
},
|
||||
})
|
||||
const response = await GET(request)
|
||||
@@ -85,6 +87,42 @@ describe('GET /api/bookkeeping/journal-entries', () => {
|
||||
expect(mockSupabase.from).toHaveBeenCalledWith('journal_entries')
|
||||
})
|
||||
|
||||
it('uses RPC with include_related when period_id is set', async () => {
|
||||
const rpcRows = [
|
||||
{
|
||||
entry: { ...makeJournalEntry({ id: 'je-1' }), out_of_period: false },
|
||||
total_count: 2,
|
||||
},
|
||||
{
|
||||
entry: { ...makeJournalEntry({ id: 'je-2' }), out_of_period: true },
|
||||
total_count: 2,
|
||||
},
|
||||
]
|
||||
enqueue({ data: rpcRows, error: null })
|
||||
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries', {
|
||||
searchParams: { period_id: 'period-1' },
|
||||
})
|
||||
const response = await GET(request)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: Array<{ id: string; out_of_period?: boolean }>
|
||||
count: number
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(mockSupabase.rpc).toHaveBeenCalledWith(
|
||||
'list_fiscal_period_entries_with_related',
|
||||
expect.objectContaining({
|
||||
p_company_id: 'company-1',
|
||||
p_period_id: 'period-1',
|
||||
p_include_related: true,
|
||||
})
|
||||
)
|
||||
expect(body.data).toHaveLength(2)
|
||||
expect(body.data[1].out_of_period).toBe(true)
|
||||
expect(body.count).toBe(2)
|
||||
})
|
||||
|
||||
it('returns 500 on database error', async () => {
|
||||
enqueue({ data: null, error: { message: 'DB error' } })
|
||||
|
||||
|
||||
@@ -27,8 +27,38 @@ export async function GET(request: Request) {
|
||||
const dateFrom = searchParams.get('date_from')
|
||||
const dateTo = searchParams.get('date_to')
|
||||
const sortDate = searchParams.get('sort_date') // 'asc' | 'desc'
|
||||
// Default on: when a fiscal period is selected, include follow-up entries
|
||||
// booked in later periods whose source aggregate (invoice, supplier invoice)
|
||||
// is dated inside the selected period. Pass include_related=false to
|
||||
// restore strict fiscal_period_id filtering.
|
||||
const includeRelated = searchParams.get('include_related') !== 'false'
|
||||
|
||||
const dateAscending = sortDate === 'asc'
|
||||
const sortDateParam = sortDate === 'asc' || sortDate === 'desc' ? sortDate : 'desc'
|
||||
|
||||
if (periodId && includeRelated) {
|
||||
const { data, error } = await supabase.rpc('list_fiscal_period_entries_with_related', {
|
||||
p_company_id: companyId,
|
||||
p_period_id: periodId,
|
||||
p_include_related: true,
|
||||
p_status: status,
|
||||
p_date_from: dateFrom,
|
||||
p_date_to: dateTo,
|
||||
p_sort_date: sortDateParam,
|
||||
p_limit: limit,
|
||||
p_offset: offset,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const rows = data ?? []
|
||||
const entries = rows.map((r: { entry: unknown }) => r.entry)
|
||||
const count = rows.length > 0 ? Number((rows[0] as { total_count: number | string }).total_count) : 0
|
||||
|
||||
return NextResponse.json({ data: entries, count })
|
||||
}
|
||||
|
||||
let query = supabase
|
||||
.from('journal_entries')
|
||||
|
||||
@@ -221,9 +221,29 @@ export default function JournalEntryForm({
|
||||
setLines(updated)
|
||||
}
|
||||
|
||||
const totalDebit = lines.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0)
|
||||
const totalCredit = lines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0)
|
||||
const isBalanced = Math.round((totalDebit - totalCredit) * 100) === 0 && totalDebit > 0
|
||||
// Only lines with both an account and a non-zero amount end up in the submit
|
||||
// payload (see the filter in handleConfirm). Compute totals and balance from
|
||||
// those same lines so the enable-gate matches what the API will actually see.
|
||||
const submittableLines = lines.filter((l) => {
|
||||
const d = parseFloat(l.debit_amount) || 0
|
||||
const c = parseFloat(l.credit_amount) || 0
|
||||
return !!l.account_number && (d > 0 || c > 0)
|
||||
})
|
||||
const incompleteLineCount = lines.filter((l) => {
|
||||
const d = parseFloat(l.debit_amount) || 0
|
||||
const c = parseFloat(l.credit_amount) || 0
|
||||
const hasAmount = d > 0 || c > 0
|
||||
const hasAccount = !!l.account_number
|
||||
// Row counts as incomplete if exactly one of (account, amount) is present.
|
||||
return hasAccount !== hasAmount
|
||||
}).length
|
||||
const totalDebit = submittableLines.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0)
|
||||
const totalCredit = submittableLines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0)
|
||||
const isBalanced =
|
||||
Math.round((totalDebit - totalCredit) * 100) === 0
|
||||
&& totalDebit > 0
|
||||
&& submittableLines.length >= 2
|
||||
&& incompleteLineCount === 0
|
||||
|
||||
const rate = parseFloat(exchangeRate) || 0
|
||||
// If user has manually entered a foreign amount, use that; otherwise derive from SEK total
|
||||
@@ -734,12 +754,18 @@ export default function JournalEntryForm({
|
||||
{!canWrite && <Lock className="mr-2 h-4 w-4" />}
|
||||
Granska & skapa
|
||||
</Button>
|
||||
{(!description || !selectedPeriod || isUploading || periodMismatch) && (
|
||||
{(!description || !selectedPeriod || isUploading || periodMismatch || incompleteLineCount > 0 || (!isBalanced && submittableLines.length < 2)) && (
|
||||
<div className="text-xs text-muted-foreground space-y-0.5 text-right">
|
||||
{!description && <p>Ange en beskrivning</p>}
|
||||
{!selectedPeriod && <p>Välj en räkenskapsperiod</p>}
|
||||
{periodMismatch === 'no_period' && <p>Skapa ett räkenskapsår som matchar datumet</p>}
|
||||
{isUploading && <p>Vänta tills filerna laddats upp</p>}
|
||||
{incompleteLineCount > 0 && (
|
||||
<p>Alla rader med belopp måste ha ett konto (och tvärtom)</p>
|
||||
)}
|
||||
{submittableLines.length < 2 && incompleteLineCount === 0 && (
|
||||
<p>Minst två rader med konto och belopp krävs</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -309,6 +309,15 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
<span className="text-sm text-muted-foreground w-24">
|
||||
{entry.entry_date}
|
||||
</span>
|
||||
{entry.out_of_period && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs font-normal shrink-0"
|
||||
title="Bokförd i ett senare räkenskapsår, men avser det valda året (t.ex. betalning av en faktura utställd i det valda året)."
|
||||
>
|
||||
Efterföljande
|
||||
</Badge>
|
||||
)}
|
||||
{(entry.status === 'reversed' || entry.source_type === 'storno' || entry.source_type === 'correction') && (
|
||||
<JournalEntryStatusBadge entry={entry} showStatus={entry.status === 'reversed'} />
|
||||
)}
|
||||
@@ -344,6 +353,15 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{entry.entry_date}
|
||||
</span>
|
||||
{entry.out_of_period && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs font-normal shrink-0"
|
||||
title="Bokförd i ett senare räkenskapsår, men avser det valda året."
|
||||
>
|
||||
Efterföljande
|
||||
</Badge>
|
||||
)}
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
{attachmentCounts[entry.id] ? (
|
||||
<span className="flex items-center gap-0.5 text-muted-foreground" title={`${attachmentCounts[entry.id]} underlag`}>
|
||||
|
||||
@@ -148,7 +148,21 @@ function tryParseZodErrors(error: unknown): string | null {
|
||||
if (messages.length > 0) return messages.join('. ')
|
||||
}
|
||||
|
||||
// Check for { errors: { field: ["msg"] } } shape from validateBody
|
||||
// Check for { errors: [{ field, message, code }] } shape from validateBody
|
||||
if (Array.isArray(obj.errors)) {
|
||||
const items = obj.errors as Array<{ field?: string; message?: string }>
|
||||
const messages = items
|
||||
.slice(0, 3)
|
||||
.map((it) => {
|
||||
const field = it.field || ''
|
||||
const msg = it.message || 'ogiltigt värde'
|
||||
return field ? `${field}: ${msg}` : msg
|
||||
})
|
||||
.filter(Boolean)
|
||||
if (messages.length > 0) return messages.join('. ')
|
||||
}
|
||||
|
||||
// Check for { errors: { field: ["msg"] } } shape (legacy)
|
||||
if (typeof obj.errors === 'object' && obj.errors !== null) {
|
||||
const fieldErrors = obj.errors as Record<string, string[]>
|
||||
const messages: string[] = []
|
||||
|
||||
+2
-2
@@ -8,10 +8,10 @@
|
||||
|
||||
-- 1. Add columns (nullable — existing rows get NULL)
|
||||
ALTER TABLE public.journal_entries
|
||||
ADD COLUMN commit_method TEXT CHECK (commit_method IS NULL OR commit_method IN (
|
||||
ADD COLUMN IF NOT EXISTS commit_method TEXT CHECK (commit_method IS NULL OR commit_method IN (
|
||||
'user_accept', 'bulk_accept', 'timing_ceiling', 'migration', 'legacy'
|
||||
)),
|
||||
ADD COLUMN rubric_version TEXT;
|
||||
ADD COLUMN IF NOT EXISTS rubric_version TEXT;
|
||||
|
||||
-- 2. Update commit RPC to accept and set the new columns atomically
|
||||
CREATE OR REPLACE FUNCTION public.commit_journal_entry(
|
||||
@@ -0,0 +1,120 @@
|
||||
-- RPC: list journal entries for a fiscal period, optionally including
|
||||
-- follow-up entries booked in a later period that relate to aggregates
|
||||
-- that originated in the selected period.
|
||||
--
|
||||
-- Why this exists: journal_entries.fiscal_period_id is strictly bound to
|
||||
-- entry_date (validated in lib/bookkeeping/engine.ts). So a customer
|
||||
-- invoice created in FY2025 and paid in FY2026 produces two entries in
|
||||
-- two different periods, and filtering the /bookkeeping view by
|
||||
-- fiscal_period_id hides the tail of the story. Users reviewing a past
|
||||
-- fiscal year expect to see the full processing history for that year's
|
||||
-- aggregates (behandlingshistorik per BFL/BFNAR).
|
||||
--
|
||||
-- Expansion rules (when p_include_related = true):
|
||||
-- - Entries with source_type in invoice follow-ups whose invoice was
|
||||
-- dated inside the selected period.
|
||||
-- - Same for supplier invoice follow-ups.
|
||||
--
|
||||
-- Storno and correction entries inherit fiscal_period_id from their
|
||||
-- original (see lib/bookkeeping/engine.ts reverseEntry and
|
||||
-- lib/core/bookkeeping/storno-service.ts), so they are already captured
|
||||
-- by the primary fiscal_period_id filter — no extra rule needed.
|
||||
--
|
||||
-- Currency revaluation is booked within the period it revalues, also
|
||||
-- captured by the primary filter.
|
||||
--
|
||||
-- Returns jsonb rows shaped like the PostgREST response used by
|
||||
-- GET /api/bookkeeping/journal-entries (entry + nested lines), plus an
|
||||
-- out_of_period boolean the UI uses to badge tail entries.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.list_fiscal_period_entries_with_related(
|
||||
p_company_id uuid,
|
||||
p_period_id uuid,
|
||||
p_include_related boolean DEFAULT true,
|
||||
p_status text DEFAULT NULL,
|
||||
p_date_from date DEFAULT NULL,
|
||||
p_date_to date DEFAULT NULL,
|
||||
p_sort_date text DEFAULT 'desc',
|
||||
p_limit int DEFAULT 50,
|
||||
p_offset int DEFAULT 0
|
||||
)
|
||||
RETURNS TABLE (
|
||||
entry jsonb,
|
||||
total_count bigint
|
||||
)
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY INVOKER
|
||||
SET search_path = public, pg_temp
|
||||
AS $$
|
||||
WITH period AS (
|
||||
SELECT period_start, period_end
|
||||
FROM public.fiscal_periods
|
||||
WHERE id = p_period_id AND company_id = p_company_id
|
||||
),
|
||||
matching AS (
|
||||
SELECT je.*
|
||||
FROM public.journal_entries je
|
||||
CROSS JOIN period p
|
||||
WHERE je.company_id = p_company_id
|
||||
AND (
|
||||
je.fiscal_period_id = p_period_id
|
||||
OR (
|
||||
p_include_related
|
||||
AND je.source_type IN ('invoice_paid','invoice_cash_payment','credit_note')
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.invoices i
|
||||
WHERE i.id = je.source_id
|
||||
AND i.company_id = p_company_id
|
||||
AND i.invoice_date BETWEEN p.period_start AND p.period_end
|
||||
)
|
||||
)
|
||||
OR (
|
||||
p_include_related
|
||||
AND je.source_type IN ('supplier_invoice_paid','supplier_invoice_cash_payment','supplier_credit_note')
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.supplier_invoices si
|
||||
WHERE si.id = je.source_id
|
||||
AND si.company_id = p_company_id
|
||||
AND si.invoice_date BETWEEN p.period_start AND p.period_end
|
||||
)
|
||||
)
|
||||
)
|
||||
AND (p_status IS NULL OR je.status = p_status)
|
||||
AND (p_date_from IS NULL OR je.entry_date >= p_date_from)
|
||||
AND (p_date_to IS NULL OR je.entry_date <= p_date_to)
|
||||
),
|
||||
matching_with_total AS (
|
||||
SELECT m.*, COUNT(*) OVER () AS total
|
||||
FROM matching m
|
||||
),
|
||||
paged AS (
|
||||
SELECT *
|
||||
FROM matching_with_total
|
||||
ORDER BY
|
||||
CASE WHEN p_sort_date = 'asc' THEN entry_date END ASC NULLS LAST,
|
||||
CASE WHEN p_sort_date = 'desc' THEN entry_date END DESC NULLS LAST,
|
||||
voucher_series,
|
||||
voucher_number
|
||||
LIMIT p_limit OFFSET p_offset
|
||||
)
|
||||
SELECT
|
||||
(to_jsonb(p.*) - 'total')
|
||||
|| jsonb_build_object(
|
||||
'lines', COALESCE(
|
||||
(SELECT jsonb_agg(to_jsonb(l.*) ORDER BY l.sort_order)
|
||||
FROM public.journal_entry_lines l
|
||||
WHERE l.journal_entry_id = p.id),
|
||||
'[]'::jsonb
|
||||
),
|
||||
'out_of_period', (p.fiscal_period_id IS DISTINCT FROM p_period_id)
|
||||
) AS entry,
|
||||
p.total AS total_count
|
||||
FROM paged p;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.list_fiscal_period_entries_with_related(
|
||||
uuid, uuid, boolean, text, date, date, text, int, int
|
||||
) TO authenticated;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
-- Drop the legacy user_id-scoped unique constraints on supplier_invoices that
|
||||
-- the multi-tenant refactor (20260330130000) missed.
|
||||
--
|
||||
-- That refactor tried to drop constraints named
|
||||
-- supplier_invoices_user_id_arrival_number_key
|
||||
-- supplier_invoices_user_id_supplier_id_supplier_invoice_numbe_key
|
||||
-- (the auto-generated names Postgres would have picked had the original
|
||||
-- CREATE TABLE used inline UNIQUE constraints). But the 20240101000025
|
||||
-- migration named them explicitly — uq_supplier_invoices_arrival and
|
||||
-- uq_supplier_invoices_ref — so the IF EXISTS drops were no-ops and the
|
||||
-- user_id-scoped uniqueness remained in place.
|
||||
--
|
||||
-- Meanwhile get_next_arrival_number() was rewritten to scope by company_id.
|
||||
-- The mismatch blows up the moment a single user has supplier invoices in
|
||||
-- two companies: the second company's arrival_number restarts at 1 and
|
||||
-- collides with the first company's row under the user_id-scoped constraint.
|
||||
--
|
||||
-- The correct composite unique indexes — (company_id, arrival_number) and
|
||||
-- (company_id, supplier_id, supplier_invoice_number) — were added as
|
||||
-- CREATE UNIQUE INDEX IF NOT EXISTS in the 2026-03-30 refactor and are
|
||||
-- already in place, so dropping the legacy constraints is all that's
|
||||
-- needed.
|
||||
|
||||
ALTER TABLE public.supplier_invoices
|
||||
DROP CONSTRAINT IF EXISTS uq_supplier_invoices_arrival,
|
||||
DROP CONSTRAINT IF EXISTS uq_supplier_invoices_ref;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -982,6 +982,10 @@ export interface JournalEntry {
|
||||
updated_at: string
|
||||
// Relations
|
||||
lines?: JournalEntryLine[]
|
||||
// Set by list_fiscal_period_entries_with_related when the entry was
|
||||
// returned as a follow-up from a different fiscal period than the one
|
||||
// being viewed. Absent from plain PostgREST responses.
|
||||
out_of_period?: boolean
|
||||
}
|
||||
|
||||
// Journal Entry Line
|
||||
|
||||
Reference in New Issue
Block a user