chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n namespaces and 4 unused dependencies; fold byte-identical helper copies into one canonical home each (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body + v1ValidationError rolled out to ~55 v1 routes, booking-template schemas). No behaviour change: v1 bodies and status codes, MCP tool schemas, DB writes and money math are untouched. Naive ore rounding was deliberately not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list of things left alone on purpose. tsc, lint, 19588 unit tests and check:guards green; antipattern baseline ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(transactions): import RawTransaction from @/types after the ingest re-export removal CI's type ratchet (check:types, full tsconfig) caught the one test file that still imported the type through lib/transactions/ingest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
18cbc4c30a
commit
f266c386f3
@@ -6,7 +6,7 @@ import { RotateCw } from 'lucide-react'
|
||||
import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
|
||||
import { useBasReference } from '@/lib/bookkeeping/use-bas-reference'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { formatAmount, formatCurrency } from '@/lib/utils'
|
||||
import type { DeepEntity, DeepLedgerContext } from '@/lib/agent-context/ledger-deep'
|
||||
import { entityMagnitude, selectAccountRing, type RingBasis } from './ledger-graph-magnitude'
|
||||
|
||||
@@ -751,7 +751,7 @@ function DetailCard({ p, t }: { p: Payee; t: ReturnType<typeof useTranslations>
|
||||
<div className="flex justify-between gap-3">
|
||||
<span>{t('card_amount')}</span>
|
||||
<span className="tabular-nums" style={{ color: PAPER }}>
|
||||
{e.total_amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
{formatAmount(e.total_amount)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 border-t pt-2" style={{ borderColor: HAIR }}>
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { guardBrowserWrite } from '@/lib/company/tab-guard'
|
||||
import { useAccounts } from '@/lib/reference-data/hooks'
|
||||
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ChevronDown, ChevronRight, Search } from 'lucide-react'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import type { BASAccount } from '@/types'
|
||||
|
||||
const CLASS_LABELS: Record<number, string> = {
|
||||
1: 'Tillgångar',
|
||||
2: 'Eget kapital och skulder',
|
||||
3: 'Intäkter',
|
||||
4: 'Varor och material',
|
||||
5: 'Övriga externa kostnader',
|
||||
6: 'Övriga externa kostnader',
|
||||
7: 'Personal och avskrivningar',
|
||||
8: 'Finansiella poster',
|
||||
}
|
||||
|
||||
export default function ChartOfAccounts() {
|
||||
// Session-cached chart (lib/reference-data), shared with every picker.
|
||||
const { accounts, isLoading: loading } = useAccounts()
|
||||
const [expandedClasses, setExpandedClasses] = useState<Set<number>>(new Set())
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [editingSRU, setEditingSRU] = useState<string | null>(null)
|
||||
const [sruValue, setSruValue] = useState('')
|
||||
|
||||
async function updateSRUCode(accountId: string, newSruCode: string) {
|
||||
// Cross-tab guard (WL-09): browser-direct Supabase write, outside the
|
||||
// patched-fetch seam. The blocking dialog is the user feedback.
|
||||
if (!guardBrowserWrite()) return
|
||||
const supabase = createClient()
|
||||
const trimmed = newSruCode.trim() || null
|
||||
await supabase
|
||||
.from('chart_of_accounts')
|
||||
.update({ sru_code: trimmed })
|
||||
.eq('id', accountId)
|
||||
|
||||
// Refresh the shared chart so this list and every picker show the code.
|
||||
await invalidateReferenceData('ref:accounts')
|
||||
setEditingSRU(null)
|
||||
}
|
||||
|
||||
const toggleClass = (cls: number) => {
|
||||
const next = new Set(expandedClasses)
|
||||
if (next.has(cls)) {
|
||||
next.delete(cls)
|
||||
} else {
|
||||
next.add(cls)
|
||||
}
|
||||
setExpandedClasses(next)
|
||||
}
|
||||
|
||||
const filteredAccounts = accounts.filter((a) => {
|
||||
if (!searchQuery) return true
|
||||
const q = searchQuery.toLowerCase()
|
||||
return (
|
||||
a.account_number.includes(q) ||
|
||||
a.account_name.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
const groupedByClass = filteredAccounts.reduce(
|
||||
(acc, account) => {
|
||||
const cls = account.account_class
|
||||
if (!acc[cls]) acc[cls] = []
|
||||
acc[cls].push(account)
|
||||
return acc
|
||||
},
|
||||
{} as Record<number, BASAccount[]>
|
||||
)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Laddar kontoplan...
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Sök konto (nummer eller namn)..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{Object.entries(groupedByClass)
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.map(([cls, classAccounts]) => {
|
||||
const classNum = Number(cls)
|
||||
const isExpanded = expandedClasses.has(classNum) || !!searchQuery
|
||||
|
||||
return (
|
||||
<Card key={cls}>
|
||||
<button
|
||||
onClick={() => toggleClass(classNum)}
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
<span className="font-semibold">
|
||||
Klass {cls}: {CLASS_LABELS[classNum] || ''}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">{classAccounts.length} konton</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<CardContent className="pt-0 pb-4">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||||
<tr className="border-b text-left">
|
||||
<th className="py-2 w-24">Konto</th>
|
||||
<th className="py-2">Namn</th>
|
||||
<th className="py-2 w-20 text-center">SRU</th>
|
||||
<th className="py-2 w-24 text-center">Typ</th>
|
||||
<th className="py-2 w-24 text-center">Normal</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{classAccounts.map((account) => (
|
||||
<tr
|
||||
key={account.id}
|
||||
className={`border-b last:border-0 ${
|
||||
!account.is_active ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="py-2"><AccountNumber number={account.account_number} name={account.account_name} /></td>
|
||||
<td className="py-2">{account.account_name}</td>
|
||||
<td className="py-2 text-center">
|
||||
{editingSRU === account.id ? (
|
||||
<Input
|
||||
value={sruValue}
|
||||
onChange={(e) => setSruValue(e.target.value)}
|
||||
onBlur={() => updateSRUCode(account.id, sruValue)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') updateSRUCode(account.id, sruValue)
|
||||
if (e.key === 'Escape') setEditingSRU(null)
|
||||
}}
|
||||
className="h-7 w-16 text-xs text-center px-1"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
className="text-xs font-mono text-muted-foreground hover:text-foreground cursor-pointer min-w-[2rem]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditingSRU(account.id)
|
||||
setSruValue(account.sru_code || '')
|
||||
}}
|
||||
>
|
||||
{account.sru_code || '-'}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-center text-xs text-muted-foreground">
|
||||
{account.account_type === 'asset'
|
||||
? 'Tillgång'
|
||||
: account.account_type === 'liability'
|
||||
? 'Skuld'
|
||||
: account.account_type === 'equity'
|
||||
? 'EK'
|
||||
: account.account_type === 'revenue'
|
||||
? 'Intäkt'
|
||||
: 'Kostnad'}
|
||||
</td>
|
||||
<td className="py-2 text-center text-xs text-muted-foreground">
|
||||
{account.normal_balance === 'debit' ? 'Debet' : 'Kredit'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { UUID_RE } from '@/lib/invariants/uuid'
|
||||
import { Eye } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
@@ -11,7 +12,6 @@ interface DocumentViewButtonProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
/**
|
||||
* Opens a document in the browser through the same-origin inline proxy
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, type ReactNode } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { CalendarDays } from 'lucide-react'
|
||||
import {
|
||||
monthsBetween,
|
||||
parseDateParts,
|
||||
@@ -11,16 +7,6 @@ import {
|
||||
} from '@/lib/bookkeeping/validate-period-duration'
|
||||
import type { EntityType } from '@/types'
|
||||
|
||||
const monthNames = [
|
||||
'januari', 'februari', 'mars', 'april', 'maj', 'juni',
|
||||
'juli', 'augusti', 'september', 'oktober', 'november', 'december',
|
||||
]
|
||||
|
||||
function formatSwedishDate(dateStr: string): string {
|
||||
const { year, month, day } = parseDateParts(dateStr)
|
||||
return `${day} ${monthNames[month - 1]} ${year}`
|
||||
}
|
||||
|
||||
function endsOnDec31(end: string): boolean {
|
||||
const e = parseDateParts(end)
|
||||
return e.month === 12 && e.day === 31
|
||||
@@ -47,8 +33,8 @@ export interface FiscalPeriodValidation {
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared validation for the first fiscal period: used by both onboarding Step 3
|
||||
* and the settings FiscalPeriodEditor. Returns Swedish error copy.
|
||||
* Validation for the first fiscal period, used by the settings
|
||||
* FiscalPeriodEditor. Returns Swedish error copy.
|
||||
*/
|
||||
export function validateFirstPeriod(
|
||||
startDate: string,
|
||||
@@ -89,91 +75,3 @@ export function validateFirstPeriod(
|
||||
canSummarise: true,
|
||||
}
|
||||
}
|
||||
|
||||
interface FiscalPeriodDateFieldsProps {
|
||||
startDate: string
|
||||
onStartDateChange: (value: string) => void
|
||||
startHelpText?: string
|
||||
/**
|
||||
* Render the end-date control. Onboarding passes its AB end-month <Select>
|
||||
* + computed-option <Select>; settings passes a native <input type="date">.
|
||||
*/
|
||||
endDateSlot: ReactNode
|
||||
/** The raw end-date string (used for summary + validation). */
|
||||
endDate: string
|
||||
entityType: EntityType | undefined
|
||||
/** Label for the summary card. Defaults to "Ditt första räkenskapsår". */
|
||||
summaryTitle?: string
|
||||
/** Optional override for start date <Label> content. */
|
||||
startLabel?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared first-fiscal-period date entry: day-level start (native date input),
|
||||
* caller-provided end-date control, and a Swedish summary card with inline
|
||||
* validation errors (max 18 months, EF calendar-year rule, last-day-of-month).
|
||||
*
|
||||
* Used by onboarding Step 3 and the settings FiscalPeriodEditor so the two
|
||||
* screens stay in lockstep.
|
||||
*/
|
||||
export function FiscalPeriodDateFields({
|
||||
startDate,
|
||||
onStartDateChange,
|
||||
startHelpText = 'Första räkenskapsåret kan börja valfri dag.',
|
||||
endDateSlot,
|
||||
endDate,
|
||||
entityType,
|
||||
summaryTitle = 'Ditt första räkenskapsår',
|
||||
startLabel = 'Startdatum',
|
||||
}: FiscalPeriodDateFieldsProps) {
|
||||
const validation = useMemo(
|
||||
() => validateFirstPeriod(startDate, endDate, entityType),
|
||||
[startDate, endDate, entityType],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fiscal-period-start">{startLabel}</Label>
|
||||
<Input
|
||||
id="fiscal-period-start"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => onStartDateChange(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{startHelpText}</p>
|
||||
</div>
|
||||
|
||||
{endDateSlot}
|
||||
|
||||
{validation.canSummarise && (
|
||||
<div className="rounded-lg border border-primary/20 bg-primary/5 p-4 space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<CalendarDays className="h-4 w-4 text-primary" />
|
||||
{summaryTitle}
|
||||
</div>
|
||||
{startDate && endDate && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatSwedishDate(startDate)}: {formatSwedishDate(endDate)}
|
||||
</p>
|
||||
)}
|
||||
{validation.months !== null && (
|
||||
<p
|
||||
className={`text-xs ${validation.error ? 'text-destructive' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{validation.months} månader
|
||||
{validation.error ? `: ${validation.error}` : ''}
|
||||
</p>
|
||||
)}
|
||||
{validation.error && validation.months === null && (
|
||||
<p className="text-xs text-destructive">{validation.error}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{validation.error && !validation.canSummarise && (
|
||||
<p className="text-xs text-destructive">{validation.error}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { CheckCircle2, Paperclip } from 'lucide-react'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { formatAmount, formatDate } from '@/lib/utils'
|
||||
|
||||
interface ReviewLine {
|
||||
account_number: string
|
||||
@@ -26,10 +26,6 @@ interface JournalEntryReviewContentProps {
|
||||
hideDate?: boolean
|
||||
}
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
export function JournalEntryReviewContent({
|
||||
periodName,
|
||||
entryDate,
|
||||
|
||||
@@ -28,6 +28,10 @@ import {
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import type { Asset } from '@/types'
|
||||
import type {
|
||||
TaxDepreciationMethod as TaxMethod,
|
||||
TaxDepreciationRule as TaxRule,
|
||||
} from '@/lib/bokslut/assets/tax-depreciation'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
interface ProposalItem {
|
||||
@@ -45,9 +49,6 @@ interface Proposal {
|
||||
totalAmount: number
|
||||
}
|
||||
|
||||
type TaxMethod = 'rakenskapsenlig' | 'restvarde'
|
||||
type TaxRule = 'huvudregel_30' | 'kompletteringsregel_20'
|
||||
|
||||
interface TaxAlternative {
|
||||
rule: TaxRule | 'restvarde_25'
|
||||
rate: number | null
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useCashAccounts } from '@/lib/reference-data/hooks'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'Accounted:cash-account:'
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Current selection: a BAS ledger account number ('1930', '1932', …).
|
||||
* `null` would only be meaningful if "all accounts" were an option, which
|
||||
* isn't currently supported (reconciliation is always single-account).
|
||||
*/
|
||||
value: string
|
||||
onChange: (accountNumber: string) => void
|
||||
/**
|
||||
* Optional label above the select. Pass null to render without a label.
|
||||
*/
|
||||
label?: string | null
|
||||
/**
|
||||
* Called once after the initial fetch completes so callers can suppress a
|
||||
* skeleton until the selector is ready.
|
||||
*/
|
||||
onReady?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Cash account selector for reconciliation, drift, and any UI that scopes a
|
||||
* read to a particular settlement account (1930 SEK, 1932 EUR, …).
|
||||
*
|
||||
* Loads /api/cash-accounts for the active company, persists the last selection
|
||||
* per company in sessionStorage, and renders the same Select primitive as the
|
||||
* fiscal-year picker so the UX stays consistent.
|
||||
*
|
||||
* sessionStorage (not localStorage) so the selection clears when the tab/
|
||||
* session ends. The data is a UI preference, not a credential; persisting
|
||||
* which BAS account a company uses across sessions in browser storage would
|
||||
* couple company id + financial account reference for the lifetime of the
|
||||
* browser profile (GDPR Art. 25(2) data minimisation, ISO 27001 A.8.12).
|
||||
*/
|
||||
export function CashAccountSelector({
|
||||
value,
|
||||
onChange,
|
||||
label = 'Konto',
|
||||
onReady,
|
||||
className,
|
||||
}: Props) {
|
||||
const { company } = useCompany()
|
||||
// Session-cached and seeded by the dashboard layout (lib/reference-data):
|
||||
// on a normal visit the list is here on the first render, so the restore
|
||||
// below runs in the first effect tick and onReady fires without a round
|
||||
// trip. Restore once per company load, not on every background refresh.
|
||||
const { cashAccounts: accounts, isLoading } = useCashAccounts()
|
||||
const loaded = !isLoading
|
||||
const restoredForRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!company?.id) {
|
||||
onReady?.()
|
||||
return
|
||||
}
|
||||
if (!loaded || restoredForRef.current === company.id) return
|
||||
restoredForRef.current = company.id
|
||||
|
||||
// Restore last selection or pick the primary as default.
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = window.sessionStorage.getItem(STORAGE_KEY_PREFIX + company.id)
|
||||
const inFetched = (ledger: string) =>
|
||||
accounts.some(a => a.ledger_account === ledger)
|
||||
|
||||
if (stored && inFetched(stored)) {
|
||||
if (stored !== value) onChange(stored)
|
||||
} else {
|
||||
const primary = accounts.find(a => a.is_primary)
|
||||
const fallback = primary ?? accounts[0]
|
||||
if (fallback && fallback.ledger_account !== value) {
|
||||
onChange(fallback.ledger_account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onReady?.()
|
||||
// onReady/onChange are lifecycle callbacks: fire once per load, not on
|
||||
// parent re-renders that re-create them. `value` is read once at restore.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [company?.id, loaded, accounts])
|
||||
|
||||
const handleChange = (next: string) => {
|
||||
if (company?.id && typeof window !== 'undefined') {
|
||||
window.sessionStorage.setItem(STORAGE_KEY_PREFIX + company.id, next)
|
||||
}
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
// Fallback when the table is empty (fresh company, no PSD2 connections yet):
|
||||
// show a single hardcoded '1930' option so the rest of the UI still works.
|
||||
const options = accounts.length > 0
|
||||
? accounts.map(a => ({
|
||||
value: a.ledger_account,
|
||||
label: `${a.ledger_account} ${a.name ?? a.iban ?? a.currency}`,
|
||||
}))
|
||||
: [{ value: '1930', label: '1930 Bankkonto' }]
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && <Label>{label}</Label>}
|
||||
<div className={`flex items-center gap-2 ${label ? 'mt-1' : ''}`}>
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={handleChange}
|
||||
disabled={!loaded && accounts.length === 0}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-[280px]">
|
||||
<SelectValue placeholder={loaded ? 'Välj konto' : 'Laddar…'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map(o => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface DataStateProps {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
/** When true (and not loading/error), render `empty` instead of `children`. */
|
||||
isEmpty?: boolean
|
||||
/** Retry handler: typically `useFetch().refetch`. Shows a retry button. */
|
||||
onRetry?: () => void
|
||||
/** Loading placeholder. Defaults to three skeleton rows. */
|
||||
skeleton?: React.ReactNode
|
||||
/** Shown when `isEmpty`. Pass an `EmptyState` / preset (e.g. `<EmptyInvoices />`). */
|
||||
empty?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the loading / error / empty / ready states for a data-driven section,
|
||||
* so callers stop hand-rolling that branch every time.
|
||||
*
|
||||
* Pairs with `useFetch`:
|
||||
*
|
||||
* @example
|
||||
* const { data, loading, error, refetch } = useFetch<Account[]>(url, { select: b => b.data })
|
||||
* return (
|
||||
* <DataState
|
||||
* loading={loading}
|
||||
* error={error}
|
||||
* onRetry={refetch}
|
||||
* isEmpty={!data?.length}
|
||||
* empty={<EmptyState title={t('none_title')} description={t('none_desc')} />}
|
||||
* >
|
||||
* <AccountsTable accounts={data!} />
|
||||
* </DataState>
|
||||
* )
|
||||
*
|
||||
* Loading uses the `Skeleton` primitive; empty expects an `EmptyState`; the
|
||||
* error branch uses the only chrome-permitted semantic colour (`destructive`).
|
||||
*/
|
||||
export function DataState({
|
||||
loading,
|
||||
error,
|
||||
isEmpty = false,
|
||||
onRetry,
|
||||
skeleton,
|
||||
empty,
|
||||
children,
|
||||
className,
|
||||
}: DataStateProps) {
|
||||
const t = useTranslations('common')
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{skeleton ?? (
|
||||
<div className="space-y-3" aria-busy="true" aria-live="polite">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-3/4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={cn('flex flex-col items-center justify-center py-12 px-4 text-center', className)}
|
||||
>
|
||||
<AlertCircle className="h-8 w-8 text-destructive mb-3" aria-hidden="true" />
|
||||
<p className="text-sm font-medium mb-1">{t('load_error')}</p>
|
||||
<p className="text-sm text-muted-foreground max-w-sm mb-6 text-balance">{error}</p>
|
||||
{onRetry && (
|
||||
<Button variant="outline" onClick={onRetry}>
|
||||
{t('retry')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isEmpty) {
|
||||
return <div className={className}>{empty}</div>
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Deadline, DeadlineStatus, TAX_DEADLINE_TYPE_LABELS, DEADLINE_STATUS_LABELS } from '@/types'
|
||||
import { getReportUrl } from '@/lib/tax/deadline-config'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
// Use the labels from types
|
||||
const STATUS_LABELS = DEADLINE_STATUS_LABELS
|
||||
import {
|
||||
FileText,
|
||||
ChevronRight,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Send,
|
||||
Check,
|
||||
Loader2,
|
||||
ExternalLink,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface TaxTodoWidgetProps {
|
||||
deadlines: Deadline[]
|
||||
onStatusChange?: (deadlineId: string, newStatus: DeadlineStatus) => void
|
||||
}
|
||||
|
||||
export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps) {
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('tax_todo')
|
||||
const [updatingId, setUpdatingId] = useState<string | null>(null)
|
||||
|
||||
// Filter to only tax deadlines needing attention
|
||||
const taxDeadlines = deadlines.filter(
|
||||
(d) =>
|
||||
d.deadline_type === 'tax' &&
|
||||
!d.is_completed &&
|
||||
['action_needed', 'overdue', 'in_progress'].includes(d.status)
|
||||
)
|
||||
|
||||
// Sort by urgency: overdue first, then by date
|
||||
const sortedDeadlines = taxDeadlines.sort((a, b) => {
|
||||
if (a.status === 'overdue' && b.status !== 'overdue') return -1
|
||||
if (b.status === 'overdue' && a.status !== 'overdue') return 1
|
||||
return a.due_date.localeCompare(b.due_date)
|
||||
})
|
||||
|
||||
const overdueCount = taxDeadlines.filter((d) => d.status === 'overdue').length
|
||||
const actionNeededCount = taxDeadlines.filter((d) => d.status === 'action_needed').length
|
||||
|
||||
if (sortedDeadlines.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr)
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
const diffDays = Math.ceil(
|
||||
(date.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
|
||||
)
|
||||
|
||||
if (diffDays === 0) return t('today')
|
||||
if (diffDays === 1) return t('tomorrow')
|
||||
if (diffDays < 0) return t('days_ago', { count: Math.abs(diffDays) })
|
||||
if (diffDays <= 7) return t('in_days', { count: diffDays })
|
||||
|
||||
return date.toLocaleDateString('sv-SE', { day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
const handleStatusChange = async (deadlineId: string, newStatus: DeadlineStatus) => {
|
||||
setUpdatingId(deadlineId)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/deadlines/${deadlineId}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
// Map the parsed body plus the status, never `new Error(data.error)`:
|
||||
// the route answers thrown errors with the canonical envelope
|
||||
// `{ error: { code, message } }`, and the Error constructor would
|
||||
// stringify that object to "[object Object]", discarding the route's
|
||||
// own Swedish reason.
|
||||
const body = await response.json().catch(() => null)
|
||||
toast({
|
||||
title: getUserErrorMessage(body, { statusCode: response.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t('toast_status_updated'),
|
||||
description: t('toast_status_updated_description', { status: STATUS_LABELS[newStatus].toLowerCase() }),
|
||||
})
|
||||
|
||||
onStatusChange?.(deadlineId, newStatus)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: error instanceof Error ? getUserErrorMessage(error) : t('toast_status_update_failed'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setUpdatingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const getReportLink = (deadline: Deadline): string | null => {
|
||||
if (!deadline.linked_report_type || !deadline.linked_report_period) {
|
||||
return null
|
||||
}
|
||||
|
||||
return getReportUrl(deadline.linked_report_type, deadline.linked_report_period)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-attn" />
|
||||
{t('title')}
|
||||
</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
{overdueCount > 0 && (
|
||||
<Badge variant="destructive">{t('overdue_badge', { count: overdueCount })}</Badge>
|
||||
)}
|
||||
{actionNeededCount > 0 && (
|
||||
<Badge variant="warning">{t('action_needed_badge', { count: actionNeededCount })}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{sortedDeadlines.slice(0, 5).map((deadline) => {
|
||||
const isUpdating = updatingId === deadline.id
|
||||
const reportLink = getReportLink(deadline)
|
||||
const isOverdue = deadline.status === 'overdue'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={deadline.id}
|
||||
className={`p-3 rounded-lg ${isOverdue ? 'bg-destructive/5' : ''}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2 min-w-0 flex-1">
|
||||
{isOverdue ? (
|
||||
<AlertTriangle className="h-4 w-4 text-destructive flex-shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<Clock className="h-4 w-4 text-attn flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{deadline.title}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span
|
||||
className={`text-xs ${
|
||||
isOverdue ? 'text-destructive font-medium' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{formatDate(deadline.due_date)}
|
||||
</span>
|
||||
{deadline.tax_deadline_type && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
· {TAX_DEADLINE_TYPE_LABELS[deadline.tax_deadline_type]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{/* Link to report if available */}
|
||||
{reportLink && (
|
||||
<Link href={reportLink}>
|
||||
<Button variant="ghost" size="sm" className="h-9 px-2.5">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Mark as in progress */}
|
||||
{['action_needed', 'overdue'].includes(deadline.status) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 px-3 text-xs"
|
||||
disabled={isUpdating}
|
||||
onClick={() => handleStatusChange(deadline.id, 'in_progress')}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
t('start')
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Mark as submitted */}
|
||||
{deadline.status === 'in_progress' && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-9 px-3 text-xs gap-1"
|
||||
disabled={isUpdating}
|
||||
onClick={() => handleStatusChange(deadline.id, 'submitted')}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
{t('submitted')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{sortedDeadlines.length > 5 && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{t('more_tasks', { count: sortedDeadlines.length - 5 })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Link href="/deadlines" className="block">
|
||||
<Button variant="ghost" className="w-full justify-between">
|
||||
{t('view_all')}
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Deadline, DeadlineStatus } from '@/types'
|
||||
import {
|
||||
getUpcomingDeadlines,
|
||||
STATUS_LABELS,
|
||||
} from '@/lib/calendar/utils'
|
||||
import { Calendar, ChevronRight, AlertTriangle, Clock, Check, Send, Loader2 } from 'lucide-react'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
interface UpcomingDeadlinesWidgetProps {
|
||||
deadlines: Deadline[]
|
||||
maxItems?: number
|
||||
onStatusChange?: (deadlineId: string, newStatus: DeadlineStatus) => void
|
||||
}
|
||||
|
||||
export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChange }: UpcomingDeadlinesWidgetProps) {
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('upcoming_deadlines')
|
||||
const [updatingId, setUpdatingId] = useState<string | null>(null)
|
||||
|
||||
// Get upcoming deadlines (next 7 days) + any needing attention
|
||||
const upcomingDeadlines = getUpcomingDeadlines(deadlines, 7)
|
||||
const actionNeededDeadlines = deadlines.filter(
|
||||
(d) => !d.is_completed && d.status === 'action_needed'
|
||||
)
|
||||
const overdueDeadlines = deadlines.filter(
|
||||
(d) => !d.is_completed && d.status === 'overdue'
|
||||
)
|
||||
|
||||
// Combine and sort (overdue first, then action_needed, then upcoming)
|
||||
const displayDeadlines = [...overdueDeadlines, ...actionNeededDeadlines, ...upcomingDeadlines]
|
||||
.filter((d, i, arr) => arr.findIndex((x) => x.id === d.id) === i) // Remove duplicates
|
||||
.slice(0, maxItems)
|
||||
|
||||
const overdueCount = overdueDeadlines.length
|
||||
const actionNeededCount = actionNeededDeadlines.length
|
||||
|
||||
if (displayDeadlines.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr)
|
||||
const today = new Date()
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
return t('today')
|
||||
}
|
||||
if (date.toDateString() === tomorrow.toDateString()) {
|
||||
return t('tomorrow')
|
||||
}
|
||||
return date.toLocaleDateString('sv-SE', { day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
const handleStatusChange = async (deadlineId: string, newStatus: DeadlineStatus) => {
|
||||
setUpdatingId(deadlineId)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/deadlines/${deadlineId}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
// Map the parsed body plus the status, never `new Error(data.error)`:
|
||||
// the route answers thrown errors with the canonical envelope
|
||||
// `{ error: { code, message } }`, and the Error constructor would
|
||||
// stringify that object to "[object Object]", discarding the route's
|
||||
// own Swedish reason.
|
||||
const body = await response.json().catch(() => null)
|
||||
toast({
|
||||
title: getUserErrorMessage(body, { statusCode: response.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t('toast_status_updated'),
|
||||
description: t('toast_status_updated_description', { status: STATUS_LABELS[newStatus].toLowerCase() }),
|
||||
})
|
||||
|
||||
// Notify parent component
|
||||
onStatusChange?.(deadlineId, newStatus)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: error instanceof Error ? getUserErrorMessage(error) : t('toast_status_update_failed'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setUpdatingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusBadgeVariant = (status: DeadlineStatus): 'default' | 'secondary' | 'destructive' | 'warning' => {
|
||||
switch (status) {
|
||||
case 'overdue': return 'destructive'
|
||||
case 'action_needed': return 'warning'
|
||||
case 'submitted':
|
||||
case 'confirmed': return 'default'
|
||||
default: return 'secondary'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
{t('title')}
|
||||
</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
{overdueCount > 0 && (
|
||||
<Badge variant="destructive">{t('overdue_badge', { count: overdueCount })}</Badge>
|
||||
)}
|
||||
{actionNeededCount > 0 && (
|
||||
<Badge variant="warning">{t('action_needed_badge', { count: actionNeededCount })}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{displayDeadlines.map((deadline) => {
|
||||
const isUpdating = updatingId === deadline.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={deadline.id}
|
||||
className={`flex items-center justify-between p-2 rounded-lg ${
|
||||
deadline.status === 'overdue' ? 'bg-destructive/5' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
{deadline.status === 'overdue' ? (
|
||||
<AlertTriangle className="h-4 w-4 text-destructive flex-shrink-0" />
|
||||
) : deadline.status === 'action_needed' ? (
|
||||
<Clock className="h-4 w-4 text-attn flex-shrink-0" />
|
||||
) : (
|
||||
<Clock className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">{deadline.title}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDate(deadline.due_date)}
|
||||
{deadline.due_time && ` ${t('time_prefix')} ${deadline.due_time.slice(0, 5)}`}
|
||||
{deadline.tax_deadline_type && (
|
||||
<>
|
||||
{' '}
|
||||
<span className="text-muted-foreground/30">·</span> {t('tax_badge')}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{/* Status badge */}
|
||||
<Badge variant={getStatusBadgeVariant(deadline.status)} className="text-xs">
|
||||
{STATUS_LABELS[deadline.status]}
|
||||
</Badge>
|
||||
|
||||
{/* Quick action buttons for tax deadlines */}
|
||||
{deadline.tax_deadline_type && !['submitted', 'confirmed'].includes(deadline.status) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 px-2.5"
|
||||
disabled={isUpdating}
|
||||
onClick={() => handleStatusChange(deadline.id, 'submitted')}
|
||||
title={t('mark_as_submitted')}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{deadline.status === 'submitted' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 px-2.5"
|
||||
disabled={isUpdating}
|
||||
onClick={() => handleStatusChange(deadline.id, 'confirmed')}
|
||||
title={t('mark_as_confirmed')}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<Link href="/deadlines" className="block">
|
||||
<Button variant="ghost" className="w-full justify-between mt-2">
|
||||
{t('view_all')}
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export { DeadlineRow } from './DeadlineRow'
|
||||
export { DeadlineForm } from './DeadlineForm'
|
||||
export { DeadlineList } from './DeadlineList'
|
||||
export { UpcomingDeadlinesWidget } from './UpcomingDeadlinesWidget'
|
||||
export { TaxTodoWidget } from './TaxTodoWidget'
|
||||
@@ -1,15 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
|
||||
import { Wand } from 'lucide-react'
|
||||
|
||||
export default function AiCategorizationWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
return (
|
||||
<EmptyExtensionState
|
||||
title="Transaktionskategorisering"
|
||||
description="Transaktioner kategoriseras automatiskt baserat på dina regler när nya synkas. Gå till Transaktioner för att se matchningar."
|
||||
icon={<Wand className="h-12 w-12 text-muted-foreground/40 mb-4" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -63,7 +63,7 @@ import { useCapability, useCompanyOptional } from '@/contexts/CompanyContext'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { useBranding } from '@/lib/branding/brand-context'
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import type { InboxChannelContext, InvoiceExtractionResult, InboxItemSource } from '@/types'
|
||||
import type { AccountingMethod, InboxChannelContext, InvoiceExtractionResult, InboxItemSource } from '@/types'
|
||||
import { renderChannelParticipant } from '@/lib/documents/channel-context-notes'
|
||||
import { selectInboxFields } from '@/lib/documents/inbox-field-visibility'
|
||||
import {
|
||||
@@ -92,8 +92,6 @@ import {
|
||||
import { shrinkImageForUpload } from '@/lib/documents/shrink-image'
|
||||
import { uploadViaSignedUrl } from '@/lib/documents/direct-upload'
|
||||
|
||||
type AccountingMethod = 'accrual' | 'cash'
|
||||
|
||||
/**
|
||||
* A failure whose message is already the sentence to show the user, resolved
|
||||
* where the response was still in hand.
|
||||
@@ -334,8 +332,6 @@ const EXTRACTED_FIELD_ACCESSORS: ((d: InvoiceExtractionResult) => unknown)[] = [
|
||||
(d) => d.totals?.total,
|
||||
]
|
||||
|
||||
export const EXTRACTED_FIELD_COUNT = EXTRACTED_FIELD_ACCESSORS.length
|
||||
|
||||
/** How many of them the extraction actually filled in. */
|
||||
function countExtractedFields(data: InvoiceExtractionResult | null): number {
|
||||
if (!data) return 0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
|
||||
import { Camera } from 'lucide-react'
|
||||
|
||||
export default function ReceiptOcrWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
return (
|
||||
<EmptyExtensionState
|
||||
title="Kvittoscanning"
|
||||
description="Ladda upp och skanna kvitton direkt från tillägets arbetsyta. Gå till Kvitton i sidomenyn för att komma igång."
|
||||
icon={<Camera className="h-12 w-12 text-muted-foreground/40 mb-4" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Loader2, AlertTriangle } from 'lucide-react'
|
||||
|
||||
interface ConfirmDeleteDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title?: string
|
||||
description?: string
|
||||
onConfirm: () => void | Promise<void>
|
||||
isDeleting?: boolean
|
||||
}
|
||||
|
||||
export default function ConfirmDeleteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title = 'Bekräfta borttagning',
|
||||
description = 'Är du säker på att du vill ta bort detta? Åtgärden kan inte ångras.',
|
||||
onConfirm,
|
||||
isDeleting = false,
|
||||
}: ConfirmDeleteDialogProps) {
|
||||
const handleConfirm = async () => {
|
||||
await onConfirm()
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isDeleting}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleConfirm} disabled={isDeleting}>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Tar bort...
|
||||
</>
|
||||
) : (
|
||||
'Ta bort'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Upload, FileText, Check } from 'lucide-react'
|
||||
|
||||
interface CsvImportWizardProps {
|
||||
targetFields: { key: string; label: string; required?: boolean }[]
|
||||
defaultMappings?: Record<string, string>
|
||||
onImport: (rows: Record<string, string>[]) => Promise<void>
|
||||
className?: string
|
||||
}
|
||||
|
||||
function parseCsv(text: string): { headers: string[]; rows: string[][] } {
|
||||
const lines = text.split(/\r?\n/).filter(line => line.trim())
|
||||
if (lines.length === 0) return { headers: [], rows: [] }
|
||||
|
||||
const separator = lines[0].includes(';') ? ';' : ','
|
||||
const headers = lines[0].split(separator).map(h => h.trim().replace(/^"(.*)"$/, '$1'))
|
||||
const rows = lines.slice(1).map(line =>
|
||||
line.split(separator).map(cell => cell.trim().replace(/^"(.*)"$/, '$1'))
|
||||
)
|
||||
return { headers, rows }
|
||||
}
|
||||
|
||||
export default function CsvImportWizard({
|
||||
targetFields,
|
||||
defaultMappings,
|
||||
onImport,
|
||||
className,
|
||||
}: CsvImportWizardProps) {
|
||||
const [step, setStep] = useState<1 | 2 | 3>(1)
|
||||
const [headers, setHeaders] = useState<string[]>([])
|
||||
const [rows, setRows] = useState<string[][]>([])
|
||||
const [mappings, setMappings] = useState<Record<string, string>>({})
|
||||
const [isImporting, setIsImporting] = useState(false)
|
||||
const [importCount, setImportCount] = useState(0)
|
||||
const [fileName, setFileName] = useState('')
|
||||
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setFileName(file.name)
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
const text = ev.target?.result as string
|
||||
const parsed = parseCsv(text)
|
||||
setHeaders(parsed.headers)
|
||||
setRows(parsed.rows)
|
||||
|
||||
// Auto-map using defaults
|
||||
const autoMappings: Record<string, string> = {}
|
||||
for (const field of targetFields) {
|
||||
const defaultCsv = defaultMappings?.[field.key]
|
||||
if (defaultCsv && parsed.headers.includes(defaultCsv)) {
|
||||
autoMappings[field.key] = defaultCsv
|
||||
} else {
|
||||
const match = parsed.headers.find(
|
||||
h => h.toLowerCase() === field.key.toLowerCase() ||
|
||||
h.toLowerCase() === field.label.toLowerCase()
|
||||
)
|
||||
if (match) autoMappings[field.key] = match
|
||||
}
|
||||
}
|
||||
setMappings(autoMappings)
|
||||
setStep(2)
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}, [targetFields, defaultMappings])
|
||||
|
||||
const handleImport = async () => {
|
||||
setIsImporting(true)
|
||||
try {
|
||||
const mappedRows = rows.map(row => {
|
||||
const obj: Record<string, string> = {}
|
||||
for (const [fieldKey, csvCol] of Object.entries(mappings)) {
|
||||
const colIdx = headers.indexOf(csvCol)
|
||||
if (colIdx >= 0 && row[colIdx]) {
|
||||
obj[fieldKey] = row[colIdx]
|
||||
}
|
||||
}
|
||||
return obj
|
||||
}).filter(row => Object.keys(row).length > 0)
|
||||
|
||||
await onImport(mappedRows)
|
||||
setImportCount(mappedRows.length)
|
||||
setStep(3)
|
||||
} finally {
|
||||
setIsImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
setStep(1)
|
||||
setHeaders([])
|
||||
setRows([])
|
||||
setMappings({})
|
||||
setFileName('')
|
||||
setImportCount(0)
|
||||
}
|
||||
|
||||
const requiredFieldsMapped = targetFields
|
||||
.filter(f => f.required)
|
||||
.every(f => mappings[f.key])
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
{step === 1 && <><Upload className="h-4 w-4" /> Steg 1: Välj fil</>}
|
||||
{step === 2 && <><FileText className="h-4 w-4" /> Steg 2: Kolumnmappning</>}
|
||||
{step === 3 && <><Check className="h-4 w-4" /> Import klar</>}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div className="border-2 border-dashed rounded-lg p-8 text-center">
|
||||
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Välj en CSV-fil att importera
|
||||
</p>
|
||||
<Label htmlFor="csv-upload" className="cursor-pointer">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<span>Välj fil</span>
|
||||
</Button>
|
||||
</Label>
|
||||
<input
|
||||
id="csv-upload"
|
||||
type="file"
|
||||
accept=".csv,.txt"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{fileName} - {rows.length} rader hittades. Mappa kolumner:
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{targetFields.map(field => (
|
||||
<div key={field.key} className="flex items-center gap-3">
|
||||
<Label className="w-36 text-sm shrink-0">
|
||||
{field.label}{field.required && ' *'}
|
||||
</Label>
|
||||
<Select
|
||||
value={mappings[field.key] ?? ''}
|
||||
onValueChange={(val) => setMappings(prev => ({ ...prev, [field.key]: val }))}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Välj kolumn..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{headers.map(h => (
|
||||
<SelectItem key={h} value={h}>{h}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{rows.length > 0 && (
|
||||
<div className="rounded-lg border overflow-auto max-h-48">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{headers.map(h => (
|
||||
/* data-ph-mask: CSV headers are user data */
|
||||
<TableHead key={h} data-ph-mask="" className="text-xs whitespace-nowrap">{h}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.slice(0, 5).map((row, i) => (
|
||||
<TableRow key={i}>
|
||||
{row.map((cell, j) => (
|
||||
<TableCell key={j} className="text-xs whitespace-nowrap">{cell}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={reset}>
|
||||
Tillbaka
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleImport}
|
||||
disabled={!requiredFieldsMapped || isImporting}
|
||||
>
|
||||
{isImporting ? 'Importerar...' : `Importera ${rows.length} rader`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="text-center py-4">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-success/10 mb-3">
|
||||
<Check className="h-6 w-6 text-success" />
|
||||
</div>
|
||||
<p className="font-medium">{importCount} rader importerades</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Från {fileName}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={reset} className="mt-4">
|
||||
Importera fler
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface DataEntryFormProps {
|
||||
title: string
|
||||
onSubmit: (e: React.FormEvent) => void
|
||||
submitLabel?: string
|
||||
isSubmitting?: boolean
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function DataEntryForm({
|
||||
title,
|
||||
onSubmit,
|
||||
submitLabel = 'Spara',
|
||||
isSubmitting = false,
|
||||
children,
|
||||
className,
|
||||
}: DataEntryFormProps) {
|
||||
return (
|
||||
<Card className={cn('', className)}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
{children}
|
||||
<Button type="submit" disabled={isSubmitting} size="sm">
|
||||
{isSubmitting ? 'Sparar...' : submitLabel}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type Period = 'month' | 'quarter' | 'year' | 'custom'
|
||||
|
||||
interface DateRangeFilterProps {
|
||||
onRangeChange: (start: string, end: string) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function DateRangeFilter({ onRangeChange, className }: DateRangeFilterProps) {
|
||||
const [activePeriod, setActivePeriod] = useState<Period>('month')
|
||||
|
||||
const now = new Date()
|
||||
|
||||
const handlePeriod = (period: Period) => {
|
||||
setActivePeriod(period)
|
||||
const year = now.getFullYear()
|
||||
const month = now.getMonth()
|
||||
|
||||
switch (period) {
|
||||
case 'month': {
|
||||
const start = new Date(year, month, 1).toISOString().slice(0, 10)
|
||||
const end = new Date(year, month + 1, 0).toISOString().slice(0, 10)
|
||||
onRangeChange(start, end)
|
||||
break
|
||||
}
|
||||
case 'quarter': {
|
||||
const qStart = Math.floor(month / 3) * 3
|
||||
const start = new Date(year, qStart, 1).toISOString().slice(0, 10)
|
||||
const end = new Date(year, qStart + 3, 0).toISOString().slice(0, 10)
|
||||
onRangeChange(start, end)
|
||||
break
|
||||
}
|
||||
case 'year': {
|
||||
onRangeChange(`${year}-01-01`, `${year}-12-31`)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const periods: { key: Period; label: string }[] = [
|
||||
{ key: 'month', label: 'Månad' },
|
||||
{ key: 'quarter', label: 'Kvartal' },
|
||||
{ key: 'year', label: 'År' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1', className)}>
|
||||
{periods.map(({ key, label }) => (
|
||||
<Button
|
||||
key={key}
|
||||
variant={activePeriod === key ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => handlePeriod(key)}
|
||||
className="text-xs"
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { ReactNode } from 'react'
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
interface EditEntryDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
description?: string
|
||||
onSave: () => void | Promise<void>
|
||||
isSaving?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function EditEntryDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
onSave,
|
||||
isSaving = false,
|
||||
children,
|
||||
}: EditEntryDialogProps) {
|
||||
const handleSave = async () => {
|
||||
await onSave()
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description && <DialogDescription>{description}</DialogDescription>}
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{children}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sparar...
|
||||
</>
|
||||
) : (
|
||||
'Spara'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function ExtensionLoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* KPI cards skeleton */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="rounded-lg border p-6 space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Content skeleton */}
|
||||
<div className="rounded-lg border p-6 space-y-4">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-40 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface KPICardProps {
|
||||
label: string
|
||||
value: string | number
|
||||
suffix?: string
|
||||
trend?: { value: number; label: string }
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function KPICard({ label, value, suffix, trend, className }: KPICardProps) {
|
||||
return (
|
||||
<Card className={cn('', className)}>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
<div className="flex items-baseline gap-1 mt-1">
|
||||
<span className="text-2xl font-semibold tracking-tight tabular-nums">{value}</span>
|
||||
{suffix && <span className="text-sm text-muted-foreground">{suffix}</span>}
|
||||
</div>
|
||||
{trend && (
|
||||
<p className={cn(
|
||||
'text-xs mt-1',
|
||||
trend.value > 0 ? 'text-success' : trend.value < 0 ? 'text-destructive' : 'text-muted-foreground'
|
||||
)}>
|
||||
{trend.value > 0 ? '+' : ''}{trend.value}% {trend.label}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FlaskConical, X, Replace } from 'lucide-react'
|
||||
|
||||
interface MockDataBannerProps {
|
||||
importedAt: string | null
|
||||
onClear: () => void
|
||||
onReplace: () => void
|
||||
}
|
||||
|
||||
export default function MockDataBanner({ importedAt, onClear, onReplace }: MockDataBannerProps) {
|
||||
const formatted = importedAt
|
||||
? new Date(importedAt).toLocaleString('sv-SE', {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
: null
|
||||
|
||||
return (
|
||||
<Card className="border-l-4 border-l-warning bg-muted/30">
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<FlaskConical className="h-5 w-5 text-attn shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-attn">
|
||||
Testdata aktivt
|
||||
</p>
|
||||
<p className="text-xs text-attn mt-0.5">
|
||||
Rapporten visar importerad testdata istället för bokföringsdata.
|
||||
{formatted && <> Importerat {formatted}.</>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1.5 shrink-0">
|
||||
<Button variant="outline" size="sm" onClick={onReplace} className="h-7 text-xs">
|
||||
<Replace className="h-3.5 w-3.5 mr-1" />
|
||||
Ersätt
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onClear} className="h-7 text-xs">
|
||||
<X className="h-3.5 w-3.5 mr-1" />
|
||||
Rensa
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,406 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Upload, FileJson, FileSpreadsheet, Download, AlertCircle, Check,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────
|
||||
|
||||
export interface CsvFieldDef {
|
||||
key: string
|
||||
label: string
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
interface MockDataImportDialogProps<T> {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
csvFields: CsvFieldDef[]
|
||||
defaultMappings?: Record<string, string>
|
||||
parseCsvRows: (rows: Record<string, string>[]) => T
|
||||
validateReport: (data: unknown) => { valid: boolean; error?: string }
|
||||
templateCsvContent: string
|
||||
templateFileName: string
|
||||
onImport: (report: T, meta: { source: 'csv' | 'json'; fileName: string; rowCount: number }) => Promise<void>
|
||||
}
|
||||
|
||||
function parseCsv(text: string): { headers: string[]; rows: string[][] } {
|
||||
const lines = text.split(/\r?\n/).filter(line => line.trim())
|
||||
if (lines.length === 0) return { headers: [], rows: [] }
|
||||
|
||||
const separator = lines[0].includes(';') ? ';' : ','
|
||||
const headers = lines[0].split(separator).map(h => h.trim().replace(/^"(.*)"$/, '$1'))
|
||||
const rows = lines.slice(1).map(line =>
|
||||
line.split(separator).map(cell => cell.trim().replace(/^"(.*)"$/, '$1'))
|
||||
)
|
||||
return { headers, rows }
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────
|
||||
|
||||
type Step = 'upload' | 'map-csv' | 'preview-json' | 'importing'
|
||||
|
||||
export default function MockDataImportDialog<T>({
|
||||
open,
|
||||
onOpenChange,
|
||||
csvFields,
|
||||
defaultMappings,
|
||||
parseCsvRows,
|
||||
validateReport,
|
||||
templateCsvContent,
|
||||
templateFileName,
|
||||
onImport,
|
||||
}: MockDataImportDialogProps<T>) {
|
||||
const [step, setStep] = useState<Step>('upload')
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// CSV state
|
||||
const [csvHeaders, setCsvHeaders] = useState<string[]>([])
|
||||
const [csvRows, setCsvRows] = useState<string[][]>([])
|
||||
const [mappings, setMappings] = useState<Record<string, string>>({})
|
||||
const [fileName, setFileName] = useState('')
|
||||
|
||||
// JSON state
|
||||
const [jsonReport, setJsonReport] = useState<T | null>(null)
|
||||
const [jsonSummary, setJsonSummary] = useState('')
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setStep('upload')
|
||||
setError(null)
|
||||
setCsvHeaders([])
|
||||
setCsvRows([])
|
||||
setMappings({})
|
||||
setFileName('')
|
||||
setJsonReport(null)
|
||||
setJsonSummary('')
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
const handleOpenChange = useCallback((open: boolean) => {
|
||||
if (!open) reset()
|
||||
onOpenChange(open)
|
||||
}, [onOpenChange, reset])
|
||||
|
||||
const processFile = useCallback((file: File) => {
|
||||
setError(null)
|
||||
setFileName(file.name)
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
const text = ev.target?.result as string
|
||||
|
||||
if (file.name.endsWith('.json')) {
|
||||
// JSON path
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
const validation = validateReport(parsed)
|
||||
if (!validation.valid) {
|
||||
setError(validation.error || 'Ogiltig JSON-struktur')
|
||||
return
|
||||
}
|
||||
setJsonReport(parsed as T)
|
||||
|
||||
// Build summary
|
||||
const keys = Object.keys(parsed)
|
||||
const lines = Array.isArray(parsed.lines) ? parsed.lines.length
|
||||
: Array.isArray(parsed.receivables) ? parsed.receivables.length
|
||||
: Array.isArray(parsed.boxes) ? parsed.boxes.length
|
||||
: null
|
||||
setJsonSummary(
|
||||
`${keys.length} fält` + (lines !== null ? `, ${lines} rader` : '')
|
||||
)
|
||||
setStep('preview-json')
|
||||
} catch {
|
||||
setError('Kunde inte tolka JSON-filen. Kontrollera formatet.')
|
||||
}
|
||||
} else {
|
||||
// CSV path
|
||||
const parsed = parseCsv(text)
|
||||
if (parsed.headers.length === 0 || parsed.rows.length === 0) {
|
||||
setError('Ingen data hittades i CSV-filen.')
|
||||
return
|
||||
}
|
||||
|
||||
setCsvHeaders(parsed.headers)
|
||||
setCsvRows(parsed.rows)
|
||||
|
||||
// Auto-map columns
|
||||
const autoMappings: Record<string, string> = {}
|
||||
for (const field of csvFields) {
|
||||
const defaultCol = defaultMappings?.[field.key]
|
||||
if (defaultCol && parsed.headers.includes(defaultCol)) {
|
||||
autoMappings[field.key] = defaultCol
|
||||
} else {
|
||||
const match = parsed.headers.find(
|
||||
h => h.toLowerCase() === field.key.toLowerCase() ||
|
||||
h.toLowerCase() === field.label.toLowerCase()
|
||||
)
|
||||
if (match) autoMappings[field.key] = match
|
||||
}
|
||||
}
|
||||
setMappings(autoMappings)
|
||||
setStep('map-csv')
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}, [csvFields, defaultMappings, validateReport])
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) processFile(file)
|
||||
}, [processFile])
|
||||
|
||||
const handleFileInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) processFile(file)
|
||||
}, [processFile])
|
||||
|
||||
const handleCsvImport = useCallback(async () => {
|
||||
setStep('importing')
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const mappedRows = csvRows.map(row => {
|
||||
const obj: Record<string, string> = {}
|
||||
for (const [fieldKey, csvCol] of Object.entries(mappings)) {
|
||||
const colIdx = csvHeaders.indexOf(csvCol)
|
||||
if (colIdx >= 0 && row[colIdx]) {
|
||||
obj[fieldKey] = row[colIdx]
|
||||
}
|
||||
}
|
||||
return obj
|
||||
}).filter(row => Object.keys(row).length > 0)
|
||||
|
||||
const report = parseCsvRows(mappedRows)
|
||||
await onImport(report, { source: 'csv', fileName, rowCount: mappedRows.length })
|
||||
handleOpenChange(false)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? getUserErrorMessage(e) : 'Import misslyckades')
|
||||
setStep('map-csv')
|
||||
}
|
||||
}, [csvRows, csvHeaders, mappings, parseCsvRows, onImport, fileName, handleOpenChange])
|
||||
|
||||
const handleJsonImport = useCallback(async () => {
|
||||
if (!jsonReport) return
|
||||
setStep('importing')
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await onImport(jsonReport, { source: 'json', fileName, rowCount: 0 })
|
||||
handleOpenChange(false)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? getUserErrorMessage(e) : 'Import misslyckades')
|
||||
setStep('preview-json')
|
||||
}
|
||||
}, [jsonReport, onImport, fileName, handleOpenChange])
|
||||
|
||||
const downloadTemplate = useCallback(() => {
|
||||
const blob = new Blob([templateCsvContent], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = templateFileName
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}, [templateCsvContent, templateFileName])
|
||||
|
||||
const requiredFieldsMapped = csvFields
|
||||
.filter(f => f.required)
|
||||
.every(f => mappings[f.key])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{step === 'upload' && 'Importera testdata'}
|
||||
{step === 'map-csv' && 'Kolumnmappning'}
|
||||
{step === 'preview-json' && 'Förhandsgranska JSON'}
|
||||
{step === 'importing' && 'Importerar...'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* ── Error ─────────────────────────────────────── */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-destructive/5 text-destructive text-sm">
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step: Upload ──────────────────────────────── */}
|
||||
{step === 'upload' && (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className={cn(
|
||||
'border-2 border-dashed rounded-lg p-10 text-center transition-colors',
|
||||
isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-muted-foreground/25 hover:border-muted-foreground/50'
|
||||
)}
|
||||
onDragOver={(e) => { e.preventDefault(); setIsDragging(true) }}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Upload className="h-8 w-8 mx-auto mb-3 text-muted-foreground" />
|
||||
<p className="text-sm font-medium mb-1">
|
||||
Dra och släpp en fil här
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
CSV (.csv) eller JSON (.json)
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
Välj fil
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,.json"
|
||||
onChange={handleFileInput}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={downloadTemplate}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||
Ladda ner CSV-mall
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step: Map CSV ─────────────────────────────── */}
|
||||
{step === 'map-csv' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<FileSpreadsheet className="h-4 w-4" />
|
||||
<span>{fileName}: {csvRows.length} rader</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{csvFields.map(field => (
|
||||
<div key={field.key} className="flex items-center gap-3">
|
||||
<Label className="w-40 text-sm shrink-0">
|
||||
{field.label}{field.required && ' *'}
|
||||
</Label>
|
||||
<Select
|
||||
value={mappings[field.key] ?? '___none___'}
|
||||
onValueChange={(val) => setMappings(prev => ({
|
||||
...prev,
|
||||
[field.key]: val === '___none___' ? '' : val,
|
||||
}))}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Välj kolumn..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="___none___">- Välj kolumn -</SelectItem>
|
||||
{csvHeaders.map(h => (
|
||||
<SelectItem key={h} value={h}>{h}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Preview first 5 rows */}
|
||||
{csvRows.length > 0 && (
|
||||
<div className="rounded-lg border overflow-auto max-h-48">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{csvHeaders.map(h => (
|
||||
/* data-ph-mask: CSV headers are user data */
|
||||
<TableHead key={h} data-ph-mask="" className="text-xs whitespace-nowrap">{h}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{csvRows.slice(0, 5).map((row, i) => (
|
||||
<TableRow key={i}>
|
||||
{row.map((cell, j) => (
|
||||
<TableCell key={j} className="text-xs whitespace-nowrap">{cell}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={reset}>Tillbaka</Button>
|
||||
<Button
|
||||
onClick={handleCsvImport}
|
||||
disabled={!requiredFieldsMapped}
|
||||
>
|
||||
Importera {csvRows.length} rader
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step: Preview JSON ────────────────────────── */}
|
||||
{step === 'preview-json' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<FileJson className="h-4 w-4" />
|
||||
<span>{fileName}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-success/10 text-success text-sm">
|
||||
<Check className="h-4 w-4 shrink-0" />
|
||||
<span>Giltig JSON: {jsonSummary}</span>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={reset}>Tillbaka</Button>
|
||||
<Button onClick={handleJsonImport}>
|
||||
Importera testdata
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step: Importing ───────────────────────────── */}
|
||||
{step === 'importing' && (
|
||||
<div className="py-8 text-center">
|
||||
<div className="animate-spin h-8 w-8 border-2 border-primary border-t-transparent rounded-full mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">Importerar testdata...</p>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface MonthlyRow {
|
||||
month: string
|
||||
value: number
|
||||
label?: string
|
||||
}
|
||||
|
||||
interface MonthlyTrendTableProps {
|
||||
rows: MonthlyRow[]
|
||||
valueLabel?: string
|
||||
valueSuffix?: string
|
||||
formatValue?: (v: number) => string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function MonthlyTrendTable({
|
||||
rows,
|
||||
valueLabel = 'Belopp',
|
||||
valueSuffix = 'kr',
|
||||
formatValue,
|
||||
className,
|
||||
}: MonthlyTrendTableProps) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className={cn('rounded-lg border p-6 text-center text-sm text-muted-foreground', className)}>
|
||||
Ingen data att visa
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const maxValue = Math.max(...rows.map(r => Math.abs(r.value)), 1)
|
||||
const fmt = formatValue ?? ((v: number) => v.toLocaleString('sv-SE'))
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-lg border', className)}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Period</TableHead>
|
||||
<TableHead className="text-right">{valueLabel}</TableHead>
|
||||
<TableHead className="w-[40%]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => {
|
||||
const barWidth = Math.round((Math.abs(row.value) / maxValue) * 100)
|
||||
return (
|
||||
<TableRow key={row.month}>
|
||||
<TableCell className="font-medium">{row.label ?? row.month}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{fmt(row.value)} {valueSuffix}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="h-4 w-full rounded-sm bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-sm bg-primary/60 transition-all"
|
||||
style={{ width: `${barWidth}%` }}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Settings } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface SetupField {
|
||||
key: string
|
||||
label: string
|
||||
type?: 'number' | 'text'
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
interface SetupPromptProps {
|
||||
title: string
|
||||
description: string
|
||||
fields: SetupField[]
|
||||
onSave: (values: Record<string, string>) => Promise<void>
|
||||
}
|
||||
|
||||
export default function SetupPrompt({ title, description, fields, onSave }: SetupPromptProps) {
|
||||
const [values, setValues] = useState<Record<string, string>>({})
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await onSave(values)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const allFilled = fields.every(f => values[f.key]?.trim())
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center mb-6">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-muted mb-3">
|
||||
<Settings className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3>{title}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">{description}</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{fields.map(field => (
|
||||
<div key={field.key} className="space-y-2">
|
||||
<Label htmlFor={`setup-${field.key}`}>{field.label}</Label>
|
||||
<Input
|
||||
id={`setup-${field.key}`}
|
||||
type={field.type ?? 'text'}
|
||||
placeholder={field.placeholder}
|
||||
value={values[field.key] ?? ''}
|
||||
onChange={e => setValues(prev => ({ ...prev, [field.key]: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button type="submit" className="w-full" disabled={!allFilled || isSaving}>
|
||||
{isSaving ? 'Sparar...' : 'Kom igång'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
ArrowRight,
|
||||
RotateCcw,
|
||||
} from 'lucide-react'
|
||||
import type { IngestResult } from '@/lib/transactions/ingest'
|
||||
import type { IngestResult } from '@/types'
|
||||
|
||||
interface BankFileResultStepProps {
|
||||
result: IngestResult
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useCallback, useEffect } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Upload, FileText, AlertCircle, CheckCircle, Loader2, XCircle, RefreshCw } from 'lucide-react'
|
||||
import { Upload, AlertCircle, CheckCircle, Loader2, XCircle, RefreshCw } from 'lucide-react'
|
||||
|
||||
const LOADING_PHASES = [
|
||||
{ message: 'Läser fil...', progress: 10 },
|
||||
|
||||
@@ -26,7 +26,8 @@ import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { Plus, Trash2, Loader2 } from 'lucide-react'
|
||||
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
|
||||
import type { Invoice, InvoiceItem, Customer, EntityType } from '@/types'
|
||||
import type { EntityType } from '@/types'
|
||||
import type { InvoiceWithRelations } from '@/components/invoices/types'
|
||||
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
|
||||
|
||||
type DuplicateMatchReason = 'ocr_exact' | 'name_amount_fuzzy' | 'amount_only'
|
||||
@@ -42,14 +43,6 @@ interface DuplicateCandidate {
|
||||
match_confidence: number
|
||||
}
|
||||
|
||||
interface InvoiceWithRelations extends Invoice {
|
||||
customer: Customer
|
||||
items: InvoiceItem[]
|
||||
// Present once an issuance verifikat has been booked (faktureringsmetoden);
|
||||
// absent on kontantmetoden invoices that recognise revenue at payment.
|
||||
journal_entry_id?: string | null
|
||||
}
|
||||
|
||||
interface PaymentBookingDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
|
||||
@@ -32,8 +32,8 @@ import {
|
||||
type ErrorLocale,
|
||||
} from '@/lib/errors/get-error-message'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import type { DeductionType } from '@/lib/invoices/rot-rut-rules'
|
||||
|
||||
type DeductionType = 'rot' | 'rut'
|
||||
type RequestStatus =
|
||||
| 'generated'
|
||||
| 'submitted'
|
||||
|
||||
@@ -30,7 +30,8 @@ import { creditNoteNeedsJournalEntry } from '@/lib/invoices/issue-credit-note'
|
||||
import { itemHasAccrual } from '@/lib/bookkeeping/accruals/account-suggestions'
|
||||
import { Loader2, Mail, Plus, Send, Trash2 } from 'lucide-react'
|
||||
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
|
||||
import type { Invoice, InvoiceItem, Customer, EntityType } from '@/types'
|
||||
import type { EntityType } from '@/types'
|
||||
import type { InvoiceWithRelations } from '@/components/invoices/types'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
|
||||
import {
|
||||
@@ -41,11 +42,6 @@ import {
|
||||
resolveInvoiceEmailRecipients,
|
||||
} from '@/lib/invoices/email-recipients'
|
||||
|
||||
interface InvoiceWithRelations extends Invoice {
|
||||
customer: Customer
|
||||
items: InvoiceItem[]
|
||||
}
|
||||
|
||||
interface SendInvoiceDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Invoice, InvoiceItem, Customer } from '@/types'
|
||||
|
||||
/**
|
||||
* Invoice row joined with its customer and line items, as loaded by the
|
||||
* invoice detail / credit pages and passed to the invoice dialogs.
|
||||
*/
|
||||
export interface InvoiceWithRelations extends Invoice {
|
||||
customer: Customer
|
||||
items: InvoiceItem[]
|
||||
// Optional reference to the issuance verifikation. Populated by the
|
||||
// backend when the invoice flow auto-books an entry on send; absent on
|
||||
// older invoices, on kontantmetoden invoices that recognise revenue at
|
||||
// payment, and on companies where issuance is not auto-booked.
|
||||
journal_entry_id?: string | null
|
||||
}
|
||||
@@ -16,16 +16,7 @@ import {
|
||||
INK2R_EQUITY_LIABILITY_CODES,
|
||||
INK2R_INCOME_CODES,
|
||||
} from '@/lib/reports/ink2/types'
|
||||
|
||||
/** API errors may be a plain string or the canonical { code, message } envelope. */
|
||||
function parseApiError(error: unknown, fallback: string): string {
|
||||
if (typeof error === 'string') return error
|
||||
if (error && typeof error === 'object') {
|
||||
const message = (error as { message?: unknown }).message
|
||||
if (typeof message === 'string') return message
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
import { parseApiError } from './api-error'
|
||||
|
||||
export function INK2DeclarationView({ periodId }: { periodId: string }) {
|
||||
// Fetch outcome tagged with the key it was requested under: switching
|
||||
|
||||
@@ -10,16 +10,7 @@ import {
|
||||
formatWholeKronor,
|
||||
} from '@/components/reports/DeclarationRutaRow'
|
||||
import type { NEDeclaration } from '@/lib/reports/ne-bilaga/types'
|
||||
|
||||
/** API errors may be a plain string or the canonical { code, message } envelope. */
|
||||
function parseApiError(error: unknown, fallback: string): string {
|
||||
if (typeof error === 'string') return error
|
||||
if (error && typeof error === 'object') {
|
||||
const message = (error as { message?: unknown }).message
|
||||
if (typeof message === 'string') return message
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
import { parseApiError } from './api-error'
|
||||
|
||||
export function NEDeclarationView({ periodId }: { periodId: string }) {
|
||||
// Fetch outcome tagged with the key it was requested under: switching
|
||||
|
||||
@@ -32,27 +32,13 @@ import {
|
||||
ExternalLink,
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { formatCurrency, formatWholeKr } from '@/lib/utils'
|
||||
import type {
|
||||
PeriodiskSammanstallningReport,
|
||||
PsPeriodType,
|
||||
PsWarning,
|
||||
} from '@/lib/reports/periodisk-sammanstallning'
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
// Hela kronor: SKV 5740 har inga öre.
|
||||
return amount.toLocaleString('sv-SE', { maximumFractionDigits: 0 })
|
||||
}
|
||||
|
||||
/** API errors may be a plain string or the canonical { code, message } envelope. */
|
||||
function parseApiError(error: unknown, fallback: string): string {
|
||||
if (typeof error === 'string') return error
|
||||
if (error && typeof error === 'object') {
|
||||
const message = (error as { message?: unknown }).message
|
||||
if (typeof message === 'string') return message
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
import { parseApiError } from './api-error'
|
||||
|
||||
function typeBadge(row: { services: number; goods: number; triangulation: number }) {
|
||||
const types: string[] = []
|
||||
@@ -275,19 +261,19 @@ export function PeriodiskSammanstallningView() {
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground uppercase tracking-wider">Tjänster (typ 3)</div>
|
||||
<div className="font-display text-xl tabular-nums">
|
||||
{formatAmount(data.totals.services)} kr
|
||||
{formatWholeKr(data.totals.services)} kr
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground uppercase tracking-wider">Varor (typ 1)</div>
|
||||
<div className="font-display text-xl tabular-nums">
|
||||
{formatAmount(data.totals.goods)} kr
|
||||
{formatWholeKr(data.totals.goods)} kr
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground uppercase tracking-wider">Trepart (typ 2)</div>
|
||||
<div className="font-display text-xl tabular-nums">
|
||||
{formatAmount(data.totals.triangulation)} kr
|
||||
{formatWholeKr(data.totals.triangulation)} kr
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -300,9 +286,9 @@ export function PeriodiskSammanstallningView() {
|
||||
<CheckCircle2 className="h-4 w-4 mt-1 shrink-0 text-success" />
|
||||
<span>
|
||||
Stämmer mot momsdeklarationen
|
||||
{' '}(Ruta 39: {formatAmount(data.reconciliation.ruta39 ?? 0)} kr,
|
||||
{' '}Ruta 35: {formatAmount(data.reconciliation.ruta35 ?? 0)} kr,
|
||||
{' '}Ruta 38: {formatAmount(data.reconciliation.ruta38 ?? 0)} kr)
|
||||
{' '}(Ruta 39: {formatWholeKr(data.reconciliation.ruta39 ?? 0)} kr,
|
||||
{' '}Ruta 35: {formatWholeKr(data.reconciliation.ruta35 ?? 0)} kr,
|
||||
{' '}Ruta 38: {formatWholeKr(data.reconciliation.ruta38 ?? 0)} kr)
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
@@ -310,9 +296,9 @@ export function PeriodiskSammanstallningView() {
|
||||
<AlertTriangle className="h-4 w-4 mt-1 shrink-0" />
|
||||
<span>
|
||||
Avviker från momsdeklarationen: kontrollera bokföringen.
|
||||
{' '}Ruta 39: {formatAmount(data.reconciliation.ruta39 ?? 0)} kr,
|
||||
{' '}Ruta 35: {formatAmount(data.reconciliation.ruta35 ?? 0)} kr,
|
||||
{' '}Ruta 38: {formatAmount(data.reconciliation.ruta38 ?? 0)} kr.
|
||||
{' '}Ruta 39: {formatWholeKr(data.reconciliation.ruta39 ?? 0)} kr,
|
||||
{' '}Ruta 35: {formatWholeKr(data.reconciliation.ruta35 ?? 0)} kr,
|
||||
{' '}Ruta 38: {formatWholeKr(data.reconciliation.ruta38 ?? 0)} kr.
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
@@ -407,13 +393,13 @@ export function PeriodiskSammanstallningView() {
|
||||
<TableCell className="tabular-nums font-mono text-xs">{row.country}</TableCell>
|
||||
<TableCell className="tabular-nums font-mono text-xs">{row.vatNumber}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{row.services !== 0 ? formatAmount(row.services) : '-'}
|
||||
{row.services !== 0 ? formatWholeKr(row.services) : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{row.goods !== 0 ? formatAmount(row.goods) : '-'}
|
||||
{row.goods !== 0 ? formatWholeKr(row.goods) : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{row.triangulation !== 0 ? formatAmount(row.triangulation) : '-'}
|
||||
{row.triangulation !== 0 ? formatWholeKr(row.triangulation) : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{typeBadge(row)}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
@@ -426,9 +412,9 @@ export function PeriodiskSammanstallningView() {
|
||||
))}
|
||||
<TableRow className="bg-muted/30 font-medium">
|
||||
<TableCell colSpan={2}>Totalt</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(data.totals.services)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(data.totals.goods)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(data.totals.triangulation)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatWholeKr(data.totals.services)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatWholeKr(data.totals.goods)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatWholeKr(data.totals.triangulation)}</TableCell>
|
||||
<TableCell colSpan={2} />
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { useState, useCallback, useMemo } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { ChevronDown, ChevronRight, AlertCircle } from 'lucide-react'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { formatAmount, formatDate } from '@/lib/utils'
|
||||
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||
import {
|
||||
createSourceLoader,
|
||||
@@ -299,9 +299,3 @@ function ExpansionContent({
|
||||
)
|
||||
}
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import { useCapability, useCompanyOptional } from '@/contexts/CompanyContext'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { UpgradeNote } from '@/components/billing/UpgradeNote'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
import { formatAmount } from '@/lib/utils'
|
||||
|
||||
interface SkatteverketStatus {
|
||||
connected: boolean
|
||||
@@ -107,10 +108,6 @@ interface Notice {
|
||||
text: string
|
||||
}
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
const ORG_NUMBER_MISSING_NOTICE: Notice = {
|
||||
kind: 'error',
|
||||
text: 'Organisationsnummer saknas. Ange det under Inställningar innan du använder Skatteverket-kopplingen.',
|
||||
|
||||
@@ -28,25 +28,11 @@ import {
|
||||
import type { VatDeclarationCheck } from '@/lib/reports/vat-declaration-checks'
|
||||
import type { RcBasisGap } from '@/lib/reports/rc-basis-gaps'
|
||||
import type { VatPeriodType } from '@/types'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { formatAmount, formatDate } from '@/lib/utils'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { QUIET_LINK_CLASS } from '@/components/ui/dry-table'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
/** API responses use the canonical envelope; error may be a string or an object. */
|
||||
function apiErrorMessage(json: unknown, fallback: string): string {
|
||||
const err = (json as { error?: unknown } | null)?.error
|
||||
if (typeof err === 'string') return err
|
||||
if (err && typeof err === 'object') {
|
||||
const message = (err as { message?: unknown }).message
|
||||
if (typeof message === 'string') return message
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
import { apiErrorMessage } from './api-error'
|
||||
|
||||
type SupplierType = 'eu_business' | 'non_eu_business' | 'swedish_business'
|
||||
type SupplyType = 'service' | 'goods'
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Small, shared readers for the `{ error }` envelope the report API routes
|
||||
* return. Deliberately NOT lib/errors getErrorMessage: that one rewrites known
|
||||
* patterns into user-facing Swedish, these just pick the string out.
|
||||
*/
|
||||
|
||||
/** A string error is itself; an object with a string `message` yields it; else the fallback. */
|
||||
export function parseApiError(error: unknown, fallback: string): string {
|
||||
if (typeof error === 'string') return error
|
||||
if (error && typeof error === 'object') {
|
||||
const message = (error as { message?: unknown }).message
|
||||
if (typeof message === 'string') return message
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
/** parseApiError applied to a JSON body's `error` field. */
|
||||
export function apiErrorMessage(json: unknown, fallback: string): string {
|
||||
return parseApiError((json as { error?: unknown } | null)?.error, fallback)
|
||||
}
|
||||
@@ -21,7 +21,8 @@ import { FyPicker } from '@/components/common/FyPicker'
|
||||
import { mostRecentEndedVatPeriod } from '@/lib/vat/period-defaults'
|
||||
import { resolveInitialVatPeriodSelection } from '@/lib/vat/period-selection'
|
||||
import { ContextPicker } from '@/components/common/ContextPicker'
|
||||
import { cn, formatDate } from '@/lib/utils'
|
||||
import { cn, formatAmount, formatDate } from '@/lib/utils'
|
||||
import { formatDateISO } from '@/lib/calendar/utils'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { formatLatestVouchers } from '@/lib/reports/latest-vouchers-format'
|
||||
@@ -88,10 +89,6 @@ import type {
|
||||
VatPeriodType,
|
||||
} from '@/types'
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
// Shared shells for the report bodies, so all views read as the same
|
||||
// instrument: Skeleton while loading, EmptyState when the period has no data,
|
||||
// a quiet destructive card on fetch errors (design.md primitives).
|
||||
@@ -1281,7 +1278,6 @@ function VatBookingCard({
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/** The Stegen header (concept Moms C): the filing pipeline as a clickable
|
||||
* horizontal stepper with honest per-step status subs. Statutory surface,
|
||||
* Swedish in both locales like the rest of the declaration. */
|
||||
@@ -2434,11 +2430,6 @@ interface SupplierLedgerData {
|
||||
|
||||
// Local calendar date (YYYY-MM-DD) for the reskontra "per datum" default:
|
||||
// toISOString() is UTC and rolls the date over an hour early in Sweden.
|
||||
function localIsoDate(): string {
|
||||
const now = new Date()
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// Shared "Per datum" control + export menu header for the two reskontra views
|
||||
// (#1020/#1021): pick an arbitrary as-of date and export PDF/Excel for it.
|
||||
function ReskontraToolbar({
|
||||
@@ -2482,7 +2473,7 @@ export function SupplierLedgerView({ periodId }: { periodId: string }) {
|
||||
const [data, setData] = useState<SupplierLedgerData | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [asOfDate, setAsOfDate] = useState(localIsoDate)
|
||||
const [asOfDate, setAsOfDate] = useState(() => formatDateISO(new Date()))
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
@@ -3227,7 +3218,7 @@ export function ARLedgerView({ periodId }: { periodId: string }) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [expandedCustomers, setExpandedCustomers] = useState<Set<string>>(new Set())
|
||||
const [asOfDate, setAsOfDate] = useState(localIsoDate)
|
||||
const [asOfDate, setAsOfDate] = useState(() => formatDateISO(new Date()))
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* "Ny lönekörning" as a modal: mirrors NewJournalEntryDialog. A successful
|
||||
* create navigates straight to the run detail page (the real workspace),
|
||||
* unmounting the host list page and this dialog with it.
|
||||
*/
|
||||
export default function NewSalaryRunDialog({ open, onOpenChange }: Props) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="sm:max-w-lg max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto"
|
||||
// Same convention as NewJournalEntryDialog: closing is explicit (the
|
||||
// header X or Avbryt), never an accidental Escape or backdrop click.
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ny lönekörning</DialogTitle>
|
||||
</DialogHeader>
|
||||
<NewSalaryRunForm onCancel={() => onOpenChange(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// Inner component so form state resets whenever the dialog reopens (Radix
|
||||
// unmounts DialogContent children on close).
|
||||
function NewSalaryRunForm({ onCancel }: { onCancel: () => void }) {
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const now = new Date()
|
||||
const defaultYear = now.getFullYear()
|
||||
const defaultMonth = now.getMonth() + 1
|
||||
const defaultPayDate = `${defaultYear}-${String(defaultMonth).padStart(2, '0')}-25`
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
|
||||
const form = new FormData(e.currentTarget)
|
||||
const body = {
|
||||
period_year: parseInt(form.get('period_year') as string),
|
||||
period_month: parseInt(form.get('period_month') as string),
|
||||
payment_date: form.get('payment_date') as string,
|
||||
voucher_series: form.get('voucher_series') as string || 'A',
|
||||
}
|
||||
|
||||
const res = await fetch('/api/salary/runs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
toast({ title: 'Lönekörning skapad' })
|
||||
router.push(`/salary/runs/${data.id}`)
|
||||
} else {
|
||||
const result = await res.json()
|
||||
toast({
|
||||
title: 'Kunde inte skapa lönekörning',
|
||||
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="period_year">År</Label>
|
||||
<Input id="period_year" name="period_year" type="number" defaultValue={defaultYear} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="period_month">Månad (1-12)</Label>
|
||||
<Input id="period_month" name="period_month" type="number" min="1" max="12" defaultValue={defaultMonth} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="payment_date">Utbetalningsdag</Label>
|
||||
<Input id="payment_date" name="payment_date" type="date" defaultValue={defaultPayDate} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="voucher_series">Verifikationsserie</Label>
|
||||
<Input id="voucher_series" name="voucher_series" defaultValue="A" maxLength={1} className="max-w-20" />
|
||||
<p className="text-xs text-muted-foreground">En bokstav A-Z. Standard: A</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? 'Skapar...' : 'Skapa och fortsätt'}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Semester card on the salary dashboard (payroll gap-closure 3.5).
|
||||
*
|
||||
* Shows the open vacation-ledger totals (remaining + saved days across
|
||||
* active employees) and hosts the year-close dialog: dry-run report first,
|
||||
* a working confirm second (soft-guard convention).
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Palmtree, Loader2 } from 'lucide-react'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
interface BalanceRow {
|
||||
employee_vacation_balance_id: string
|
||||
employee_name: string
|
||||
remaining_days: number
|
||||
saved_days_total: number
|
||||
forced_payout_days: number
|
||||
}
|
||||
|
||||
interface CloseReportRow {
|
||||
employee_name: string
|
||||
remaining_days: number
|
||||
saveable_days: number
|
||||
untaken_below_floor_days: number
|
||||
expiring_days: number
|
||||
next_year_entitled: number
|
||||
}
|
||||
|
||||
interface CloseReport {
|
||||
vacation_year_start: string
|
||||
vacation_year_end: string
|
||||
rows: CloseReportRow[]
|
||||
sek: {
|
||||
computed_liability: number
|
||||
booked_2920: number
|
||||
drift_2920: number
|
||||
adjustment_needed: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export function VacationBalanceCard({ canWrite }: { canWrite: boolean }) {
|
||||
const t = useTranslations('salary')
|
||||
const { toast } = useToast()
|
||||
const [rows, setRows] = useState<BalanceRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [report, setReport] = useState<CloseReport | null>(null)
|
||||
const [previewError, setPreviewError] = useState<string | null>(null)
|
||||
const [previewing, setPreviewing] = useState(false)
|
||||
const [closing, setClosing] = useState(false)
|
||||
|
||||
async function fetchBalances(): Promise<BalanceRow[] | null> {
|
||||
const res = await fetch('/api/salary/vacation-balances')
|
||||
if (!res.ok) return null
|
||||
const { data } = await res.json()
|
||||
return (data ?? []) as BalanceRow[]
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetchBalances().then((data) => {
|
||||
if (cancelled) return
|
||||
if (data) setRows(data)
|
||||
setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const totalRemaining = rows.reduce((s, r) => s + r.remaining_days, 0)
|
||||
const totalSaved = rows.reduce((s, r) => s + r.saved_days_total, 0)
|
||||
|
||||
async function openCloseDialog() {
|
||||
setDialogOpen(true)
|
||||
setPreviewing(true)
|
||||
setReport(null)
|
||||
setPreviewError(null)
|
||||
const res = await fetch('/api/salary/vacation-year-close', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dry_run: true }),
|
||||
})
|
||||
const body = await res.json().catch(() => null)
|
||||
if (res.ok && body) {
|
||||
setReport(body.data.report as CloseReport)
|
||||
} else {
|
||||
// Resolve via the parsed body plus the status: the route answers thrown
|
||||
// errors with the canonical envelope `{ error: { code, message } }`,
|
||||
// and rendering that object would crash React.
|
||||
setPreviewError(getErrorMessage(body, { statusCode: res.status }))
|
||||
}
|
||||
setPreviewing(false)
|
||||
}
|
||||
|
||||
async function confirmClose() {
|
||||
setClosing(true)
|
||||
const res = await fetch('/api/salary/vacation-year-close', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ book_adjustment: true }),
|
||||
})
|
||||
const body = await res.json().catch(() => null)
|
||||
if (res.ok) {
|
||||
toast({ title: t('vacation_close_done') })
|
||||
setDialogOpen(false)
|
||||
const refreshed = await fetchBalances()
|
||||
if (refreshed) setRows(refreshed)
|
||||
} else {
|
||||
toast({
|
||||
title: t('vacation_close_failed'),
|
||||
description: getErrorMessage(body, { statusCode: res.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
setClosing(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Palmtree className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-xs text-muted-foreground">{t('card_vacation_title')}</p>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">…</p>
|
||||
) : rows.length > 0 ? (
|
||||
<>
|
||||
<p className="font-sans text-lg font-medium tabular-nums leading-tight">
|
||||
{totalRemaining}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('card_vacation_detail', { employees: rows.length, saved: totalSaved })}
|
||||
</p>
|
||||
{canWrite && (
|
||||
<Button variant="outline" size="sm" className="mt-2" onClick={openCloseDialog}>
|
||||
{t('vacation_close_button')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t('card_vacation_none')}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('vacation_close_title')}</DialogTitle>
|
||||
<DialogDescription>{t('vacation_close_description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{previewing && (
|
||||
<div className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('vacation_close_previewing')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewError && <p className="text-sm text-destructive py-4">{previewError}</p>}
|
||||
|
||||
{report && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('vacation_close_period', {
|
||||
from: report.vacation_year_start,
|
||||
to: report.vacation_year_end,
|
||||
})}
|
||||
</p>
|
||||
<div className="max-h-64 overflow-y-auto border border-border rounded-lg">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<th className="text-left px-3 py-2">{t('vacation_close_col_employee')}</th>
|
||||
<th className="text-right px-3 py-2">{t('vacation_close_col_saved')}</th>
|
||||
<th className="text-right px-3 py-2">{t('vacation_close_col_flagged')}</th>
|
||||
<th className="text-right px-3 py-2">{t('vacation_close_col_expiring')}</th>
|
||||
<th className="text-right px-3 py-2">{t('vacation_close_col_next')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report.rows.map((row, idx) => (
|
||||
<tr key={idx} className="border-t border-border">
|
||||
<td className="px-3 py-2">{row.employee_name}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{row.saveable_days}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{row.untaken_below_floor_days}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{row.expiring_days}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{row.next_year_entitled}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="text-sm space-y-1">
|
||||
<p className="tabular-nums">
|
||||
{t('vacation_close_computed', {
|
||||
amount: formatCurrency(report.sek.computed_liability),
|
||||
})}
|
||||
</p>
|
||||
<p className="tabular-nums">
|
||||
{t('vacation_close_booked', { amount: formatCurrency(report.sek.booked_2920) })}
|
||||
</p>
|
||||
<p className="tabular-nums">
|
||||
{report.sek.adjustment_needed
|
||||
? t('vacation_close_drift', { amount: formatCurrency(report.sek.drift_2920) })
|
||||
: t('vacation_close_no_drift')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={closing}>
|
||||
{t('vacation_close_cancel')}
|
||||
</Button>
|
||||
<Button onClick={confirmClose} disabled={!report || closing}>
|
||||
{closing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('vacation_close_confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -24,8 +24,7 @@ import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { cn, formatDateLong } from '@/lib/utils'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
|
||||
type Kind = 'fact' | 'preference' | 'pattern' | 'correction'
|
||||
type Source = 'composer' | 'user_taught' | 'agent_learned' | 'derived'
|
||||
import type { FactKind as Kind, FactSource as Source } from '@/lib/agent-context/agent-competence'
|
||||
|
||||
interface AgentMemoryRow {
|
||||
id: string
|
||||
|
||||
@@ -15,7 +15,7 @@ import { SettingsGroup, SettingsRowNote } from '@/components/settings/SettingsRo
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
|
||||
type Tier = 'horizontal' | 'vertical' | 'modifier'
|
||||
import type { AtomTier as Tier } from '@/lib/agent-context/agent-competence'
|
||||
|
||||
interface AtomMeta {
|
||||
id: string
|
||||
|
||||
@@ -87,5 +87,3 @@ export const SETTINGS_SECTIONS: Record<string, ComponentType> = {
|
||||
whatsapp: WhatsAppSettingsContent,
|
||||
mail: MailSettingsContent,
|
||||
}
|
||||
|
||||
export type SettingsSectionId = keyof typeof SETTINGS_SECTIONS
|
||||
|
||||
@@ -31,7 +31,7 @@ import { formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { exceedsHostedUploadLimit } from '@/lib/documents/upload-size'
|
||||
import { uploadViaSignedUrl } from '@/lib/documents/direct-upload'
|
||||
import { cn, formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { cn, formatAmount, formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { getDisplayTotal } from '@/lib/invoices/rounding'
|
||||
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
@@ -78,10 +78,6 @@ function RequiredMark() {
|
||||
return <span className="text-destructive ml-0.5" aria-hidden="true">*</span>
|
||||
}
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
// Accepts sv-SE formatted numbers ("12 345,67" with regular, no-break or
|
||||
// narrow no-break group separators) as well as plain "12345.67".
|
||||
function parseFlexibleNumber(s: string): number | null {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { CalendarClock } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { formatAmount, formatCurrency, formatDate } from '@/lib/utils'
|
||||
import {
|
||||
resolveReverseChargeRate,
|
||||
isReverseChargeBasisAccount,
|
||||
@@ -57,10 +57,6 @@ interface SupplierInvoiceReviewContentProps {
|
||||
total: number
|
||||
}
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
interface JournalPreviewLine {
|
||||
account_number: string
|
||||
description: string
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { ArrowUpRight, ArrowDownRight } from 'lucide-react'
|
||||
import VatTreatmentSelect from './VatTreatmentSelect'
|
||||
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from './transaction-types'
|
||||
import type { TransactionWithInvoice } from './transaction-types'
|
||||
import type { TransactionCategory, VatTreatment } from '@/types'
|
||||
|
||||
interface CategoryExpandedDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
transaction: TransactionWithInvoice | null
|
||||
onSelectCategory: (category: TransactionCategory, vatTreatment?: VatTreatment) => void
|
||||
isProcessing: boolean
|
||||
}
|
||||
|
||||
export default function CategoryExpandedDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
transaction,
|
||||
onSelectCategory,
|
||||
isProcessing,
|
||||
}: CategoryExpandedDialogProps) {
|
||||
const [vatTreatment, setVatTreatment] = useState<VatTreatment | 'none'>('standard_25')
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setVatTreatment('standard_25')
|
||||
}
|
||||
}, [open, transaction?.id])
|
||||
|
||||
if (!transaction) return null
|
||||
|
||||
const isIncome = transaction.amount > 0
|
||||
|
||||
const handleSelectCategory = (category: TransactionCategory) => {
|
||||
const resolvedVat = vatTreatment === 'none' ? undefined : vatTreatment
|
||||
onSelectCategory(category, resolvedVat)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={isProcessing ? undefined : onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Välj kategori</DialogTitle>
|
||||
<DialogDescription>
|
||||
Välj rätt kategori för att bokföra transaktionen
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Transaction summary */}
|
||||
<div className="flex items-center gap-3 rounded-lg border p-3">
|
||||
<div
|
||||
className={`h-9 w-9 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
isIncome
|
||||
? 'bg-success/10 text-success'
|
||||
: 'bg-destructive/10 text-destructive'
|
||||
}`}
|
||||
>
|
||||
{isIncome ? (
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ArrowDownRight className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm truncate">{transaction.description}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatDate(transaction.date)}</p>
|
||||
</div>
|
||||
<p className={`font-medium text-sm flex-shrink-0 ${isIncome ? 'text-success' : ''}`}>
|
||||
{isIncome ? '+' : ''}
|
||||
{formatCurrency(transaction.amount, transaction.currency)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* VAT treatment selector */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground mb-2">Momsbehandling</h4>
|
||||
<VatTreatmentSelect
|
||||
value={vatTreatment}
|
||||
onValueChange={setVatTreatment}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category grid */}
|
||||
<div className="space-y-4 py-2">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground mb-2">Kostnader</h4>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{EXPENSE_CATEGORIES.map((cat) => (
|
||||
<Button
|
||||
key={cat.value}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="justify-start text-xs h-auto py-1.5"
|
||||
onClick={() => handleSelectCategory(cat.value)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<div className="flex flex-col items-start">
|
||||
<span>{cat.label}</span>
|
||||
{cat.account && <span className="text-[10px] text-muted-foreground">{cat.account}</span>}
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-muted-foreground mb-2">Intäkter</h4>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{INCOME_CATEGORIES.map((cat) => (
|
||||
<Button
|
||||
key={cat.value}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="justify-start text-xs h-auto py-1.5"
|
||||
onClick={() => handleSelectCategory(cat.value)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<div className="flex flex-col items-start">
|
||||
<span>{cat.label}</span>
|
||||
{cat.account && <span className="text-[10px] text-muted-foreground">{cat.account}</span>}
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -20,10 +20,9 @@ type OpenInvoice = Invoice & { customer?: Customer }
|
||||
interface InvoicePickerProps {
|
||||
transaction: TransactionWithInvoice
|
||||
onSelect: (invoice: OpenInvoice) => void
|
||||
isProcessing: boolean
|
||||
}
|
||||
|
||||
export default function InvoicePicker({ transaction, onSelect, isProcessing }: InvoicePickerProps) {
|
||||
export default function InvoicePicker({ transaction, onSelect }: InvoicePickerProps) {
|
||||
const t = useTranslations('tx_invoice_picker')
|
||||
const { company } = useCompany()
|
||||
const supabase = useMemo(() => createClient(), [])
|
||||
@@ -160,13 +159,11 @@ export default function InvoicePicker({ transaction, onSelect, isProcessing }: I
|
||||
key={invoice.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(invoice)}
|
||||
disabled={isProcessing}
|
||||
className={cn(
|
||||
'w-full text-left rounded-lg border px-3 py-2.5 transition-colors',
|
||||
'hover:bg-muted/50 focus:outline-none focus:ring-2 focus:ring-ring',
|
||||
exact && 'border-success/50 bg-success/5',
|
||||
close && 'border-primary/30',
|
||||
isProcessing && 'opacity-50 pointer-events-none'
|
||||
close && 'border-primary/30'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
|
||||
@@ -19,13 +19,11 @@ type OpenSupplierInvoice = SupplierInvoice & { supplier?: Supplier }
|
||||
interface SupplierInvoicePickerProps {
|
||||
transaction: TransactionWithInvoice
|
||||
onSelect: (invoice: OpenSupplierInvoice) => void
|
||||
isProcessing: boolean
|
||||
}
|
||||
|
||||
export default function SupplierInvoicePicker({
|
||||
transaction,
|
||||
onSelect,
|
||||
isProcessing,
|
||||
}: SupplierInvoicePickerProps) {
|
||||
const { company } = useCompany()
|
||||
const supabase = useMemo(() => createClient(), [])
|
||||
@@ -155,13 +153,11 @@ export default function SupplierInvoicePicker({
|
||||
key={invoice.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(invoice)}
|
||||
disabled={isProcessing}
|
||||
className={cn(
|
||||
'w-full text-left rounded-lg border px-3 py-2.5 transition-colors',
|
||||
'hover:bg-muted/50 focus:outline-none focus:ring-2 focus:ring-ring',
|
||||
exact && 'border-success/50 bg-success/5',
|
||||
close && 'border-primary/30',
|
||||
isProcessing && 'opacity-50 pointer-events-none'
|
||||
close && 'border-primary/30'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
|
||||
@@ -239,8 +239,8 @@ interface TemplatePickerProps {
|
||||
direction: 'expense' | 'income'
|
||||
entityType?: EntityType
|
||||
suggestedTemplates?: SuggestedTemplate[]
|
||||
recentTemplateIds?: string[]
|
||||
onSelect: (template: BookingTemplate) => void
|
||||
|
||||
onSelectCounterparty?: (templateId: string) => void
|
||||
onPickLibraryTemplate?: (raw: BookingTemplateLibrary) => void
|
||||
// When provided, the search field also matches the company's active chart
|
||||
|
||||
@@ -42,11 +42,6 @@ export type CategorizeHandler = (
|
||||
dimensions?: Record<string, string>
|
||||
) => Promise<string | null>
|
||||
|
||||
export type MatchInvoiceHandler = (
|
||||
transactionId: string,
|
||||
invoiceId: string
|
||||
) => Promise<boolean>
|
||||
|
||||
// Category option type. `label` retains the Swedish text for back-compat and
|
||||
// non-React consumers; `labelKey` is the next-intl key under the `tx_categories`
|
||||
// namespace that React components should prefer.
|
||||
|
||||
@@ -6,12 +6,7 @@ import { useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
ReceiptText,
|
||||
Users,
|
||||
ArrowLeftRight,
|
||||
Building2,
|
||||
FileText,
|
||||
Calendar,
|
||||
Plus,
|
||||
TrendingUp,
|
||||
type LucideIcon,
|
||||
@@ -105,20 +100,6 @@ export function EmptyState({
|
||||
|
||||
// Preset empty states for common pages
|
||||
|
||||
export function EmptyInvoices({ onAction }: { onAction?: () => void } = {}) {
|
||||
const t = useTranslations('empty')
|
||||
return (
|
||||
<EmptyState
|
||||
icon={ReceiptText}
|
||||
title={t('preset_invoices_title')}
|
||||
description={t('preset_invoices_description')}
|
||||
actionLabel={t('preset_invoices_action')}
|
||||
actionHref={onAction ? undefined : '/invoices?new=1'}
|
||||
onAction={onAction}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyCustomers({ onAction }: { onAction?: () => void } = {}) {
|
||||
const t = useTranslations('empty')
|
||||
return (
|
||||
@@ -133,60 +114,6 @@ export function EmptyCustomers({ onAction }: { onAction?: () => void } = {}) {
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyTransactions() {
|
||||
const t = useTranslations('empty')
|
||||
return (
|
||||
<EmptyState
|
||||
icon={ArrowLeftRight}
|
||||
title={t('preset_transactions_title')}
|
||||
description={t('preset_transactions_description')}
|
||||
actionLabel={t('preset_transactions_action')}
|
||||
actionHref="/import"
|
||||
supportHint
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyDeadlines() {
|
||||
const t = useTranslations('empty')
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Calendar}
|
||||
title={t('preset_deadlines_title')}
|
||||
description={t('preset_deadlines_description')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function NoBankConnected() {
|
||||
const t = useTranslations('empty')
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Building2}
|
||||
title={t('preset_no_bank_title')}
|
||||
description={t('preset_no_bank_description')}
|
||||
actionLabel={t('preset_no_bank_action')}
|
||||
actionHref="/import"
|
||||
supportHint
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyReports() {
|
||||
const t = useTranslations('empty')
|
||||
return (
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title={t('preset_reports_title')}
|
||||
description={t('preset_reports_description')}
|
||||
actionLabel={t('preset_reports_action')}
|
||||
actionHref="/invoices?new=1"
|
||||
secondaryActionLabel={t('preset_reports_secondary')}
|
||||
secondaryActionHref="/import"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Byrå cockpit: no client companies yet. A preset, not a bare <EmptyState
|
||||
* icon={TrendingUp} />, because the only caller is a Server Component: a
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Upload } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface FallbackPromptProps {
|
||||
message: string
|
||||
linkHref: string
|
||||
linkLabel: string
|
||||
}
|
||||
|
||||
export function FallbackPrompt({ message, linkHref, linkLabel }: FallbackPromptProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-border bg-muted/50 p-4">
|
||||
<Upload className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
<p className="flex-1 text-sm text-muted-foreground">{message}</p>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={linkHref}>{linkLabel}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Sheet = DialogPrimitive.Root
|
||||
|
||||
const SheetTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const SheetClose = DialogPrimitive.Close
|
||||
|
||||
const SheetPortal = DialogPrimitive.Portal
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 dark:bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
type SheetSide = "top" | "right" | "bottom" | "left"
|
||||
|
||||
const sideClasses: Record<SheetSide, string> = {
|
||||
top:
|
||||
"inset-x-0 top-0 border-b data-[state=open]:slide-in-from-top data-[state=closed]:slide-out-to-top",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm data-[state=open]:slide-in-from-right data-[state=closed]:slide-out-to-right",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=open]:slide-in-from-bottom data-[state=closed]:slide-out-to-bottom",
|
||||
left:
|
||||
"inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm data-[state=open]:slide-in-from-left data-[state=closed]:slide-out-to-left",
|
||||
}
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
|
||||
side?: SheetSide
|
||||
}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ className, children, side = "right", onInteractOutside, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
// Same companion-overlay guard as DialogContent: a portaled panel
|
||||
// marked data-dialog-companion counts as an inside interaction.
|
||||
onInteractOutside={(event) => {
|
||||
onInteractOutside?.(event)
|
||||
const target = event.target
|
||||
if (target instanceof Element && target.closest('[data-dialog-companion]')) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-background p-6 transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-200 data-[state=open]:duration-300",
|
||||
sideClasses[side],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
SheetContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-1.5 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetHeader.displayName = "SheetHeader"
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end sm:space-x-2 sm:gap-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetFooter.displayName = "SheetFooter"
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
// data-ph-unmask: sheet titles are static i18n chrome in session replays;
|
||||
// a title carrying user data adds data-ph-mask at the call site.
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
data-ph-unmask=""
|
||||
className={cn("break-words text-base tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
data-ph-unmask=""
|
||||
className={cn("break-words text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
Reference in New Issue
Block a user