d0fbc2b616
* refactor(ui): app-wide UI/UX consistency pass
Net: +1,159 / −1,373 LOC across 77 files. No new features, no behavior
changes. Locks in a uniform design system across every dashboard surface.
What changed:
- **Foundation**: sidebar width 232→256px (md:w-64), spacing scale locked
(Tailwind 1/2/3/4/6/8/10/12; 2.5/5 forbidden), card padding p-6 default
(p-4 for compact metric cards), space-y-8 between page sections.
- **Tables unified**: all 33 thead blocks now share the Resultatrapport
pattern via shadcn Table primitive (text-[11px] font-medium uppercase
tracking-wider text-muted-foreground). Hand-rolled <table> instances
converted where they were data tables; form/edit grids kept distinct.
- **Status badges unified**: every status indicator routes through
shadcn <Badge variant>. Eliminated raw Tailwind colors
(bg-amber-100, bg-emerald-500/10, bg-blue-100, bg-purple-100, etc.)
in favor of the gnubok semantic palette (success=sage, warning=ochre,
destructive=terracotta).
- **Empty states unified**: list pages migrated from hand-rolled
"flex flex-col items-center py-12" divs to the EmptyState primitive.
- **Loading skeletons unified**: hand-rolled bg-muted rounded animate-pulse
divs replaced with shadcn <Skeleton> across 15 files.
- **Touch targets**: 6 back-buttons + edit-pencil + inbox delete bumped
from 24/32/36px to shadcn's 40px icon default. Added aria-labels on
9 icon-only navigation buttons.
- **Date formatting**: formatDate() for accounting data (ISO yyyy-MM-dd,
table-friendly) vs formatDateLong() for metadata (Swedish long form).
Raw {x.invoice_date} renderings routed through formatDate() in 18 sites.
- **Toast titles**: eliminated 33 generic "Fel" titles. Each toast title
now carries the action ("Kunde inte skapa lönekörning" etc.) with
description carrying the error detail.
- **Page-level cleanups**:
- Dashboard: dropped greeting hero + Snabbåtgärder/Att hantera nav
duplicates + Visa detaljer collapsible.
- Reports: 5-col mega-menu replaced with left-rail layout
(new ReportsNav component).
- Bookkeeping: fixed layout jump between Verifikationer/Ny verifikation
tabs (moved FiscalYearSelector inside journal tab).
- Bookkeeping: added voucher sort (A1 first / latest first) alongside
existing date sort. Required matching API param sort_by.
- KPI page: FiscalYearSelector instead of raw <select>; InfoTooltip
instead of inline info-button toggle; bigger numbers.
- Salary section: enum values translated to Swedish labels, mobile
table collapses to Anställd+Netto on <md, KPI typography aligned
with dashboard.
- Invoice forms: styled RequiredMark + aria-required, tabular-nums
on amount inputs.
- **CLAUDE.md**: new "Design System Tokens" subsection documents the
locked spacing scale, primitives table, typography rules, date helpers,
and forbidden patterns so future contributors don't drift.
Tests: 2,906 passing (unchanged). Lint: unchanged from main baseline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address PR review feedback (Greptile + compliance bot)
- **formatDate / formatDateLong timezone fix**: switch from new Date() to
parseISO. Bare yyyy-MM-dd strings are now parsed as local midnight rather
than UTC midnight, eliminating the off-by-one display in west-of-UTC
timezones flagged by Greptile.
- **DashboardContentProps cleanup**: removed unused firstName and settings
fields from the interface, and the corresponding fetch (profiles table)
+ computation in app/(dashboard)/page.tsx. The greeting was dropped in
the dashboard cleanup; these props were dead weight.
- **Voucher sort behavior documented**: extended the comment in the journal
entries API route to explain why voucher sort intentionally uses strict
fiscal_period_id filtering (BFL 5 kap 6–7 §§ — voucher numbers are
series-scoped within a fiscal year). The row-count delta between date
sort and voucher sort is now a documented design choice.
- **delete_last_voucher migration + draft-delete test included**: the UI
already shipped the "Radera utkast" path in the previous commit; this
pulls in the backing RPC migration that allows draft deletes (with the
full safety logic — drafts skip series/period checks since they have
voucher_number=0, posted entries go through the existing unchanged
path). This was originally meant for a separate PR but the UI shipped
half the feature without it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(migration): rename to match applied version
The function delete_last_voucher is already applied to the production DB
under version 20260509103736 (verified via pg_get_functiondef — exact
byte-for-byte match to file content). The previous file timestamp
20260509120000 would cause a fresh `supabase db push` to attempt re-applying
under a different version row. Renaming the file aligns local tracking
with what the database actually has.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address compliance bot findings (payroll label + VAT visibility)
- sick_karens label: drop "(första sjukdagen)" qualifier. Per sjuklönelagen
6 §, karensavdrag is a single calculated amount (20% of one week's
sjuklön) deducted from the first sick day's pay — not bounded to the
first day. The qualifier could mislead users when the first sick day
and return-to-work span a weekend. Swedish-payroll bot recommendation.
- Omvänd skattskyldighet badge: variant outline → warning. The reverse-
charge indicator is compliance-critical (ML 16 kap) — missing it leads
to incorrect input VAT deduction. Outline was too subtle; warning's
ochre fill matches its semantic weight.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
187 lines
5.8 KiB
PL/PgSQL
187 lines
5.8 KiB
PL/PgSQL
-- Allow delete_last_voucher RPC to delete draft entries.
|
|
--
|
|
-- Background: createJournalEntry does createDraft + commit atomically. If
|
|
-- commit fails the orphan draft is now cancelled (commit 28df5d85), but
|
|
-- legacy drafts created before that fix remain stuck — the previous RPC
|
|
-- only accepted status='posted'. There is no UI path to remove them.
|
|
--
|
|
-- Drafts are not part of the verifikationsserie (voucher_number = 0,
|
|
-- never assigned a sequence number) and are not bokförda under BFL.
|
|
-- Deletion of a draft does not affect the audit trail and does not
|
|
-- require the same protections as posted entries.
|
|
--
|
|
-- For drafts we skip:
|
|
-- * last-in-series check (drafts have no number)
|
|
-- * voucher_sequences row update (no number was issued)
|
|
-- * reverses_id un-reversal (drafts cannot be reversed)
|
|
-- * fiscal-period closed/locked check (drafts are not posted)
|
|
--
|
|
-- We keep:
|
|
-- * owner/admin role gate
|
|
-- * audit_log entry
|
|
-- * gnubok.allow_delete flag so the line-immutability trigger lets
|
|
-- the cascade delete its rows
|
|
--
|
|
-- For posted entries the existing logic is unchanged.
|
|
|
|
CREATE OR REPLACE FUNCTION public.delete_last_voucher(p_company_id uuid, p_entry_id uuid)
|
|
RETURNS jsonb
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path TO 'public'
|
|
AS $function$
|
|
DECLARE
|
|
v_entry record;
|
|
v_period record;
|
|
v_max_voucher integer;
|
|
v_ref_count integer;
|
|
v_caller_role text;
|
|
v_snapshot jsonb;
|
|
v_lines_snapshot jsonb;
|
|
BEGIN
|
|
SELECT cm.role INTO v_caller_role
|
|
FROM company_members cm
|
|
WHERE cm.company_id = p_company_id
|
|
AND cm.user_id = auth.uid();
|
|
|
|
IF v_caller_role IS NULL OR v_caller_role NOT IN ('owner', 'admin') THEN
|
|
RAISE EXCEPTION 'Only company owners and admins can delete vouchers';
|
|
END IF;
|
|
|
|
SELECT * INTO v_entry
|
|
FROM journal_entries
|
|
WHERE id = p_entry_id
|
|
AND company_id = p_company_id
|
|
FOR UPDATE;
|
|
|
|
IF v_entry IS NULL THEN
|
|
RAISE EXCEPTION 'Journal entry not found';
|
|
END IF;
|
|
|
|
IF v_entry.status NOT IN ('posted', 'draft') THEN
|
|
RAISE EXCEPTION 'Only posted or draft entries can be deleted (current status: %)', v_entry.status;
|
|
END IF;
|
|
|
|
-- Snapshot for audit log (same shape for draft and posted)
|
|
SELECT jsonb_agg(to_jsonb(l)) INTO v_lines_snapshot
|
|
FROM journal_entry_lines l
|
|
WHERE l.journal_entry_id = p_entry_id;
|
|
|
|
v_snapshot := to_jsonb(v_entry) || jsonb_build_object('lines', COALESCE(v_lines_snapshot, '[]'::jsonb));
|
|
|
|
-- Draft path: simplified deletion (no series, no period checks needed)
|
|
IF v_entry.status = 'draft' THEN
|
|
PERFORM set_config('gnubok.allow_delete', 'true', true);
|
|
|
|
UPDATE document_attachments
|
|
SET journal_entry_id = NULL
|
|
WHERE journal_entry_id = p_entry_id;
|
|
|
|
DELETE FROM journal_entries WHERE id = p_entry_id;
|
|
|
|
INSERT INTO audit_log (user_id, action, table_name, record_id, actor_id, old_state, description)
|
|
VALUES (
|
|
v_entry.user_id,
|
|
'DELETE',
|
|
'journal_entries',
|
|
p_entry_id,
|
|
auth.uid(),
|
|
v_snapshot,
|
|
'Deleted draft journal entry (delete_last_voucher RPC, caller: ' || auth.uid() || ')'
|
|
);
|
|
|
|
RETURN jsonb_build_object(
|
|
'deleted', true,
|
|
'voucher_series', v_entry.voucher_series,
|
|
'voucher_number', v_entry.voucher_number,
|
|
'was_draft', true
|
|
);
|
|
END IF;
|
|
|
|
-- Posted path: existing logic unchanged
|
|
SELECT * INTO v_period
|
|
FROM fiscal_periods
|
|
WHERE id = v_entry.fiscal_period_id
|
|
FOR UPDATE;
|
|
|
|
IF v_period.is_closed THEN
|
|
RAISE EXCEPTION 'Cannot delete voucher in a closed fiscal period';
|
|
END IF;
|
|
|
|
IF v_period.locked_at IS NOT NULL THEN
|
|
RAISE EXCEPTION 'Cannot delete voucher in a locked fiscal period';
|
|
END IF;
|
|
|
|
PERFORM 1 FROM voucher_sequences
|
|
WHERE company_id = p_company_id
|
|
AND fiscal_period_id = v_entry.fiscal_period_id
|
|
AND voucher_series = v_entry.voucher_series
|
|
FOR UPDATE;
|
|
|
|
SELECT MAX(voucher_number) INTO v_max_voucher
|
|
FROM journal_entries
|
|
WHERE company_id = p_company_id
|
|
AND fiscal_period_id = v_entry.fiscal_period_id
|
|
AND voucher_series = v_entry.voucher_series
|
|
AND status NOT IN ('cancelled', 'draft');
|
|
|
|
IF v_entry.voucher_number != v_max_voucher THEN
|
|
RAISE EXCEPTION 'Kan bara radera det sista verifikatet i serien. % har nummer % men senaste är %',
|
|
v_entry.voucher_series, v_entry.voucher_number, v_max_voucher;
|
|
END IF;
|
|
|
|
SELECT COUNT(*) INTO v_ref_count
|
|
FROM journal_entries
|
|
WHERE company_id = p_company_id
|
|
AND status != 'cancelled'
|
|
AND (reverses_id = p_entry_id OR correction_of_id = p_entry_id);
|
|
|
|
IF v_ref_count > 0 THEN
|
|
RAISE EXCEPTION 'Cannot delete: other entries reference this voucher (% references)',
|
|
v_ref_count;
|
|
END IF;
|
|
|
|
IF v_entry.reverses_id IS NOT NULL THEN
|
|
PERFORM set_config('gnubok.allow_delete', 'true', true);
|
|
UPDATE journal_entries
|
|
SET status = 'posted', reversed_by_id = NULL
|
|
WHERE id = v_entry.reverses_id
|
|
AND company_id = p_company_id;
|
|
END IF;
|
|
|
|
PERFORM set_config('gnubok.allow_delete', 'true', true);
|
|
|
|
UPDATE document_attachments
|
|
SET journal_entry_id = NULL
|
|
WHERE journal_entry_id = p_entry_id;
|
|
|
|
DELETE FROM journal_entries WHERE id = p_entry_id;
|
|
|
|
UPDATE voucher_sequences
|
|
SET last_number = GREATEST(last_number - 1, 0)
|
|
WHERE company_id = p_company_id
|
|
AND fiscal_period_id = v_entry.fiscal_period_id
|
|
AND voucher_series = v_entry.voucher_series;
|
|
|
|
INSERT INTO audit_log (user_id, action, table_name, record_id, actor_id, old_state, description)
|
|
VALUES (
|
|
v_entry.user_id,
|
|
'DELETE',
|
|
'journal_entries',
|
|
p_entry_id,
|
|
auth.uid(),
|
|
v_snapshot,
|
|
'Deleted voucher ' || v_entry.voucher_series || v_entry.voucher_number ||
|
|
' (delete_last_voucher RPC, caller: ' || auth.uid() || ')'
|
|
);
|
|
|
|
RETURN jsonb_build_object(
|
|
'deleted', true,
|
|
'voucher_series', v_entry.voucher_series,
|
|
'voucher_number', v_entry.voucher_number
|
|
);
|
|
END;
|
|
$function$;
|
|
|
|
NOTIFY pgrst, 'reload schema';
|