diff --git a/CLAUDE.md b/CLAUDE.md index db6a535c..03593523 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -273,6 +273,7 @@ export async function POST(request: Request) { | `bankgiro/` | Luhn checksum validation | | `calendar/` | ICS generator, calendar utilities | | `errors/` | Swedish error message mapping (Zod → Postgres → HTTP → fallback) | +| `rate-limits/` | Per-company Postgres-backed rate limiter (`checkInboxUploadRateLimit`) — used by inbox upload + email-inbound + retry-extraction. Calls `check_and_increment_inbox_quota` RPC; fails open on infra error. | | `hooks/` | React hooks (e.g., `use-unsaved-changes`, `use-can-write`) | | `logger.ts` | Structured logger with module prefixes, env-aware filtering | | `support.ts` | Server-side support recipient email (used by `/api/support/contact`) | diff --git a/app/(dashboard)/kpi/page.tsx b/app/(dashboard)/kpi/page.tsx index 7e55fb05..b642a36c 100644 --- a/app/(dashboard)/kpi/page.tsx +++ b/app/(dashboard)/kpi/page.tsx @@ -6,6 +6,8 @@ import { Skeleton } from "@/components/ui/skeleton" import { FiscalYearSelector } from '@/components/common/FiscalYearSelector' import { KPIHeroCards } from '@/components/kpi/KPIHeroCards' import { KPITrendChart } from '@/components/kpi/KPITrendChart' +import { KPIExpenseMixChart } from '@/components/kpi/KPIExpenseMixChart' +import { KPITopSuppliersChart } from '@/components/kpi/KPITopSuppliersChart' import { KPISettingsDialog } from '@/components/kpi/KPISettingsDialog' import { getDefaultPreferences } from '@/lib/reports/kpi-definitions' import type { KPIReport, KPIPreferences } from '@/types' @@ -107,6 +109,10 @@ export default function KpiPage() { <> {report.months.length > 0 && } +
+ + +
)} @@ -133,6 +139,16 @@ function LoadingSkeleton() { +
+ {[1, 2].map((i) => ( + + + + + + + ))} +
) } diff --git a/app/(dashboard)/supplier-invoices/new/page.tsx b/app/(dashboard)/supplier-invoices/new/page.tsx index 730b4676..e2bbba03 100644 --- a/app/(dashboard)/supplier-invoices/new/page.tsx +++ b/app/(dashboard)/supplier-invoices/new/page.tsx @@ -17,6 +17,7 @@ import { SupplierInvoiceReviewContent } from '@/components/suppliers/SupplierInv import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions' import { getErrorMessage } from '@/lib/errors/get-error-message' +import { cn, formatCurrency } from '@/lib/utils' import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes' import { useCanWrite } from '@/lib/hooks/use-can-write' import BankTransactionPicker from '@/components/transactions/BankTransactionPicker' @@ -106,6 +107,7 @@ export default function NewSupplierInvoicePage() { const { toast } = useToast() const [suppliers, setSuppliers] = useState([]) + const [suppliersLoaded, setSuppliersLoaded] = useState(false) const [accounts, setAccounts] = useState([]) const [entityType, setEntityType] = useState('enskild_firma') const [isSubmitting, setIsSubmitting] = useState(false) @@ -174,8 +176,11 @@ export default function NewSupplierInvoicePage() { // One-shot: load inbox item and prefill form. Runs after suppliers are // loaded so we can resolve matched_supplier_id to a real picker value. + // Gate on `suppliersLoaded`, not `suppliers.length > 0` — otherwise the + // effect never fires for users who haven't booked a supplier yet and + // the "Laddar uppgifter från inkorgen…" spinner sticks forever. useEffect(() => { - if (!inboxItemId || hasPrefilled || suppliers.length === 0) return + if (!inboxItemId || hasPrefilled || !suppliersLoaded) return let cancelled = false ;(async () => { @@ -266,7 +271,7 @@ export default function NewSupplierInvoicePage() { cancelled = true } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [inboxItemId, suppliers]) + }, [inboxItemId, suppliersLoaded, suppliers]) // Auto-fill due date and defaults when supplier is selected — but never // overwrite a value the AI already filled in for us. @@ -298,6 +303,50 @@ export default function NewSupplierInvoicePage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [watchedSupplierId, suppliers]) + // Auto-fetch Riksbanken exchange rate when currency switches to non-SEK and + // the user hasn't typed a custom rate yet. Re-fetches when the invoice + // date changes too. Never overwrites a user-entered rate. + const watchedInvoiceDate = watch('invoice_date') + // The "user has manually edited the rate" flag is scoped *per currency*. + // Switching from EUR (rate 11.8 edited by hand) to USD must re-fetch — the + // EUR rate is meaningless for a USD invoice. Tracking last-fetched currency + // lets us reset the touched flag on a currency switch while still honoring + // a manual edit when only the invoice date changes within the same currency. + const userTouchedRateRef = useRef(false) + const lastFxCurrencyRef = useRef(null) + useEffect(() => { + if (watchedCurrency === 'SEK') { + setValue('exchange_rate', '') + userTouchedRateRef.current = false + lastFxCurrencyRef.current = null + return + } + if (lastFxCurrencyRef.current !== watchedCurrency) { + // Currency switched — drop the previous currency's manual-edit flag. + userTouchedRateRef.current = false + lastFxCurrencyRef.current = watchedCurrency + } + if (userTouchedRateRef.current) return + let cancelled = false + ;(async () => { + try { + const url = `/api/currency/rate?currency=${watchedCurrency}${ + watchedInvoiceDate ? `&date=${watchedInvoiceDate}` : '' + }` + const res = await fetch(url) + if (!res.ok) return + const { data } = await res.json() + if (cancelled || !data?.rate) return + // Don't clobber a value the user typed while we were fetching. + if (userTouchedRateRef.current) return + setValue('exchange_rate', String(Math.round(data.rate * 10000) / 10000)) + } catch { + // Non-critical — user can type the rate manually. + } + })() + return () => { cancelled = true } + }, [watchedCurrency, watchedInvoiceDate, setValue]) + // Auto-select newly created supplier once it shows up in the list useEffect(() => { if (pendingSupplierSelect && suppliers.find((s) => s.id === pendingSupplierSelect)) { @@ -307,9 +356,13 @@ export default function NewSupplierInvoicePage() { }, [suppliers, pendingSupplierSelect, setValue]) async function fetchSuppliers() { - const res = await fetch('/api/suppliers') - const { data } = await res.json() - setSuppliers(data || []) + try { + const res = await fetch('/api/suppliers') + const { data } = await res.json() + setSuppliers(data || []) + } finally { + setSuppliersLoaded(true) + } } async function fetchAccounts() { @@ -546,6 +599,9 @@ export default function NewSupplierInvoicePage() { // Auto-approve for EF const approveRes = await fetch(`/api/supplier-invoices/${result.data.id}/approve`, { method: 'POST' }) + // Clear dirty state so useUnsavedChanges doesn't fire the + // beforeunload prompt while we navigate away on a successful submit. + reset(data) if (!approveRes.ok) { toast({ title: 'Varning', @@ -572,6 +628,8 @@ export default function NewSupplierInvoicePage() { const invoiceId = result.data.id const arrivalNumber = result.data.arrival_number setShowReview(false) + // Clear dirty state — see comment in handleDirectSubmit. + reset(pendingData) if (pendingTransactionId) { const matchRes = await fetch(`/api/transactions/${pendingTransactionId}/match-supplier-invoice`, { @@ -663,6 +721,7 @@ export default function NewSupplierInvoicePage() { title: 'Kreditering ångrad och faktura registrerad', description: `Ankomstnummer: ${result.data.arrival_number}`, }) + reset(pendingData) router.push(`/supplier-invoices/${result.data.id}`) return } @@ -739,6 +798,7 @@ export default function NewSupplierInvoicePage() { variant: 'destructive', }) } + reset(pendingData) router.push(`/supplier-invoices/${invoiceId}`) } @@ -880,6 +940,75 @@ export default function NewSupplierInvoicePage() { + {/* Valuta & moms — kept inline with the line items because they + drive how each row is interpreted. Hidden defaults (SEK + + normal moms) collapse to nothing so most users don't see this. */} +
+
+ + ( + + )} + /> +
+ {watchedCurrency !== 'SEK' && ( +
+ + { userTouchedRateRef.current = true }, + })} + /> +
+ )} +
+ ( + + )} + /> + +
+
+ {/* Desktop table */}
@@ -1032,7 +1161,7 @@ export default function NewSupplierInvoicePage() {
Moms - {formatAmount(itemTotals[index]?.vatAmount || 0)} kr + {formatCurrency(itemTotals[index]?.vatAmount || 0, watchedCurrency)}
))} @@ -1064,15 +1193,15 @@ export default function NewSupplierInvoicePage() {
Netto (exkl. moms) - {formatAmount(subtotal)} kr + {formatCurrency(subtotal, watchedCurrency)}
Moms - {formatAmount(totalVat)} kr + {formatCurrency(totalVat, watchedCurrency)}
Totalt - {formatAmount(total)} kr + {formatCurrency(total, watchedCurrency)}
@@ -1091,50 +1220,10 @@ export default function NewSupplierInvoicePage() { {advancedOpen && ( -
-
- - ( - - )} - /> -
- {watchedCurrency !== 'SEK' && ( -
- - -
- )} -
-
- ( - - )} - /> - -