feat: open-source under AGPL-3.0, redesign UI to grayscale palette, add uncategorize API, fix VAT account names
Add LICENSE (AGPL-3.0-or-later), CONTRIBUTING.md, SECURITY.md, DCO, and NOTICE files. Rewrite README for open-source audience with self-hosting instructions. Redesign color palette to grayscale chrome theme across all components. Add transaction uncategorize API route with tests. Fix VAT account name mismatches in migration 052. Improve import page with SIE file support and loading skeleton. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
29240738fa
commit
f3ec634a46
@@ -3,12 +3,13 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Plus, Search, Users, Building, Globe, User } from 'lucide-react'
|
||||
import { Plus, Search, Users, MapPin, Mail, Hash } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import CustomerForm from '@/components/customers/CustomerForm'
|
||||
import { EmptyCustomers } from '@/components/ui/empty-state'
|
||||
import Link from 'next/link'
|
||||
@@ -21,11 +22,20 @@ const customerTypeLabels: Record<CustomerType, string> = {
|
||||
non_eu_business: 'Utanför EU',
|
||||
}
|
||||
|
||||
const customerTypeIcons: Record<CustomerType, React.ElementType> = {
|
||||
individual: User,
|
||||
swedish_business: Building,
|
||||
eu_business: Globe,
|
||||
non_eu_business: Globe,
|
||||
const customerTypeColors: Record<CustomerType, string> = {
|
||||
individual: 'bg-blue-50 text-blue-700 border-blue-200',
|
||||
swedish_business: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
eu_business: 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
||||
non_eu_business: 'bg-violet-50 text-violet-700 border-violet-200',
|
||||
}
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.map((w) => w[0])
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
}
|
||||
|
||||
export default function CustomersPage() {
|
||||
@@ -167,49 +177,53 @@ export default function CustomersPage() {
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredCustomers.map((customer) => {
|
||||
const Icon = customerTypeIcons[customer.customer_type]
|
||||
return (
|
||||
{filteredCustomers.map((customer) => (
|
||||
<Link key={customer.id} href={`/customers/${customer.id}`}>
|
||||
<Card className="hover:border-primary/50 transition-colors cursor-pointer h-full">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Icon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">{customer.name}</CardTitle>
|
||||
<CardDescription>{customer.email || 'Ingen e-post'}</CardDescription>
|
||||
</div>
|
||||
<Card className="hover:border-primary/50 transition-colors cursor-pointer h-full group">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-3.5">
|
||||
<div className="h-11 w-11 rounded-full bg-primary/10 flex items-center justify-center shrink-0 text-sm font-semibold text-primary tracking-tight">
|
||||
{getInitials(customer.name)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="text-base truncate group-hover:text-primary transition-colors">{customer.name}</CardTitle>
|
||||
<span className={cn(
|
||||
'inline-block mt-1 text-xs font-medium px-2 py-0.5 rounded-full border',
|
||||
customerTypeColors[customer.customer_type]
|
||||
)}>
|
||||
{customerTypeLabels[customer.customer_type]}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{customerTypeLabels[customer.customer_type]}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
{customer.org_number && (
|
||||
<p>Org.nr: {customer.org_number}</p>
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
{customer.email && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{customer.email}</span>
|
||||
</div>
|
||||
)}
|
||||
{customer.vat_number && (
|
||||
<p className="flex items-center gap-1">
|
||||
VAT: {customer.vat_number}
|
||||
{customer.org_number && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Hash className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{customer.org_number}</span>
|
||||
{customer.vat_number_validated && (
|
||||
<Badge variant="success" className="text-xs">Verifierad</Badge>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{customer.city && (
|
||||
<p>{customer.city}, {customer.country}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{customer.city}, {customer.country}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -19,9 +19,9 @@ import type { SupplierInvoice, SupplierInvoiceItem, SupplierInvoicePayment, Enti
|
||||
const statusConfig: Record<string, { label: string; color: string }> = {
|
||||
registered: { label: 'Obetald', color: 'bg-blue-100 text-blue-800' },
|
||||
approved: { label: 'Obetald', color: 'bg-yellow-100 text-yellow-800' },
|
||||
paid: { label: 'Betald', color: 'bg-green-100 text-green-800' },
|
||||
paid: { label: 'Betald', color: 'bg-success/10 text-success' },
|
||||
partially_paid: { label: 'Delbetald', color: 'bg-orange-100 text-orange-800' },
|
||||
overdue: { label: 'Förfallen', color: 'bg-red-100 text-red-800' },
|
||||
overdue: { label: 'Förfallen', color: 'bg-destructive/10 text-destructive' },
|
||||
disputed: { label: 'Tvist', color: 'bg-purple-100 text-purple-800' },
|
||||
credited: { label: 'Krediterad', color: 'bg-gray-100 text-gray-800' },
|
||||
}
|
||||
@@ -322,7 +322,7 @@ export default function ExpenseDetailPage() {
|
||||
</div>
|
||||
<div className="flex justify-between pt-2">
|
||||
<span className="text-muted-foreground">Betalt</span>
|
||||
<span className="font-mono text-green-600">{formatAmount(invoice.paid_amount)} {invoice.currency}</span>
|
||||
<span className="font-mono text-success">{formatAmount(invoice.paid_amount)} {invoice.currency}</span>
|
||||
</div>
|
||||
<div className="flex justify-between font-semibold">
|
||||
<span>Kvar att betala</span>
|
||||
|
||||
@@ -199,7 +199,7 @@ const glossaryTerms: GlossaryTerm[] = [
|
||||
const categoryConfig = {
|
||||
skatt: { label: 'Skatt', icon: Calculator, color: 'bg-orange-500/10 text-orange-600' },
|
||||
moms: { label: 'Moms', icon: Receipt, color: 'bg-blue-500/10 text-blue-600' },
|
||||
faktura: { label: 'Faktura', icon: FileText, color: 'bg-green-500/10 text-green-600' },
|
||||
faktura: { label: 'Faktura', icon: FileText, color: 'bg-success/10 text-success' },
|
||||
bokföring: { label: 'Bokföring', icon: BookOpen, color: 'bg-purple-500/10 text-purple-600' },
|
||||
bank: { label: 'Bank', icon: Building2, color: 'bg-pink-500/10 text-pink-600' },
|
||||
företag: { label: 'Företag', icon: Building2, color: 'bg-cyan-500/10 text-cyan-600' },
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { ArrowLeftRight, FileText, ArrowLeft } from 'lucide-react'
|
||||
import { ArrowLeftRight, FileText, ArrowLeft, Landmark, Loader2 } from 'lucide-react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { BankSelector, type Bank } from '@/extensions/general/enable-banking/components/BankSelector'
|
||||
import { BankConnectionStatus } from '@/extensions/general/enable-banking/components/BankConnectionStatus'
|
||||
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
|
||||
import type { BankConnection } from '@/types'
|
||||
|
||||
// Bank file import components
|
||||
import BankFileUploadStep from '@/components/import/BankFileUploadStep'
|
||||
@@ -535,14 +540,209 @@ function SIEImportWizard() {
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PSD2 Bank Connection (inline, from Enable Banking extension)
|
||||
// ============================================================
|
||||
|
||||
function PSD2ConnectWizard() {
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
const { dialogProps, confirm } = useDestructiveConfirm()
|
||||
|
||||
const [bankConnections, setBankConnections] = useState<BankConnection[]>([])
|
||||
const [syncingConnectionId, setSyncingConnectionId] = useState<string | null>(null)
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const [connectingBankName, setConnectingBankName] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetchConnections()
|
||||
}, [])
|
||||
|
||||
async function fetchConnections() {
|
||||
setIsLoading(true)
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return
|
||||
|
||||
const { data: connections } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
setBankConnections(connections || [])
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
async function handleConnectBank(bank: Bank) {
|
||||
setIsConnecting(true)
|
||||
setConnectingBankName(bank.name)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/extensions/ext/enable-banking/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ aspsp_name: bank.name, aspsp_country: bank.country }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error)
|
||||
}
|
||||
|
||||
window.location.href = data.authorization_url
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error instanceof Error ? error.message : 'Kunde inte ansluta bank',
|
||||
variant: 'destructive',
|
||||
})
|
||||
setIsConnecting(false)
|
||||
setConnectingBankName(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSyncTransactions(connectionId: string) {
|
||||
setSyncingConnectionId(connectionId)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/extensions/ext/enable-banking/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ connection_id: connectionId }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error)
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Synkronisering klar',
|
||||
description: `${data.imported} nya transaktioner importerade`,
|
||||
})
|
||||
|
||||
fetchConnections()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error instanceof Error ? error.message : 'Synkronisering misslyckades',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
setSyncingConnectionId(null)
|
||||
}
|
||||
|
||||
async function handleDisconnectBank(connectionId: string) {
|
||||
const ok = await confirm({
|
||||
title: 'Koppla bort bank?',
|
||||
description: 'PSD2-samtycket kommer återkallas. Befintliga transaktioner påverkas inte.',
|
||||
confirmLabel: 'Koppla bort',
|
||||
variant: 'warning',
|
||||
})
|
||||
if (!ok) return
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/extensions/ext/enable-banking/disconnect', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ connection_id: connectionId }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new Error(data.error || 'Disconnect failed')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Bank bortkopplad',
|
||||
description: 'Bankanslutningen och PSD2-samtycket har återkallats',
|
||||
})
|
||||
fetchConnections()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error instanceof Error ? error.message : 'Kunde inte koppla bort bank',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const activeConnections = bankConnections.filter((c) => c.status === 'active')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<DestructiveConfirmDialog {...dialogProps} />
|
||||
|
||||
{/* Connected banks */}
|
||||
{activeConnections.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Anslutna banker</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{activeConnections.map((connection) => (
|
||||
<BankConnectionStatus
|
||||
key={connection.id}
|
||||
connection={connection}
|
||||
onSync={handleSyncTransactions}
|
||||
onDisconnect={handleDisconnectBank}
|
||||
isSyncing={syncingConnectionId === connection.id}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Connect new bank */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Anslut din bank</CardTitle>
|
||||
<CardDescription>
|
||||
Välj din bank nedan för att koppla ditt konto via PSD2. Transaktioner synkas automatiskt varje dag.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BankSelector
|
||||
onConnect={handleConnectBank}
|
||||
isConnecting={isConnecting}
|
||||
connectingBankName={connectingBankName}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Import Page with Selection Cards
|
||||
// ============================================================
|
||||
|
||||
type ImportMode = null | 'bank' | 'sie'
|
||||
type ImportMode = null | 'psd2' | 'bank' | 'sie'
|
||||
|
||||
export default function ImportPage() {
|
||||
const [mode, setMode] = useState<ImportMode>(null)
|
||||
const [hasBankingExtension, setHasBankingExtension] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/extensions/toggles/general/enable-banking')
|
||||
.then(res => res.ok ? res.json() : null)
|
||||
.then(json => {
|
||||
if (json?.data?.enabled) setHasBankingExtension(true)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -555,7 +755,29 @@ export default function ImportPage() {
|
||||
</div>
|
||||
|
||||
{mode === null && (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className={`grid gap-4 ${hasBankingExtension ? 'md:grid-cols-3' : 'md:grid-cols-2'}`}>
|
||||
{hasBankingExtension && (
|
||||
<Card
|
||||
className="cursor-pointer hover:border-primary/50 transition-colors border-primary/20 relative"
|
||||
onClick={() => setMode('psd2')}
|
||||
>
|
||||
<CardContent className="pt-6 pb-6 flex flex-col items-center text-center space-y-3">
|
||||
<div className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Landmark className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Koppla bank</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Anslut ditt bankkonto direkt och synka transaktioner automatiskt via PSD2.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-primary font-medium">
|
||||
Rekommenderat
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card
|
||||
className="cursor-pointer hover:border-primary/50 transition-colors"
|
||||
onClick={() => setMode('bank')}
|
||||
@@ -605,6 +827,7 @@ export default function ImportPage() {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{mode === 'psd2' && <PSD2ConnectWizard />}
|
||||
{mode === 'bank' && <BankFileImportWizard />}
|
||||
{mode === 'sie' && <SIEImportWizard />}
|
||||
</div>
|
||||
|
||||
@@ -697,8 +697,8 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
{reminder.response_type === 'marked_paid' ? (
|
||||
<>
|
||||
<CheckCircle className="h-3 w-3 text-green-600" />
|
||||
<span className="text-xs text-green-600">Kunden markerat som betald</span>
|
||||
<CheckCircle className="h-3 w-3 text-success" />
|
||||
<span className="text-xs text-success">Kunden markerat som betald</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -1,9 +1,52 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function DashboardLoading() {
|
||||
return (
|
||||
<div className="space-y-6 animate-pulse">
|
||||
<div className="h-8 bg-muted rounded w-48" />
|
||||
<div className="h-4 bg-muted rounded w-96" />
|
||||
<div className="h-64 bg-muted rounded" />
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="rounded-xl border border-border/60 bg-card p-4 space-y-2">
|
||||
<Skeleton className="h-3.5 w-20" />
|
||||
<Skeleton className="h-7 w-28" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2.5">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} className="h-10 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Alerts + Deadlines */}
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{/* Alerts */}
|
||||
<div className="rounded-xl border border-border/60 bg-card p-4 space-y-3">
|
||||
<Skeleton className="h-5 w-24" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
|
||||
{/* Deadlines */}
|
||||
<div className="rounded-xl border border-border/60 bg-card p-4 space-y-3">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<Skeleton className="h-4 w-36" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -296,7 +296,7 @@ function TrialBalanceView({ periodId }: { periodId: string }) {
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Saldobalans</CardTitle>
|
||||
{data.isBalanced ? (
|
||||
<Badge className="bg-green-100 text-green-800">Balanserad</Badge>
|
||||
<Badge className="bg-success/10 text-success">Balanserad</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive">Ej balanserad</Badge>
|
||||
)}
|
||||
@@ -343,10 +343,10 @@ function TrialBalanceView({ periodId }: { periodId: string }) {
|
||||
<td className="py-2 text-right">
|
||||
{formatAmount(data.rows.reduce((s, r) => s + r.period_credit, 0))}
|
||||
</td>
|
||||
<td className={`py-2 text-right ${data.isBalanced ? 'text-green-600' : 'text-red-600'}`}>
|
||||
<td className={`py-2 text-right ${data.isBalanced ? 'text-success' : 'text-destructive'}`}>
|
||||
{formatAmount(data.totalDebit)}
|
||||
</td>
|
||||
<td className={`py-2 text-right ${data.isBalanced ? 'text-green-600' : 'text-red-600'}`}>
|
||||
<td className={`py-2 text-right ${data.isBalanced ? 'text-success' : 'text-destructive'}`}>
|
||||
{formatAmount(data.totalCredit)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -468,7 +468,7 @@ function IncomeStatementView({ periodId }: { periodId: string }) {
|
||||
<CardContent className="py-4">
|
||||
<div className="flex justify-between font-bold text-lg">
|
||||
<span>Rörelseresultat</span>
|
||||
<span className={data.total_revenue - data.total_expenses >= 0 ? 'text-green-600' : 'text-red-600'}>
|
||||
<span className={data.total_revenue - data.total_expenses >= 0 ? 'text-success' : 'text-destructive'}>
|
||||
{formatAmount(data.total_revenue - data.total_expenses)} kr
|
||||
</span>
|
||||
</div>
|
||||
@@ -496,7 +496,7 @@ function IncomeStatementView({ periodId }: { periodId: string }) {
|
||||
<CardContent className="py-4">
|
||||
<div className="flex justify-between font-bold text-xl">
|
||||
<span>Årets resultat</span>
|
||||
<span className={data.net_result >= 0 ? 'text-green-600' : 'text-red-600'}>
|
||||
<span className={data.net_result >= 0 ? 'text-success' : 'text-destructive'}>
|
||||
{formatAmount(data.net_result)} kr
|
||||
</span>
|
||||
</div>
|
||||
@@ -599,7 +599,7 @@ function BalanceSheetView({ periodId }: { periodId: string }) {
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-bold text-lg">Balanscheck</span>
|
||||
{isBalanced ? (
|
||||
<Badge className="bg-green-100 text-green-800 text-base px-3 py-1">
|
||||
<Badge className="bg-success/10 text-success text-base px-3 py-1">
|
||||
Balanserar
|
||||
</Badge>
|
||||
) : (
|
||||
@@ -607,7 +607,7 @@ function BalanceSheetView({ periodId }: { periodId: string }) {
|
||||
<Badge variant="destructive" className="text-base px-3 py-1">
|
||||
Balanserar ej
|
||||
</Badge>
|
||||
<p className="text-sm text-red-600 mt-1">
|
||||
<p className="text-sm text-destructive mt-1">
|
||||
Differens: {formatAmount(Math.abs(data.total_assets - data.total_equity_liabilities))} kr
|
||||
</p>
|
||||
</div>
|
||||
@@ -817,7 +817,7 @@ function VatDeclarationView() {
|
||||
data.rutor.ruta49 > 0
|
||||
? 'bg-orange-100 text-orange-800'
|
||||
: data.rutor.ruta49 < 0
|
||||
? 'bg-green-100 text-green-800'
|
||||
? 'bg-success/10 text-success'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
>
|
||||
@@ -946,7 +946,7 @@ function VatDeclarationView() {
|
||||
data.rutor.ruta49 > 0
|
||||
? 'text-orange-600'
|
||||
: data.rutor.ruta49 < 0
|
||||
? 'text-green-600'
|
||||
? 'text-success'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
@@ -1106,7 +1106,7 @@ function SupplierLedgerView({ periodId }: { periodId: string }) {
|
||||
<CardTitle className="text-sm text-muted-foreground">Ej förfallet</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold text-green-600">{formatAmount(ledger.total_current)} kr</p>
|
||||
<p className="text-2xl font-bold text-success">{formatAmount(ledger.total_current)} kr</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
@@ -1114,7 +1114,7 @@ function SupplierLedgerView({ periodId }: { periodId: string }) {
|
||||
<CardTitle className="text-sm text-muted-foreground">Förfallet</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold text-red-600">{formatAmount(ledger.total_overdue)} kr</p>
|
||||
<p className="text-2xl font-bold text-destructive">{formatAmount(ledger.total_overdue)} kr</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -1146,7 +1146,7 @@ function SupplierLedgerView({ periodId }: { periodId: string }) {
|
||||
<td className="py-2 text-right">{entry.days_1_30 > 0 ? formatAmount(entry.days_1_30) : ''}</td>
|
||||
<td className="py-2 text-right">{entry.days_31_60 > 0 ? formatAmount(entry.days_31_60) : ''}</td>
|
||||
<td className="py-2 text-right">{entry.days_61_90 > 0 ? formatAmount(entry.days_61_90) : ''}</td>
|
||||
<td className="py-2 text-right text-red-600">{entry.days_90_plus > 0 ? formatAmount(entry.days_90_plus) : ''}</td>
|
||||
<td className="py-2 text-right text-destructive">{entry.days_90_plus > 0 ? formatAmount(entry.days_90_plus) : ''}</td>
|
||||
<td className="py-2 text-right font-semibold">{formatAmount(entry.total_outstanding)}</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -1158,7 +1158,7 @@ function SupplierLedgerView({ periodId }: { periodId: string }) {
|
||||
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_1_30, 0))}</td>
|
||||
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_31_60, 0))}</td>
|
||||
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_61_90, 0))}</td>
|
||||
<td className="py-2 text-right text-red-600">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_90_plus, 0))}</td>
|
||||
<td className="py-2 text-right text-destructive">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_90_plus, 0))}</td>
|
||||
<td className="py-2 text-right">{formatAmount(ledger.total_outstanding)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
@@ -1185,13 +1185,13 @@ function SupplierLedgerView({ periodId }: { periodId: string }) {
|
||||
</div>
|
||||
<div className="flex justify-between pt-2 border-t font-semibold">
|
||||
<span>Differens</span>
|
||||
<span className={reconciliation.is_reconciled ? 'text-green-600' : 'text-red-600'}>
|
||||
<span className={reconciliation.is_reconciled ? 'text-success' : 'text-destructive'}>
|
||||
{formatAmount(reconciliation.difference)} kr
|
||||
</span>
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
{reconciliation.is_reconciled ? (
|
||||
<Badge className="bg-green-100 text-green-800">Avstämd</Badge>
|
||||
<Badge className="bg-success/10 text-success">Avstämd</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive">Ej avstämd - kontrollera bokföring</Badge>
|
||||
)}
|
||||
@@ -1704,7 +1704,7 @@ function ARLedgerView({ periodId }: { periodId: string }) {
|
||||
<CardTitle className="text-sm text-muted-foreground">Ej förfallet</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold text-green-600">{formatAmount(ledger.total_current)} kr</p>
|
||||
<p className="text-2xl font-bold text-success">{formatAmount(ledger.total_current)} kr</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
@@ -1712,7 +1712,7 @@ function ARLedgerView({ periodId }: { periodId: string }) {
|
||||
<CardTitle className="text-sm text-muted-foreground">Förfallet</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold text-red-600">{formatAmount(ledger.total_overdue)} kr</p>
|
||||
<p className="text-2xl font-bold text-destructive">{formatAmount(ledger.total_overdue)} kr</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -1758,7 +1758,7 @@ function ARLedgerView({ periodId }: { periodId: string }) {
|
||||
<td className="py-2 text-right">{entry.days_1_30 > 0 ? formatAmount(entry.days_1_30) : ''}</td>
|
||||
<td className="py-2 text-right">{entry.days_31_60 > 0 ? formatAmount(entry.days_31_60) : ''}</td>
|
||||
<td className="py-2 text-right">{entry.days_61_90 > 0 ? formatAmount(entry.days_61_90) : ''}</td>
|
||||
<td className="py-2 text-right text-red-600">{entry.days_90_plus > 0 ? formatAmount(entry.days_90_plus) : ''}</td>
|
||||
<td className="py-2 text-right text-destructive">{entry.days_90_plus > 0 ? formatAmount(entry.days_90_plus) : ''}</td>
|
||||
<td className="py-2 text-right font-semibold">{formatAmount(entry.total_outstanding)}</td>
|
||||
</tr>
|
||||
{isExpanded && entry.invoices.map((inv) => (
|
||||
@@ -1793,7 +1793,7 @@ function ARLedgerView({ periodId }: { periodId: string }) {
|
||||
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_1_30, 0))}</td>
|
||||
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_31_60, 0))}</td>
|
||||
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_61_90, 0))}</td>
|
||||
<td className="py-2 text-right text-red-600">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_90_plus, 0))}</td>
|
||||
<td className="py-2 text-right text-destructive">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_90_plus, 0))}</td>
|
||||
<td className="py-2 text-right">{formatAmount(ledger.total_outstanding)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
@@ -1820,13 +1820,13 @@ function ARLedgerView({ periodId }: { periodId: string }) {
|
||||
</div>
|
||||
<div className="flex justify-between pt-2 border-t font-semibold">
|
||||
<span>Differens</span>
|
||||
<span className={reconciliation.is_reconciled ? 'text-green-600' : 'text-red-600'}>
|
||||
<span className={reconciliation.is_reconciled ? 'text-success' : 'text-destructive'}>
|
||||
{formatAmount(reconciliation.difference)} kr
|
||||
</span>
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
{reconciliation.is_reconciled ? (
|
||||
<Badge className="bg-green-100 text-green-800">Avstämd</Badge>
|
||||
<Badge className="bg-success/10 text-success">Avstämd</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive">Ej avstämd - kontrollera bokföring</Badge>
|
||||
)}
|
||||
|
||||
@@ -22,9 +22,9 @@ function formatAmount(amount: number): string {
|
||||
const statusColors: Record<string, string> = {
|
||||
registered: 'bg-blue-100 text-blue-800',
|
||||
approved: 'bg-yellow-100 text-yellow-800',
|
||||
paid: 'bg-green-100 text-green-800',
|
||||
paid: 'bg-success/10 text-success',
|
||||
partially_paid: 'bg-orange-100 text-orange-800',
|
||||
overdue: 'bg-red-100 text-red-800',
|
||||
overdue: 'bg-destructive/10 text-destructive',
|
||||
disputed: 'bg-purple-100 text-purple-800',
|
||||
credited: 'bg-gray-100 text-gray-800',
|
||||
}
|
||||
@@ -277,7 +277,7 @@ export default function SupplierInvoiceDetailPage() {
|
||||
</div>
|
||||
<div className="flex justify-between pt-2">
|
||||
<span className="text-muted-foreground">Betalt</span>
|
||||
<span className="font-mono text-green-600">{formatAmount(invoice.paid_amount)} {invoice.currency}</span>
|
||||
<span className="font-mono text-success">{formatAmount(invoice.paid_amount)} {invoice.currency}</span>
|
||||
</div>
|
||||
<div className="flex justify-between font-semibold">
|
||||
<span>Kvar att betala</span>
|
||||
|
||||
@@ -16,9 +16,9 @@ function formatAmount(amount: number): string {
|
||||
const statusColors: Record<string, string> = {
|
||||
registered: 'bg-blue-100 text-blue-800',
|
||||
approved: 'bg-yellow-100 text-yellow-800',
|
||||
paid: 'bg-green-100 text-green-800',
|
||||
paid: 'bg-success/10 text-success',
|
||||
partially_paid: 'bg-orange-100 text-orange-800',
|
||||
overdue: 'bg-red-100 text-red-800',
|
||||
overdue: 'bg-destructive/10 text-destructive',
|
||||
disputed: 'bg-purple-100 text-purple-800',
|
||||
credited: 'bg-gray-100 text-gray-800',
|
||||
}
|
||||
@@ -105,12 +105,12 @@ export default function SupplierInvoicesPage() {
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-red-500" />
|
||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||
Förfallet
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold text-red-600">{formatAmount(overdueAmount)} kr</p>
|
||||
<p className="text-2xl font-bold text-destructive">{formatAmount(overdueAmount)} kr</p>
|
||||
<p className="text-xs text-muted-foreground">{overdueCount} fakturor</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -122,9 +122,9 @@ export default function SupplierDetailPage() {
|
||||
const statusColors: Record<string, string> = {
|
||||
registered: 'bg-blue-100 text-blue-800',
|
||||
approved: 'bg-yellow-100 text-yellow-800',
|
||||
paid: 'bg-green-100 text-green-800',
|
||||
paid: 'bg-success/10 text-success',
|
||||
partially_paid: 'bg-orange-100 text-orange-800',
|
||||
overdue: 'bg-red-100 text-red-800',
|
||||
overdue: 'bg-destructive/10 text-destructive',
|
||||
credited: 'bg-gray-100 text-gray-800',
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { X } from 'lucide-react'
|
||||
import TransactionForm from '@/components/transactions/TransactionForm'
|
||||
import SwipeCategorizationView from '@/components/transactions/SwipeCategorizationView'
|
||||
@@ -235,7 +236,34 @@ export default function TransactionsPage() {
|
||||
setExitingIds((prev) => new Set(prev).add(id))
|
||||
|
||||
if (result.journal_entry_created) {
|
||||
toast({ title: 'Bokförd', description: 'Transaktion bokförd och verifikation skapad' })
|
||||
toast({
|
||||
title: 'Bokförd',
|
||||
description: 'Transaktion bokförd och verifikation skapad',
|
||||
action: (
|
||||
<ToastAction altText="Ångra kategorisering" onClick={async () => {
|
||||
try {
|
||||
const undoRes = await fetch(`/api/transactions/${id}/uncategorize`, { method: 'POST' })
|
||||
if (undoRes.ok) {
|
||||
setTransactions((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === id
|
||||
? { ...t, is_business: null, category: null as unknown as TransactionCategory, journal_entry_id: null }
|
||||
: t
|
||||
)
|
||||
)
|
||||
toast({ title: 'Ångrad', description: 'Kategorisering har ångrats' })
|
||||
} else {
|
||||
const errData = await undoRes.json()
|
||||
toast({ title: 'Kunde inte ångra', description: errData.error || 'Något gick fel', variant: 'destructive' })
|
||||
}
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte ångra', description: 'Något gick fel', variant: 'destructive' })
|
||||
}
|
||||
}}>
|
||||
Ångra
|
||||
</ToastAction>
|
||||
),
|
||||
})
|
||||
} else if (result.journal_entry_error) {
|
||||
toast({ title: 'Delvis bokförd', description: `Verifikation kunde inte skapas: ${result.journal_entry_error}`, variant: 'destructive' })
|
||||
} else {
|
||||
|
||||
@@ -119,7 +119,7 @@ export default function InvoiceActionPage({ params }: { params: Promise<{ token:
|
||||
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white flex items-center justify-center p-4">
|
||||
<Card className="max-w-md w-full">
|
||||
<CardContent className="pt-6 text-center">
|
||||
<CheckCircle className="h-12 w-12 text-green-500 mx-auto mb-4" />
|
||||
<CheckCircle className="h-12 w-12 text-success mx-auto mb-4" />
|
||||
<h2 className="text-lg font-semibold mb-2">Tack för ditt svar!</h2>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{successMessage || (
|
||||
@@ -175,15 +175,15 @@ export default function InvoiceActionPage({ params }: { params: Promise<{ token:
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Förfallodatum</p>
|
||||
<p className="font-medium text-red-600">{formatDate(invoice.dueDate)}</p>
|
||||
<p className="font-medium text-destructive">{formatDate(invoice.dueDate)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-red-50 border border-red-100 rounded-lg p-4">
|
||||
<p className="text-sm text-red-600 mb-1">
|
||||
<div className="bg-destructive/5 border border-destructive/15 rounded-lg p-4">
|
||||
<p className="text-sm text-destructive mb-1">
|
||||
Förfallen med {daysOverdue} dagar
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-red-700">
|
||||
<p className="text-2xl font-bold text-destructive">
|
||||
{formatCurrency(invoice.total, invoice.currency)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -211,7 +211,7 @@ export default function InvoiceActionPage({ params }: { params: Promise<{ token:
|
||||
onClick={() => handleAction('marked_paid')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<CheckCircle className="h-5 w-5 mr-3 text-green-600" />
|
||||
<CheckCircle className="h-5 w-5 mr-3 text-success" />
|
||||
<div className="text-left">
|
||||
<p className="font-medium">Jag har betalat</p>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
|
||||
@@ -129,9 +129,11 @@ export async function POST(
|
||||
filename = `faktura-${invoice.invoice_number}.pdf`
|
||||
}
|
||||
|
||||
// Send email
|
||||
// Send email (CC the user so they have a copy of what was sent)
|
||||
const ccAddress = company.email || user.email
|
||||
const result = await emailService.sendEmail({
|
||||
to: customer.email,
|
||||
cc: ccAddress,
|
||||
subject: generateInvoiceEmailSubject(emailData),
|
||||
html: generateInvoiceEmailHtml(emailData),
|
||||
text: generateInvoiceEmailText(emailData),
|
||||
@@ -215,7 +217,7 @@ export async function POST(
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Fakturan har skickats till ${customer.email}`,
|
||||
message: `Fakturan har skickats till ${customer.email} (kopia till ${ccAddress})`,
|
||||
messageId: result.messageId
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createMockRouteParams,
|
||||
createQueuedMockSupabase,
|
||||
} from '@/tests/helpers'
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockReverseEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
reverseEntry: (...args: unknown[]) => mockReverseEntry(...args),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
describe('POST /api/transactions/[id]/uncategorize', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
eventBus.clear()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/uncategorize', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns 404 when transaction not found', async () => {
|
||||
enqueue({ data: null, error: { message: 'Not found' } })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-999/uncategorize', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-999' }))
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body).toEqual({ error: 'Transaction not found' })
|
||||
})
|
||||
|
||||
it('returns 400 when transaction has no journal entry', async () => {
|
||||
enqueue({ data: { id: 'tx-1', journal_entry_id: null }, error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/uncategorize', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body).toEqual({ error: 'Transaction has no journal entry' })
|
||||
})
|
||||
|
||||
it('returns 400 when journal entry is not posted', async () => {
|
||||
enqueue({ data: { id: 'tx-1', journal_entry_id: 'je-1' }, error: null })
|
||||
enqueue({ data: { id: 'je-1', status: 'draft' }, error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/uncategorize', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body).toEqual({ error: 'Journal entry is not posted' })
|
||||
})
|
||||
|
||||
it('returns 500 when reversal fails', async () => {
|
||||
enqueue({ data: { id: 'tx-1', journal_entry_id: 'je-1' }, error: null })
|
||||
enqueue({ data: { id: 'je-1', status: 'posted' }, error: null })
|
||||
mockReverseEntry.mockRejectedValue(new Error('Period is locked'))
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/uncategorize', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body).toEqual({ error: 'Period is locked' })
|
||||
})
|
||||
|
||||
it('returns 200 and reverses entry on success', async () => {
|
||||
enqueue({ data: { id: 'tx-1', journal_entry_id: 'je-1' }, error: null })
|
||||
enqueue({ data: { id: 'je-1', status: 'posted' }, error: null })
|
||||
mockReverseEntry.mockResolvedValue({ id: 'je-reversal' })
|
||||
enqueue({ data: { id: 'tx-1' }, error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/uncategorize', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body).toEqual({ success: true })
|
||||
expect(mockReverseEntry).toHaveBeenCalledWith(mockSupabase, 'user-1', 'je-1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
// Fetch transaction
|
||||
const { data: transaction, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('id, journal_entry_id')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (txError || !transaction) {
|
||||
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!transaction.journal_entry_id) {
|
||||
return NextResponse.json({ error: 'Transaction has no journal entry' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify journal entry is posted
|
||||
const { data: entry, error: entryError } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id, status')
|
||||
.eq('id', transaction.journal_entry_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (entryError || !entry) {
|
||||
return NextResponse.json({ error: 'Journal entry not found' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (entry.status !== 'posted') {
|
||||
return NextResponse.json({ error: 'Journal entry is not posted' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Storno reversal (legally compliant — never deletes)
|
||||
try {
|
||||
await reverseEntry(supabase, user.id, transaction.journal_entry_id)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Reversal failed'
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Reset transaction categorization
|
||||
const { error: updateError } = await supabase
|
||||
.from('transactions')
|
||||
.update({
|
||||
is_business: null,
|
||||
category: null,
|
||||
journal_entry_id: null,
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: 'Failed to reset transaction' }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 15 KiB |
+61
-51
@@ -4,59 +4,61 @@
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
/* Clean Slate — Modern ERP Palette */
|
||||
/* Grayscale Chrome Palette */
|
||||
|
||||
--background: 220 14% 96%; /* Cool off-white #F2F4F7 */
|
||||
--foreground: 224 12% 13%; /* Deep slate #1D2029 */
|
||||
--background: 0 0% 97%;
|
||||
--foreground: 0 0% 9%;
|
||||
|
||||
--card: 0 0% 100%; /* Pure white */
|
||||
--card-foreground: 224 12% 13%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 9%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 224 12% 13%;
|
||||
--popover-foreground: 0 0% 9%;
|
||||
|
||||
/* Primary — Refined navy-blue */
|
||||
--primary: 222 47% 35%; /* #304D83 */
|
||||
--primary: 0 0% 15%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
/* Secondary — Cool pale gray */
|
||||
--secondary: 220 13% 93%; /* #EBEDF1 */
|
||||
--secondary-foreground: 224 12% 13%;
|
||||
--secondary: 0 0% 94%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
|
||||
/* Muted — Neutral gray */
|
||||
--muted: 218 12% 91%; /* #E3E6EB */
|
||||
--muted-foreground: 218 8% 46%; /* #6B7280 */
|
||||
--muted: 0 0% 92%;
|
||||
--muted-foreground: 0 0% 45%;
|
||||
|
||||
/* Accent — Clear blue for interactive highlights */
|
||||
--accent: 213 72% 50%; /* #2373D8 */
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--accent: 0 0% 92%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
|
||||
/* Destructive — Clean red */
|
||||
--destructive: 0 68% 50%; /* #D63232 */
|
||||
--destructive: 6 36% 48%; /* Muted terracotta */
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
/* Borders & Input */
|
||||
--border: 218 14% 89%; /* #DEE1E7 */
|
||||
--input: 218 14% 89%;
|
||||
--ring: 222 47% 35%;
|
||||
--border: 0 0% 90%;
|
||||
--input: 0 0% 90%;
|
||||
--ring: 0 0% 15%;
|
||||
|
||||
/* Radius */
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Success — Clean green */
|
||||
--success: 152 48% 38%; /* #329460 */
|
||||
/* Success — Sage green */
|
||||
--success: 155 25% 40%;
|
||||
--success-foreground: 0 0% 100%;
|
||||
|
||||
/* Warning — Clear amber */
|
||||
--warning: 38 92% 50%; /* #F59E0B */
|
||||
--warning-foreground: 224 12% 13%;
|
||||
/* Warning — Muted ochre */
|
||||
--warning: 38 55% 50%;
|
||||
--warning-foreground: 0 0% 9%;
|
||||
|
||||
--warm-accent: 38 45% 52%;
|
||||
|
||||
/* Chart colors */
|
||||
--chart-1: 155 25% 40%;
|
||||
--chart-2: 6 36% 48%;
|
||||
--chart-3: 38 55% 50%;
|
||||
--chart-4: 0 0% 50%;
|
||||
|
||||
/* Custom tokens */
|
||||
--font-display: var(--font-fraunces), Georgia, serif;
|
||||
--font-body: var(--font-geist-sans), system-ui, sans-serif;
|
||||
|
||||
/* Shadows — cool-toned */
|
||||
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
--shadow-sm: 0 1px 3px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
--shadow-md: 0 4px 12px rgba(15, 23, 42, 0.06);
|
||||
--shadow-lg: 0 12px 40px rgba(15, 23, 42, 0.08);
|
||||
|
||||
@@ -71,39 +73,46 @@
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
|
||||
--background: 222 16% 10%;
|
||||
--foreground: 216 12% 90%;
|
||||
--background: 0 0% 7%;
|
||||
--foreground: 0 0% 87%;
|
||||
|
||||
--card: 222 14% 13%;
|
||||
--card-foreground: 216 12% 90%;
|
||||
--card: 0 0% 10%;
|
||||
--card-foreground: 0 0% 87%;
|
||||
|
||||
--popover: 222 14% 13%;
|
||||
--popover-foreground: 216 12% 90%;
|
||||
--popover: 0 0% 10%;
|
||||
--popover-foreground: 0 0% 87%;
|
||||
|
||||
--primary: 222 50% 55%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--primary: 0 0% 85%;
|
||||
--primary-foreground: 0 0% 7%;
|
||||
|
||||
--secondary: 220 14% 18%;
|
||||
--secondary-foreground: 216 12% 90%;
|
||||
--secondary: 0 0% 14%;
|
||||
--secondary-foreground: 0 0% 87%;
|
||||
|
||||
--muted: 220 12% 20%;
|
||||
--muted-foreground: 218 8% 55%;
|
||||
--muted: 0 0% 16%;
|
||||
--muted-foreground: 0 0% 50%;
|
||||
|
||||
--accent: 213 72% 58%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--accent: 0 0% 16%;
|
||||
--accent-foreground: 0 0% 87%;
|
||||
|
||||
--destructive: 0 62% 55%;
|
||||
--destructive: 6 32% 56%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
--border: 220 12% 22%;
|
||||
--input: 220 12% 22%;
|
||||
--ring: 222 50% 55%;
|
||||
--border: 0 0% 18%;
|
||||
--input: 0 0% 18%;
|
||||
--ring: 0 0% 85%;
|
||||
|
||||
--success: 152 44% 45%;
|
||||
--success: 155 22% 48%;
|
||||
--success-foreground: 0 0% 100%;
|
||||
|
||||
--warning: 38 85% 55%;
|
||||
--warning-foreground: 224 12% 13%;
|
||||
--warning: 38 50% 55%;
|
||||
--warning-foreground: 0 0% 9%;
|
||||
|
||||
--warm-accent: 38 42% 58%;
|
||||
|
||||
--chart-1: 155 22% 48%;
|
||||
--chart-2: 6 32% 56%;
|
||||
--chart-3: 38 50% 55%;
|
||||
--chart-4: 0 0% 55%;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.25);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35);
|
||||
@@ -134,6 +143,7 @@
|
||||
--color-success-foreground: hsl(var(--success-foreground));
|
||||
--color-warning: hsl(var(--warning));
|
||||
--color-warning-foreground: hsl(var(--warning-foreground));
|
||||
--color-warm-accent: hsl(var(--warm-accent));
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
@@ -271,7 +281,7 @@ h1, h2, h3 {
|
||||
.focus-ring:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px hsl(var(--background)),
|
||||
0 0 0 4px hsl(var(--primary) / 0.3);
|
||||
0 0 0 4px hsl(var(--primary) / 0.35);
|
||||
}
|
||||
|
||||
/* Custom scrollbar - minimal */
|
||||
|
||||
Reference in New Issue
Block a user