New base
This commit is contained in:
@@ -25,6 +25,8 @@ export default function ChartOfAccounts() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expandedClasses, setExpandedClasses] = useState<Set<number>>(new Set())
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [editingSRU, setEditingSRU] = useState<string | null>(null)
|
||||
const [sruValue, setSruValue] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccounts()
|
||||
@@ -37,6 +39,20 @@ export default function ChartOfAccounts() {
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
async function updateSRUCode(accountId: string, newSruCode: string) {
|
||||
const supabase = createClient()
|
||||
const trimmed = newSruCode.trim() || null
|
||||
await supabase
|
||||
.from('chart_of_accounts')
|
||||
.update({ sru_code: trimmed })
|
||||
.eq('id', accountId)
|
||||
|
||||
setAccounts((prev) =>
|
||||
prev.map((a) => (a.id === accountId ? { ...a, sru_code: trimmed } : a))
|
||||
)
|
||||
setEditingSRU(null)
|
||||
}
|
||||
|
||||
const toggleClass = (cls: number) => {
|
||||
const next = new Set(expandedClasses)
|
||||
if (next.has(cls)) {
|
||||
@@ -121,6 +137,7 @@ export default function ChartOfAccounts() {
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<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>
|
||||
@@ -135,6 +152,32 @@ export default function ChartOfAccounts() {
|
||||
>
|
||||
<td className="py-2 font-mono">{account.account_number}</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">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{account.account_type === 'asset'
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function JournalEntryList({ periodId }: Props) {
|
||||
const statusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'posted':
|
||||
return <Badge className="bg-green-100 text-green-800">Bokförd</Badge>
|
||||
return <Badge variant="success">Bokförd</Badge>
|
||||
case 'draft':
|
||||
return <Badge variant="secondary">Utkast</Badge>
|
||||
case 'reversed':
|
||||
|
||||
@@ -120,14 +120,14 @@ export function CalendarDayView({
|
||||
{paidInvoices.map((invoice) => (
|
||||
<div
|
||||
key={invoice.id}
|
||||
className="p-2 rounded-md bg-green-500/10 border border-green-500/30"
|
||||
className="p-2 rounded-md bg-success/10 border border-success/30"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-green-500 flex-shrink-0" />
|
||||
<div className="w-3 h-3 rounded-full bg-success flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-green-700">
|
||||
<div className="text-sm font-medium text-success">
|
||||
Faktura {invoice.invoice_number}
|
||||
<span className="ml-2 text-xs bg-green-500/20 px-1.5 py-0.5 rounded">
|
||||
<span className="ml-2 text-xs bg-success/20 px-1.5 py-0.5 rounded">
|
||||
Betald
|
||||
</span>
|
||||
</div>
|
||||
@@ -151,18 +151,18 @@ export function CalendarDayView({
|
||||
'p-2 rounded-md',
|
||||
isDeadlineOverdue(deadline)
|
||||
? 'bg-destructive/10 border border-destructive/30'
|
||||
: 'bg-orange-500/10 border border-orange-500/30'
|
||||
: 'bg-warning/10 border border-warning/30'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn(
|
||||
'w-3 h-3 rounded-sm flex-shrink-0',
|
||||
isDeadlineOverdue(deadline) ? 'bg-destructive' : 'bg-orange-500'
|
||||
isDeadlineOverdue(deadline) ? 'bg-destructive' : 'bg-warning'
|
||||
)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={cn(
|
||||
'text-sm font-medium',
|
||||
isDeadlineOverdue(deadline) ? 'text-destructive' : 'text-orange-700'
|
||||
isDeadlineOverdue(deadline) ? 'text-destructive' : 'text-warning-foreground'
|
||||
)}>
|
||||
{deadline.title}
|
||||
{isDeadlineOverdue(deadline) && (
|
||||
@@ -197,17 +197,17 @@ export function CalendarDayView({
|
||||
{completedDeadlines.map((deadline) => (
|
||||
<div
|
||||
key={deadline.id}
|
||||
className="p-2 rounded-md bg-green-500/10 border border-green-500/30 opacity-60"
|
||||
className="p-2 rounded-md bg-success/10 border border-success/30 opacity-60"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-sm bg-green-500 flex-shrink-0" />
|
||||
<div className="w-3 h-3 rounded-sm bg-success flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-green-700 line-through">
|
||||
<div className="text-sm font-medium text-success line-through">
|
||||
{deadline.title}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{DEADLINE_TYPE_LABELS[deadline.deadline_type] || deadline.deadline_type}
|
||||
<span className="ml-2 bg-green-500/20 px-1.5 py-0.5 rounded">Klar</span>
|
||||
<span className="ml-2 bg-success/20 px-1.5 py-0.5 rounded">Klar</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -114,7 +114,7 @@ export function CalendarWeekView({
|
||||
'text-xs p-1 mb-1 rounded truncate',
|
||||
isDeadlineOverdue(deadline)
|
||||
? 'bg-destructive/20 text-destructive'
|
||||
: 'bg-orange-500/20 text-orange-700'
|
||||
: 'bg-warning/20 text-warning-foreground'
|
||||
)}
|
||||
title={deadline.title}
|
||||
>
|
||||
|
||||
@@ -76,7 +76,7 @@ export function DeadlineCard({
|
||||
<div
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full flex-shrink-0',
|
||||
deadline.priority === 'critical' ? 'bg-destructive' : 'bg-orange-500'
|
||||
deadline.priority === 'critical' ? 'bg-destructive' : 'bg-warning'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -26,8 +26,8 @@ export function PaymentSummaryCard({ invoices, year, month }: PaymentSummaryCard
|
||||
{/* Expected */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<TrendingUp className="h-4 w-4 text-primary" />
|
||||
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center">
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Förväntat</p>
|
||||
@@ -41,8 +41,8 @@ export function PaymentSummaryCard({ invoices, year, month }: PaymentSummaryCard
|
||||
{summary.overdueCount > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-lg bg-destructive/10 flex items-center justify-center">
|
||||
<AlertTriangle className="h-4 w-4 text-destructive" />
|
||||
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center">
|
||||
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Förfallen</p>
|
||||
@@ -60,8 +60,8 @@ export function PaymentSummaryCard({ invoices, year, month }: PaymentSummaryCard
|
||||
{/* Paid */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-lg bg-success/10 flex items-center justify-center">
|
||||
<CheckCircle className="h-4 w-4 text-success" />
|
||||
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center">
|
||||
<CheckCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Betald</p>
|
||||
@@ -77,8 +77,8 @@ export function PaymentSummaryCard({ invoices, year, month }: PaymentSummaryCard
|
||||
{summary.pendingCount > 0 && summary.overdueCount < summary.pendingCount && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-lg bg-warning/10 flex items-center justify-center">
|
||||
<Clock className="h-4 w-4 text-warning" />
|
||||
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Väntande</p>
|
||||
|
||||
@@ -112,11 +112,11 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-orange-200 bg-orange-50/30">
|
||||
<Card className="border-warning/30 bg-warning/5">
|
||||
<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-orange-600" />
|
||||
<FileText className="h-5 w-5 text-warning-foreground" />
|
||||
Att göra - Skatt
|
||||
</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
@@ -143,7 +143,7 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
|
||||
isOverdue
|
||||
? 'border-destructive/50 bg-destructive/5'
|
||||
: isActionNeeded
|
||||
? 'border-orange-300 bg-orange-50'
|
||||
? 'border-warning/40 bg-warning/10'
|
||||
: 'border-border bg-background'
|
||||
}`}
|
||||
>
|
||||
@@ -152,7 +152,7 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
|
||||
{isOverdue ? (
|
||||
<AlertTriangle className="h-4 w-4 text-destructive flex-shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<Clock className="h-4 w-4 text-orange-600 flex-shrink-0 mt-0.5" />
|
||||
<Clock className="h-4 w-4 text-warning-foreground flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{deadline.title}</p>
|
||||
|
||||
@@ -60,7 +60,7 @@ export function useChatStream(options: UseChatStreamOptions = {}): UseChatStream
|
||||
try {
|
||||
abortControllerRef.current = new AbortController()
|
||||
|
||||
const response = await fetch('/api/chat/stream', {
|
||||
const response = await fetch('/api/extensions/ai-chat/stream', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -175,7 +175,7 @@ export function useChatStream(options: UseChatStreamOptions = {}): UseChatStream
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/chat/sessions/${id}`)
|
||||
const response = await fetch(`/api/extensions/ai-chat/sessions/${id}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load session')
|
||||
}
|
||||
|
||||
@@ -5,13 +5,12 @@ import Link from 'next/link'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { cn, formatCurrency } from '@/lib/utils'
|
||||
import {
|
||||
calculateEFTax,
|
||||
calculateABTax,
|
||||
getEnhancedTaxWarningStatus
|
||||
} from '@/lib/tax/calculator'
|
||||
import { getSchablonavdragSummary } from '@/lib/tax/schablonavdrag'
|
||||
import FSkattWarningCard from '@/components/dashboard/FSkattWarningCard'
|
||||
import { UpcomingDeadlinesWidget } from '@/components/calendar/UpcomingDeadlinesWidget'
|
||||
import NewUserChecklist from '@/components/onboarding/NewUserChecklist'
|
||||
@@ -23,11 +22,9 @@ import {
|
||||
ChevronDown,
|
||||
ArrowRight,
|
||||
Camera,
|
||||
HelpCircle,
|
||||
Users,
|
||||
FileText,
|
||||
} from 'lucide-react'
|
||||
import type { CompanySettings, EntityType, MileageEntry, SchablonavdragSettings, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
|
||||
import type { CompanySettings, EntityType, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
|
||||
|
||||
interface DashboardContentProps {
|
||||
firstName?: string | null
|
||||
@@ -43,7 +40,6 @@ interface DashboardContentProps {
|
||||
unpaidVatTotal: number
|
||||
overdueInvoicesCount: number
|
||||
bankBalance: number | null
|
||||
mileageEntries: MileageEntry[]
|
||||
deadlines: Deadline[]
|
||||
receiptQueue: ReceiptQueueSummary | null
|
||||
}
|
||||
@@ -58,19 +54,11 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
const currentMonth = new Date().getMonth() + 1
|
||||
const preliminaryTaxPaidYTD = preliminaryTaxMonthly * currentMonth
|
||||
|
||||
const schablonavdragSettings = (settings as CompanySettings & { schablonavdrag_settings?: SchablonavdragSettings })?.schablonavdrag_settings || null
|
||||
const schablonavdragSummary = getSchablonavdragSummary(
|
||||
schablonavdragSettings,
|
||||
summary.mileageEntries,
|
||||
new Date().getFullYear(),
|
||||
currentMonth
|
||||
)
|
||||
|
||||
const totalTaxableIncome = summary.ytd.net
|
||||
|
||||
const taxEstimate =
|
||||
entityType === 'enskild_firma'
|
||||
? calculateEFTax(totalTaxableIncome, preliminaryTaxPaidYTD, schablonavdragSummary, summary.unpaidVatTotal)
|
||||
? calculateEFTax(totalTaxableIncome, preliminaryTaxPaidYTD, null, summary.unpaidVatTotal)
|
||||
: calculateABTax(totalTaxableIncome, 0, preliminaryTaxPaidYTD, summary.unpaidVatTotal)
|
||||
|
||||
const taxWarning = getEnhancedTaxWarningStatus(taxEstimate, preliminaryTaxMonthly, currentMonth)
|
||||
@@ -91,24 +79,19 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
if (summary.overdueInvoicesCount > 0) {
|
||||
alertItems.push(
|
||||
<Link key="overdue" href="/invoices?status=unpaid" className="group">
|
||||
<Card className="h-full border-l-4 border-l-destructive hover:border-primary/30 transition-colors">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-destructive/10">
|
||||
<Receipt className="h-4 w-4 text-destructive" />
|
||||
</div>
|
||||
<Card className="h-full border-l-2 border-l-destructive hover:bg-muted/20 transition-colors">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Receipt className="h-4 w-4 text-destructive flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">Förfallna fakturor</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{summary.overdueInvoicesCount} förfallna
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{summary.overdueInvoicesCount} st
|
||||
</p>
|
||||
<Badge variant="destructive" className="mt-1.5 animate-pulse">
|
||||
Kräver åtgärd
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground group-hover:translate-x-0.5 transition-all" />
|
||||
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground group-hover:translate-x-0.5 transition-all" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -119,21 +102,19 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
if (summary.unpaidInvoicesCount > 0 && summary.overdueInvoicesCount < summary.unpaidInvoicesCount) {
|
||||
alertItems.push(
|
||||
<Link key="unpaid" href="/invoices?status=unpaid" className="group">
|
||||
<Card className="h-full border-l-4 border-l-warning hover:border-primary/30 transition-colors">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-warning/10">
|
||||
<Receipt className="h-4 w-4 text-warning-foreground" />
|
||||
</div>
|
||||
<Card className="h-full border-l-2 border-l-warning hover:bg-muted/20 transition-colors">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Receipt className="h-4 w-4 text-warning-foreground flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">Obetalda fakturor</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{summary.unpaidInvoicesCount - summary.overdueInvoicesCount} obetalda · {formatCurrency(summary.unpaidInvoicesTotal)}
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{summary.unpaidInvoicesCount - summary.overdueInvoicesCount} st · {formatCurrency(summary.unpaidInvoicesTotal)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground group-hover:translate-x-0.5 transition-all" />
|
||||
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground group-hover:translate-x-0.5 transition-all" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -144,21 +125,19 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
if (summary.uncategorizedCount > 0) {
|
||||
alertItems.push(
|
||||
<Link key="transactions" href="/transactions" className="group">
|
||||
<Card className="h-full border-l-4 border-l-warning hover:border-primary/30 transition-colors">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-warning/10">
|
||||
<ArrowLeftRight className="h-4 w-4 text-warning-foreground" />
|
||||
</div>
|
||||
<Card className="h-full border-l-2 border-l-warning hover:bg-muted/20 transition-colors">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<ArrowLeftRight className="h-4 w-4 text-warning-foreground flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">Transaktioner</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{summary.uncategorizedCount} okategoriserade
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground group-hover:translate-x-0.5 transition-all" />
|
||||
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground group-hover:translate-x-0.5 transition-all" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -169,23 +148,21 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
if (summary.receiptQueue && (summary.receiptQueue.pending_review_count > 0 || summary.receiptQueue.unmatched_receipts_count > 0)) {
|
||||
alertItems.push(
|
||||
<Link key="receipts" href="/receipts" className="group">
|
||||
<Card className="h-full border-l-4 border-l-primary hover:border-primary/30 transition-colors">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-blue-500/10">
|
||||
<Camera className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
<Card className="h-full border-l-2 border-l-primary hover:bg-muted/20 transition-colors">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Camera className="h-4 w-4 text-primary flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">Kvitton</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{summary.receiptQueue.pending_review_count > 0
|
||||
? `${summary.receiptQueue.pending_review_count} att granska`
|
||||
: `${summary.receiptQueue.unmatched_receipts_count} omatchade`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground group-hover:translate-x-0.5 transition-all" />
|
||||
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground group-hover:translate-x-0.5 transition-all" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -207,34 +184,26 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
|
||||
return (
|
||||
<div className="stagger-enter">
|
||||
{/* Hero section - personalized greeting */}
|
||||
<header className="mb-12">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-4xl md:text-5xl font-medium tracking-tight mb-1">
|
||||
{(() => {
|
||||
const hour = new Date().getHours()
|
||||
if (hour < 12) return 'Godmorgon'
|
||||
if (hour < 18) return 'God eftermiddag'
|
||||
return 'God kväll'
|
||||
})()}{firstName ? ` ${firstName}` : ''}{settings?.company_name ? `, ${settings.company_name}` : ''}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
{summary.overdueInvoicesCount > 0
|
||||
? `${summary.overdueInvoicesCount} förfallna fakturor kräver åtgärd`
|
||||
: summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date()).length > 0
|
||||
? `${summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date()).length} passerade deadlines`
|
||||
: 'Allt är som det ska'}
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/help" className="hidden sm:flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
Hjälp
|
||||
</Link>
|
||||
</div>
|
||||
{/* Header */}
|
||||
<header className="mb-10">
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
|
||||
{(() => {
|
||||
const hour = new Date().getHours()
|
||||
if (hour < 12) return 'Godmorgon'
|
||||
if (hour < 18) return 'God eftermiddag'
|
||||
return 'God kväll'
|
||||
})()}{firstName ? `, ${firstName}` : ''}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{summary.overdueInvoicesCount > 0
|
||||
? `${summary.overdueInvoicesCount} förfallna fakturor kräver åtgärd`
|
||||
: summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date()).length > 0
|
||||
? `${summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date()).length} passerade deadlines`
|
||||
: 'Allt är som det ska'}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* TODO section - actionable items */}
|
||||
{/* Status pills */}
|
||||
{(() => {
|
||||
const passedDeadlines = summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date())
|
||||
const todoItems: { label: string; href: string; count: number; variant: 'destructive' | 'warning' | 'default' }[] = []
|
||||
@@ -246,7 +215,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
todoItems.push({ label: 'förfallna fakturor', href: '/invoices?status=unpaid', count: summary.overdueInvoicesCount, variant: 'destructive' })
|
||||
}
|
||||
if (summary.uncategorizedCount > 0) {
|
||||
todoItems.push({ label: 'okategoriserade transaktioner', href: '/transactions', count: summary.uncategorizedCount, variant: 'warning' })
|
||||
todoItems.push({ label: 'okategoriserade', href: '/transactions', count: summary.uncategorizedCount, variant: 'warning' })
|
||||
}
|
||||
if (summary.receiptQueue && summary.receiptQueue.pending_review_count > 0) {
|
||||
todoItems.push({ label: 'kvitton att granska', href: '/receipts', count: summary.receiptQueue.pending_review_count, variant: 'default' })
|
||||
@@ -255,13 +224,13 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
if (todoItems.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className="mb-8">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<section className="mb-10">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{todoItems.map((item) => (
|
||||
<Link key={item.href} href={item.href}>
|
||||
<Badge
|
||||
variant={item.variant === 'destructive' ? 'destructive' : item.variant === 'warning' ? 'outline' : 'secondary'}
|
||||
className="px-3 py-1.5 text-sm cursor-pointer hover:opacity-80 transition-opacity"
|
||||
className="px-2.5 py-1 text-xs cursor-pointer hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{item.count} {item.label}
|
||||
</Badge>
|
||||
@@ -274,7 +243,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
|
||||
{/* New user checklist */}
|
||||
{onboardingProgress && (
|
||||
<section className="mb-8">
|
||||
<section className="mb-10">
|
||||
<NewUserChecklist
|
||||
hasCustomers={onboardingProgress.hasCustomers}
|
||||
hasInvoices={onboardingProgress.hasInvoices}
|
||||
@@ -284,32 +253,41 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Upcoming deadlines - prominent placement */}
|
||||
{/* Upcoming deadlines */}
|
||||
{summary.deadlines && summary.deadlines.length > 0 && (
|
||||
<section className="mb-8">
|
||||
<section className="mb-10">
|
||||
<UpcomingDeadlinesWidget deadlines={summary.deadlines} maxItems={8} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Quick actions - moved up, as visual cards */}
|
||||
<section id="quick-actions" className="mb-12">
|
||||
<h2 className="font-display text-xl font-medium mb-6">Snabbåtgärder</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
{/* Quick actions */}
|
||||
<section id="quick-actions" className="mb-10">
|
||||
<h2 className="font-display text-lg font-medium mb-4">Snabbåtgärder</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2.5">
|
||||
{quickActions.map((action) => {
|
||||
const Icon = action.icon
|
||||
return (
|
||||
<Link key={action.href} href={action.href} className="group">
|
||||
<Card className={`h-full hover-lift text-center ${action.accent ? 'bg-primary/5 border-primary/20' : ''}`}>
|
||||
<CardContent className="p-4 flex flex-col items-center gap-2">
|
||||
<div className={`p-3 rounded-xl ${action.accent ? 'bg-primary/10' : 'bg-muted/50'} group-hover:scale-105 transition-transform`}>
|
||||
<Icon className={`h-5 w-5 ${action.accent ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{action.label}</p>
|
||||
<p className="text-xs text-muted-foreground hidden md:block">{action.desc}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className={cn(
|
||||
'flex items-center gap-3 px-4 py-3 rounded-xl border transition-colors duration-150',
|
||||
action.accent
|
||||
? 'border-primary/20 bg-primary/[0.03] hover:bg-primary/[0.06]'
|
||||
: 'border-border/40 hover:bg-muted/30'
|
||||
)}>
|
||||
<div className={cn(
|
||||
'p-2 rounded-lg',
|
||||
action.accent ? 'bg-primary/8' : 'bg-muted/50'
|
||||
)}>
|
||||
<Icon className={cn(
|
||||
'h-4 w-4',
|
||||
action.accent ? 'text-primary' : 'text-muted-foreground'
|
||||
)} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{action.label}</p>
|
||||
<p className="text-xs text-muted-foreground truncate hidden md:block">{action.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
@@ -317,24 +295,24 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
</section>
|
||||
|
||||
{/* F-skatt warning */}
|
||||
<section id="fskatt-section" className="mb-8">
|
||||
<section id="fskatt-section" className="mb-10">
|
||||
<FSkattWarningCard
|
||||
warningStatus={taxWarning}
|
||||
onAdjustClick={() => { window.location.href = '/settings' }}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Alerts section - with urgency indicators and limit */}
|
||||
{/* Alerts section */}
|
||||
{alertItems.length > 0 && (
|
||||
<section id="alerts-section" className="mb-16">
|
||||
<h2 className="font-display text-xl font-medium mb-6">Att hantera</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<section id="alerts-section" className="mb-10">
|
||||
<h2 className="font-display text-lg font-medium mb-4">Att hantera</h2>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{visibleAlerts}
|
||||
</div>
|
||||
{hasMoreAlerts && !showAllAlerts && (
|
||||
<button
|
||||
onClick={() => setShowAllAlerts(true)}
|
||||
className="mt-4 text-sm text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
|
||||
className="mt-3 text-sm text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
|
||||
>
|
||||
Visa alla ({alertItems.length})
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
@@ -345,84 +323,74 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
|
||||
{/* Uncategorized transactions warning */}
|
||||
{summary.uncategorizedCount > 0 && (summary.uncategorizedIncome > 0 || summary.uncategorizedExpenses > 0) && (
|
||||
<section className="mb-8">
|
||||
<Link href="/transactions?tab=uncategorized">
|
||||
<Card className="border-warning/50 bg-warning/5 hover:border-warning transition-colors">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-warning/10">
|
||||
<ArrowLeftRight className="h-4 w-4 text-warning-foreground" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm">
|
||||
{summary.uncategorizedCount} okategoriserade transaktioner påverkar ditt resultat
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{summary.uncategorizedIncome > 0 && (
|
||||
<span>{formatCurrency(summary.uncategorizedIncome)} i potentiella intäkter</span>
|
||||
)}
|
||||
{summary.uncategorizedIncome > 0 && summary.uncategorizedExpenses > 0 && ' och '}
|
||||
{summary.uncategorizedExpenses > 0 && (
|
||||
<span>{formatCurrency(summary.uncategorizedExpenses)} i potentiella kostnader</span>
|
||||
)}
|
||||
{' '}visas inte i resultatet. Kategorisera dem för korrekt redovisning.
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-1" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<section className="mb-10">
|
||||
<Link href="/transactions?tab=uncategorized" className="group">
|
||||
<div className="flex items-start gap-3 px-4 py-3.5 rounded-xl border border-warning/30 bg-warning/[0.03] hover:bg-warning/[0.06] transition-colors">
|
||||
<ArrowLeftRight className="h-4 w-4 text-warning-foreground flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm">
|
||||
{summary.uncategorizedCount} okategoriserade transaktioner
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{summary.uncategorizedIncome > 0 && (
|
||||
<span>{formatCurrency(summary.uncategorizedIncome)} intäkter</span>
|
||||
)}
|
||||
{summary.uncategorizedIncome > 0 && summary.uncategorizedExpenses > 0 && ', '}
|
||||
{summary.uncategorizedExpenses > 0 && (
|
||||
<span>{formatCurrency(summary.uncategorizedExpenses)} kostnader</span>
|
||||
)}
|
||||
{' '}saknas i resultatet
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground flex-shrink-0 mt-0.5 transition-colors" />
|
||||
</div>
|
||||
</Link>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Income/Expenses - Side by side editorial */}
|
||||
<section className="mb-16">
|
||||
<h2 className="font-display text-xl font-medium mb-6">Resultat</h2>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<Card className="hover-lift">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<TrendingUp className="h-4 w-4 text-success" />
|
||||
{/* Income/Expenses */}
|
||||
<section className="mb-10">
|
||||
<h2 className="font-display text-lg font-medium mb-4">Resultat</h2>
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<TrendingUp className="h-3.5 w-3.5 text-success" />
|
||||
<span className="text-sm text-muted-foreground">Intäkter</span>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">Denna månad</p>
|
||||
<p className="font-display text-3xl font-medium tabular-nums">
|
||||
{formatLargeNumber(summary.mtd.income)}
|
||||
<span className="text-lg ml-1 text-muted-foreground font-normal">kr</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="pt-4 border-t border-border/50">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">I år totalt</p>
|
||||
<p className="font-display text-xl tabular-nums">
|
||||
{formatCurrency(summary.ytd.income)}
|
||||
</p>
|
||||
<div>
|
||||
<p className="font-display text-2xl font-medium tabular-nums leading-tight">
|
||||
{formatLargeNumber(summary.mtd.income)}
|
||||
<span className="text-base ml-1 text-muted-foreground font-normal">kr</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">denna månad</p>
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-border/30">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<p className="text-xs text-muted-foreground">I år</p>
|
||||
<p className="text-sm font-medium tabular-nums">{formatCurrency(summary.ytd.income)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="hover-lift">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<TrendingDown className="h-4 w-4 text-destructive" />
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<TrendingDown className="h-3.5 w-3.5 text-destructive" />
|
||||
<span className="text-sm text-muted-foreground">Kostnader</span>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">Denna månad</p>
|
||||
<p className="font-display text-3xl font-medium tabular-nums">
|
||||
{formatLargeNumber(summary.mtd.expenses)}
|
||||
<span className="text-lg ml-1 text-muted-foreground font-normal">kr</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="pt-4 border-t border-border/50">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">I år totalt</p>
|
||||
<p className="font-display text-xl tabular-nums">
|
||||
{formatCurrency(summary.ytd.expenses)}
|
||||
</p>
|
||||
<div>
|
||||
<p className="font-display text-2xl font-medium tabular-nums leading-tight">
|
||||
{formatLargeNumber(summary.mtd.expenses)}
|
||||
<span className="text-base ml-1 text-muted-foreground font-normal">kr</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">denna månad</p>
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-border/30">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<p className="text-xs text-muted-foreground">I år</p>
|
||||
<p className="text-sm font-medium tabular-nums">{formatCurrency(summary.ytd.expenses)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -15,16 +15,12 @@ import {
|
||||
BarChart3,
|
||||
Settings,
|
||||
LogOut,
|
||||
Calculator,
|
||||
Upload,
|
||||
Calendar,
|
||||
Camera,
|
||||
Menu,
|
||||
X,
|
||||
HelpCircle,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Building2,
|
||||
} from 'lucide-react'
|
||||
import type { EntityType } from '@/types'
|
||||
|
||||
@@ -48,10 +44,7 @@ const navItems: NavItem[] = [
|
||||
{ href: '/invoices', label: 'Fakturor', icon: Receipt, group: 'finans' },
|
||||
{ href: '/customers', label: 'Kunder', icon: Users, group: 'finans' },
|
||||
{ href: '/transactions', label: 'Transaktioner', icon: ArrowLeftRight, group: 'finans' },
|
||||
{ href: '/receipts', label: 'Kvitton', icon: Camera, group: 'finans' },
|
||||
{ href: '/deductions', label: 'Avdrag', icon: Calculator, group: 'finans' },
|
||||
{ href: '/bookkeeping', label: 'Bokföring', icon: BookOpen, group: 'finans' },
|
||||
{ href: '/modules', label: 'Företagsmoduler', icon: Building2, group: 'moduler' },
|
||||
{ href: '/import', label: 'Importera', icon: Upload, group: 'övrigt' },
|
||||
{ href: '/reports', label: 'Rapporter', icon: BarChart3, group: 'övrigt' },
|
||||
{ href: '/help', label: 'Hjälp', icon: HelpCircle, group: 'övrigt' },
|
||||
@@ -61,7 +54,6 @@ const navItems: NavItem[] = [
|
||||
const groupLabels: Record<string, string> = {
|
||||
main: 'Huvudmeny',
|
||||
finans: 'Finans',
|
||||
moduler: 'Företagsmoduler',
|
||||
övrigt: 'Övrigt',
|
||||
}
|
||||
|
||||
@@ -93,40 +85,35 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
|
||||
|
||||
const mainItems = filteredItems.filter(i => i.group === 'main')
|
||||
const finansItems = filteredItems.filter(i => i.group === 'finans')
|
||||
const modulerItems = filteredItems.filter(i => i.group === 'moduler')
|
||||
const övrigtItems = filteredItems.filter(i => i.group === 'övrigt')
|
||||
|
||||
const mobileNavItems = [
|
||||
{ href: '/', label: 'Översikt', icon: LayoutDashboard },
|
||||
{ href: '/invoices', label: 'Fakturor', icon: Receipt },
|
||||
{ href: '/receipts/scan', label: 'Skanna', icon: Camera, isScan: true },
|
||||
{ href: '/transactions', label: 'Transaktioner', icon: ArrowLeftRight },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop sidebar */}
|
||||
<aside className="hidden md:fixed md:inset-y-0 md:flex md:w-60 md:flex-col">
|
||||
<div className="flex min-h-0 flex-1 flex-col border-r border-border/40 bg-card">
|
||||
<div className="flex flex-1 flex-col overflow-y-auto pt-8 pb-4">
|
||||
{/* Logo */}
|
||||
<div className="px-6 mb-10">
|
||||
<h1 className="font-display text-lg font-medium tracking-tight truncate">
|
||||
<aside className="hidden md:fixed md:inset-y-0 md:flex md:w-[232px] md:flex-col">
|
||||
<div className="flex min-h-0 flex-1 flex-col border-r border-border/30 bg-card/80">
|
||||
<div className="flex flex-1 flex-col overflow-y-auto pt-7 pb-4">
|
||||
{/* Company name */}
|
||||
<div className="px-5 mb-8">
|
||||
<p className="text-[13px] font-semibold text-foreground truncate tracking-[-0.01em]">
|
||||
{companyName}
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 tracking-wide uppercase">
|
||||
Ekonomi
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Navigation with group headers */}
|
||||
<nav className="flex-1 px-3" aria-label="Huvudnavigation">
|
||||
{/* Huvudmeny group */}
|
||||
<div className="mb-4">
|
||||
<p className="px-3 mb-1 text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
|
||||
<div className="mb-6">
|
||||
<p className="px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-[0.08em]">
|
||||
{groupLabels.main}
|
||||
</p>
|
||||
<div className="space-y-0.5">
|
||||
<div className="space-y-px">
|
||||
{mainItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item.href)
|
||||
@@ -135,15 +122,15 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'group flex items-center px-3 py-2 text-sm transition-all duration-200 rounded-lg',
|
||||
'group flex items-center px-3 py-[7px] text-[13px] transition-colors duration-150 rounded-lg',
|
||||
active
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
|
||||
? 'bg-primary/8 text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
|
||||
)}
|
||||
>
|
||||
<Icon className={cn(
|
||||
"mr-3 h-4 w-4 flex-shrink-0",
|
||||
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
|
||||
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
|
||||
active ? "text-primary" : "text-muted-foreground/70 group-hover:text-muted-foreground"
|
||||
)} />
|
||||
{item.label}
|
||||
</Link>
|
||||
@@ -152,15 +139,12 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="border-t border-border/40 mx-3 mb-4" />
|
||||
|
||||
{/* Finans group */}
|
||||
<div className="mb-4">
|
||||
<p className="px-3 mb-1 text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
|
||||
<div className="mb-6">
|
||||
<p className="px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-[0.08em]">
|
||||
{groupLabels.finans}
|
||||
</p>
|
||||
<div className="space-y-0.5">
|
||||
<div className="space-y-px">
|
||||
{finansItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item.href)
|
||||
@@ -169,15 +153,15 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'group flex items-center px-3 py-2 text-sm transition-all duration-200 rounded-lg',
|
||||
'group flex items-center px-3 py-[7px] text-[13px] transition-colors duration-150 rounded-lg',
|
||||
active
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
|
||||
? 'bg-primary/8 text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
|
||||
)}
|
||||
>
|
||||
<Icon className={cn(
|
||||
"mr-3 h-4 w-4 flex-shrink-0",
|
||||
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
|
||||
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
|
||||
active ? "text-primary" : "text-muted-foreground/70 group-hover:text-muted-foreground"
|
||||
)} />
|
||||
{item.label}
|
||||
</Link>
|
||||
@@ -186,58 +170,20 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="border-t border-border/40 mx-3 mb-4" />
|
||||
|
||||
{/* Moduler group */}
|
||||
<div className="mb-4">
|
||||
<p className="px-3 mb-1 text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{groupLabels.moduler}
|
||||
</p>
|
||||
<div className="space-y-0.5">
|
||||
{modulerItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item.href)
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'group flex items-center px-3 py-2 text-sm transition-all duration-200 rounded-lg',
|
||||
active
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
|
||||
)}
|
||||
>
|
||||
<Icon className={cn(
|
||||
"mr-3 h-4 w-4 flex-shrink-0",
|
||||
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
|
||||
)} />
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="border-t border-border/40 mx-3 mb-4" />
|
||||
|
||||
{/* Övrigt group - collapsible */}
|
||||
<div className="mb-4">
|
||||
<button
|
||||
onClick={() => setIsOvrigtExpanded(!isOvrigtExpanded)}
|
||||
className="w-full flex items-center justify-between px-3 mb-1 text-[11px] font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
|
||||
className="w-full flex items-center justify-between px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-[0.08em] hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
<span>{groupLabels.övrigt}</span>
|
||||
{isOvrigtExpanded ? (
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
<ChevronDown className={cn(
|
||||
"h-3 w-3 transition-transform duration-200",
|
||||
isOvrigtExpanded && "rotate-180"
|
||||
)} />
|
||||
</button>
|
||||
{isOvrigtExpanded && (
|
||||
<div className="space-y-0.5 animate-fade-in">
|
||||
<div className="space-y-px animate-fade-in">
|
||||
{övrigtItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item.href)
|
||||
@@ -246,15 +192,15 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'group flex items-center px-3 py-2 text-sm transition-all duration-200 rounded-lg',
|
||||
'group flex items-center px-3 py-[7px] text-[13px] transition-colors duration-150 rounded-lg',
|
||||
active
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
|
||||
? 'bg-primary/8 text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
|
||||
)}
|
||||
>
|
||||
<Icon className={cn(
|
||||
"mr-3 h-4 w-4 flex-shrink-0",
|
||||
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
|
||||
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
|
||||
active ? "text-primary" : "text-muted-foreground/70 group-hover:text-muted-foreground"
|
||||
)} />
|
||||
{item.label}
|
||||
</Link>
|
||||
@@ -267,13 +213,13 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
|
||||
</div>
|
||||
|
||||
{/* Logout button */}
|
||||
<div className="flex-shrink-0 p-4 border-t border-border/40">
|
||||
<div className="flex-shrink-0 px-3 py-3 border-t border-border/20">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-muted-foreground hover:text-foreground text-sm"
|
||||
className="w-full justify-start text-muted-foreground hover:text-foreground text-[13px] h-9 px-3"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut className="mr-3 h-4 w-4" />
|
||||
<LogOut className="mr-2.5 h-[15px] w-[15px]" />
|
||||
Logga ut
|
||||
</Button>
|
||||
</div>
|
||||
@@ -286,23 +232,6 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
|
||||
{mobileNavItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item.href)
|
||||
const isScan = 'isScan' in item && item.isScan
|
||||
|
||||
if (isScan) {
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
aria-label="Skanna kvitto"
|
||||
className="flex flex-col items-center justify-center flex-1 h-full -mt-3"
|
||||
>
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-accent text-accent-foreground shadow-md">
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
<span className="text-[10px] mt-0.5 text-accent font-medium">{item.label}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -424,33 +353,6 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Moduler section */}
|
||||
<div className="mb-4">
|
||||
<p className="px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Företagsmoduler
|
||||
</p>
|
||||
{modulerItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item.href)
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={closeMobileMenu}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors',
|
||||
active
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-muted-foreground hover:bg-secondary/50 hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Other section */}
|
||||
<div className="mb-4">
|
||||
<p className="px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
|
||||
@@ -44,9 +44,9 @@ export default function FSkattWarningCard({
|
||||
},
|
||||
info: {
|
||||
icon: Info,
|
||||
iconColor: 'text-blue-500',
|
||||
bgColor: 'bg-blue-50 dark:bg-blue-950/20',
|
||||
borderColor: 'border-blue-200 dark:border-blue-800',
|
||||
iconColor: 'text-primary',
|
||||
bgColor: 'bg-primary/5',
|
||||
borderColor: 'border-primary/20',
|
||||
badgeVariant: 'outline' as const,
|
||||
},
|
||||
warning: {
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useTransition } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
interface ModuleToggleProps {
|
||||
sectorSlug: string
|
||||
moduleSlug: string
|
||||
}
|
||||
|
||||
export function ModuleToggle({ sectorSlug, moduleSlug }: ModuleToggleProps) {
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const supabase = createClient()
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchToggle() {
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const { data } = await supabase
|
||||
.from('module_toggles')
|
||||
.select('enabled')
|
||||
.eq('user_id', user.id)
|
||||
.eq('sector_slug', sectorSlug)
|
||||
.eq('module_slug', moduleSlug)
|
||||
.maybeSingle()
|
||||
|
||||
setEnabled(data?.enabled ?? false)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
fetchToggle()
|
||||
}, [sectorSlug, moduleSlug, supabase])
|
||||
|
||||
function handleToggle(checked: boolean) {
|
||||
setEnabled(checked)
|
||||
|
||||
startTransition(async () => {
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return
|
||||
|
||||
await supabase
|
||||
.from('module_toggles')
|
||||
.upsert(
|
||||
{
|
||||
user_id: user.id,
|
||||
sector_slug: sectorSlug,
|
||||
module_slug: moduleSlug,
|
||||
enabled: checked,
|
||||
},
|
||||
{ onConflict: 'user_id,sector_slug,module_slug' }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-border bg-card px-5 py-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Laddar…</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="flex items-center justify-between gap-4 rounded-xl border border-border bg-card px-5 py-4 cursor-pointer transition-colors hover:border-primary/20">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Aktivera modul</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{enabled ? 'Modulen är aktiverad för ditt konto' : 'Aktivera för att använda modulen'}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Bell, BellOff, Loader2 } from 'lucide-react'
|
||||
|
||||
interface PushPromptProps {
|
||||
onSubscribed?: () => void
|
||||
onDismissed?: () => void
|
||||
}
|
||||
|
||||
export function PushPrompt({ onSubscribed, onDismissed }: PushPromptProps) {
|
||||
const { toast } = useToast()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isSupported, setIsSupported] = useState(false)
|
||||
const [isSubscribed, setIsSubscribed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Check if push notifications are supported
|
||||
if ('serviceWorker' in navigator && 'PushManager' in window) {
|
||||
setIsSupported(true)
|
||||
checkSubscription()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const checkSubscription = async () => {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const subscription = await registration.pushManager.getSubscription()
|
||||
setIsSubscribed(!!subscription)
|
||||
} catch (error) {
|
||||
console.error('Error checking push subscription:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const requestPermission = async () => {
|
||||
if (!isSupported) return
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
// Request notification permission
|
||||
const permission = await Notification.requestPermission()
|
||||
|
||||
if (permission !== 'granted') {
|
||||
toast({
|
||||
title: 'Aviseringar avslagna',
|
||||
description: 'Du kan aktivera aviseringar senare i inställningarna.',
|
||||
})
|
||||
setIsOpen(false)
|
||||
onDismissed?.()
|
||||
return
|
||||
}
|
||||
|
||||
// Get VAPID public key
|
||||
const vapidResponse = await fetch('/api/push/subscribe')
|
||||
const { vapidPublicKey } = await vapidResponse.json()
|
||||
|
||||
if (!vapidPublicKey) {
|
||||
throw new Error('Push notifications not configured')
|
||||
}
|
||||
|
||||
// Subscribe to push notifications
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
||||
})
|
||||
|
||||
// Send subscription to server
|
||||
const response = await fetch('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(subscription.toJSON()),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save subscription')
|
||||
}
|
||||
|
||||
setIsSubscribed(true)
|
||||
setIsOpen(false)
|
||||
|
||||
toast({
|
||||
title: 'Aviseringar aktiverade',
|
||||
description: 'Du kommer få påminnelser om viktiga deadlines.',
|
||||
})
|
||||
|
||||
onSubscribed?.()
|
||||
} catch (error) {
|
||||
console.error('Error subscribing to push:', error)
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte aktivera aviseringar. Försök igen senare.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsOpen(false)
|
||||
// Store dismissal in localStorage to not show again
|
||||
localStorage.setItem('push-prompt-dismissed', 'true')
|
||||
onDismissed?.()
|
||||
}
|
||||
|
||||
// Don't show if not supported, already subscribed, or previously dismissed
|
||||
if (!isSupported || isSubscribed) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Show dialog after a delay or when triggered
|
||||
return (
|
||||
<>
|
||||
{/* Floating button to show prompt */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="fixed bottom-4 right-4 gap-2 shadow-lg z-50"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
Aktivera aviseringar
|
||||
</Button>
|
||||
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5 text-primary" />
|
||||
Aktivera push-aviseringar
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Få påminnelser om viktiga skattedeadlines, fakturor som förfaller och
|
||||
kampanjleveranser direkt i din webbläsare.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="grid gap-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-primary rounded-full" />
|
||||
<span>Skattedeadlines (7 och 1 dag innan)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-primary rounded-full" />
|
||||
<span>Fakturor som förfaller</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-primary rounded-full" />
|
||||
<span>Kampanjleveranser</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Du kan när som helst stänga av aviseringar i inställningarna.
|
||||
Vi respekterar tysta timmar (21:00-08:00).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="ghost" onClick={handleDismiss}>
|
||||
Inte nu
|
||||
</Button>
|
||||
<Button onClick={requestPermission} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Aktiverar...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Bell className="mr-2 h-4 w-4" />
|
||||
Aktivera
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert base64 VAPID key to Uint8Array for subscription
|
||||
*/
|
||||
function urlBase64ToUint8Array(base64String: string): ArrayBuffer {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const rawData = window.atob(base64)
|
||||
const outputArray = new Uint8Array(rawData.length)
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i)
|
||||
}
|
||||
|
||||
return outputArray.buffer
|
||||
}
|
||||
@@ -1,336 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Camera, X, RotateCcw, Check, Loader2, SunMedium, Move, Focus } from 'lucide-react'
|
||||
import type { CameraQualityFeedback } from '@/types'
|
||||
|
||||
interface ReceiptCameraProps {
|
||||
onCapture: (imageData: string, mimeType: string) => Promise<void>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function ReceiptCamera({ onCapture, onClose }: ReceiptCameraProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const streamRef = useRef<MediaStream | null>(null)
|
||||
|
||||
const [isStreaming, setIsStreaming] = useState(false)
|
||||
const [capturedImage, setCapturedImage] = useState<string | null>(null)
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [quality, setQuality] = useState<CameraQualityFeedback>({
|
||||
lightingOk: false,
|
||||
distanceOk: false,
|
||||
focusOk: false,
|
||||
readyToCapture: false,
|
||||
})
|
||||
|
||||
// Start camera stream
|
||||
const startCamera = useCallback(async () => {
|
||||
try {
|
||||
setError(null)
|
||||
const constraints: MediaStreamConstraints = {
|
||||
video: {
|
||||
facingMode: 'environment', // Use back camera on mobile
|
||||
width: { ideal: 1920 },
|
||||
height: { ideal: 1080 },
|
||||
},
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia(constraints)
|
||||
streamRef.current = stream
|
||||
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = stream
|
||||
try {
|
||||
await videoRef.current.play()
|
||||
setIsStreaming(true)
|
||||
} catch (playErr) {
|
||||
// Ignore AbortError - this happens when stream is interrupted (e.g., component unmount)
|
||||
if (playErr instanceof Error && playErr.name === 'AbortError') {
|
||||
console.log('Video play interrupted - this is normal during navigation')
|
||||
return
|
||||
}
|
||||
throw playErr
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Camera access error:', err)
|
||||
// Don't show error for AbortError
|
||||
if (err instanceof Error && err.name === 'AbortError') return
|
||||
setError('Kunde inte komma åt kameran. Kontrollera att du har gett tillåtelse.')
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Stop camera stream
|
||||
const stopCamera = useCallback(() => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop())
|
||||
streamRef.current = null
|
||||
}
|
||||
setIsStreaming(false)
|
||||
}, [])
|
||||
|
||||
// Analyze video frame for quality feedback
|
||||
const analyzeQuality = useCallback(() => {
|
||||
if (!videoRef.current || !canvasRef.current || !isStreaming) return
|
||||
|
||||
const video = videoRef.current
|
||||
const canvas = canvasRef.current
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// Set canvas size to match video
|
||||
canvas.width = video.videoWidth / 4 // Downsample for analysis
|
||||
canvas.height = video.videoHeight / 4
|
||||
|
||||
// Draw current frame
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Get image data for analysis
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||
const data = imageData.data
|
||||
|
||||
// Calculate average brightness
|
||||
let totalBrightness = 0
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i]
|
||||
const g = data[i + 1]
|
||||
const b = data[i + 2]
|
||||
totalBrightness += (r + g + b) / 3
|
||||
}
|
||||
const avgBrightness = totalBrightness / (data.length / 4)
|
||||
|
||||
// Calculate contrast (standard deviation of brightness)
|
||||
let sumSquares = 0
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i]
|
||||
const g = data[i + 1]
|
||||
const b = data[i + 2]
|
||||
const brightness = (r + g + b) / 3
|
||||
sumSquares += Math.pow(brightness - avgBrightness, 2)
|
||||
}
|
||||
const contrast = Math.sqrt(sumSquares / (data.length / 4))
|
||||
|
||||
// Quality assessment
|
||||
const lightingOk = avgBrightness > 80 && avgBrightness < 200
|
||||
const focusOk = contrast > 30 // Higher contrast suggests better focus
|
||||
const distanceOk = true // Would need edge detection for proper distance check
|
||||
|
||||
const newQuality: CameraQualityFeedback = {
|
||||
lightingOk,
|
||||
distanceOk,
|
||||
focusOk,
|
||||
readyToCapture: lightingOk && focusOk && distanceOk,
|
||||
}
|
||||
|
||||
if (!lightingOk) {
|
||||
newQuality.message = avgBrightness < 80 ? 'För mörkt - öka ljuset' : 'För ljust - minska ljuset'
|
||||
} else if (!focusOk) {
|
||||
newQuality.message = 'Försök hålla kameran stilla'
|
||||
}
|
||||
|
||||
setQuality(newQuality)
|
||||
}, [isStreaming])
|
||||
|
||||
// Quality analysis loop
|
||||
useEffect(() => {
|
||||
if (!isStreaming) return
|
||||
|
||||
const interval = setInterval(analyzeQuality, 500)
|
||||
return () => clearInterval(interval)
|
||||
}, [isStreaming, analyzeQuality])
|
||||
|
||||
// Start camera on mount
|
||||
useEffect(() => {
|
||||
startCamera()
|
||||
return () => stopCamera()
|
||||
}, [startCamera, stopCamera])
|
||||
|
||||
// Capture image
|
||||
const captureImage = useCallback(() => {
|
||||
if (!videoRef.current || !canvasRef.current) return
|
||||
|
||||
const video = videoRef.current
|
||||
const canvas = canvasRef.current
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// Set canvas to full resolution
|
||||
canvas.width = video.videoWidth
|
||||
canvas.height = video.videoHeight
|
||||
|
||||
// Draw the current video frame
|
||||
ctx.drawImage(video, 0, 0)
|
||||
|
||||
// Convert to base64
|
||||
const imageData = canvas.toDataURL('image/jpeg', 0.9)
|
||||
setCapturedImage(imageData)
|
||||
stopCamera()
|
||||
}, [stopCamera])
|
||||
|
||||
// Retake photo
|
||||
const retake = useCallback(() => {
|
||||
setCapturedImage(null)
|
||||
startCamera()
|
||||
}, [startCamera])
|
||||
|
||||
// Confirm and upload
|
||||
const confirmCapture = async () => {
|
||||
if (!capturedImage) return
|
||||
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
// Extract base64 data (remove data:image/jpeg;base64, prefix)
|
||||
const base64Data = capturedImage.split(',')[1]
|
||||
await onCapture(base64Data, 'image/jpeg')
|
||||
} catch (err) {
|
||||
console.error('Upload error:', err)
|
||||
setError('Kunde inte ladda upp bilden. Försök igen.')
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Render captured image review
|
||||
if (capturedImage) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black z-[100] overflow-hidden">
|
||||
{/* Header - fixed at top */}
|
||||
<div className="absolute top-0 left-0 right-0 z-20 flex items-center justify-between p-4 bg-gradient-to-b from-black/70 to-transparent">
|
||||
<Button variant="ghost" size="icon" onClick={retake} disabled={isProcessing}>
|
||||
<RotateCcw className="h-5 w-5 text-white" />
|
||||
</Button>
|
||||
<h2 className="text-white font-medium">Granska bild</h2>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} disabled={isProcessing}>
|
||||
<X className="h-5 w-5 text-white" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Image preview - centered */}
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4 pt-20 pb-32">
|
||||
<img
|
||||
src={capturedImage}
|
||||
alt="Captured receipt"
|
||||
className="max-h-full max-w-full object-contain rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action buttons - fixed at bottom */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 z-20 flex gap-4 justify-center bg-gradient-to-t from-black/70 to-transparent pt-8 pb-8 px-4"
|
||||
style={{ paddingBottom: 'max(2rem, env(safe-area-inset-bottom, 2rem))' }}
|
||||
>
|
||||
<Button variant="outline" onClick={retake} disabled={isProcessing} className="bg-white/10 border-white text-white hover:bg-white/20">
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Ta om
|
||||
</Button>
|
||||
<Button onClick={confirmCapture} disabled={isProcessing} className="bg-white text-black hover:bg-white/90">
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Analyserar...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Använd bild
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render camera view
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black z-[100] overflow-hidden">
|
||||
{/* Header - fixed at top */}
|
||||
<div className="absolute top-0 left-0 right-0 z-20 flex items-center justify-between p-4 bg-gradient-to-b from-black/70 to-transparent">
|
||||
<div className="w-10" />
|
||||
<h2 className="text-white font-medium">Skanna kvitto</h2>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-5 w-5 text-white" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Video preview - fills the screen */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
|
||||
{/* Quality feedback overlay */}
|
||||
{isStreaming && (
|
||||
<div className="absolute bottom-36 left-0 right-0 flex justify-center z-10">
|
||||
<div className="bg-black/70 rounded-full px-4 py-2 flex items-center gap-4">
|
||||
<div className={`flex items-center gap-1 ${quality.lightingOk ? 'text-green-400' : 'text-yellow-400'}`}>
|
||||
<SunMedium className="h-4 w-4" />
|
||||
<span className="text-xs">Ljus</span>
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 ${quality.distanceOk ? 'text-green-400' : 'text-yellow-400'}`}>
|
||||
<Move className="h-4 w-4" />
|
||||
<span className="text-xs">Avstånd</span>
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 ${quality.focusOk ? 'text-green-400' : 'text-yellow-400'}`}>
|
||||
<Focus className="h-4 w-4" />
|
||||
<span className="text-xs">Fokus</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quality message */}
|
||||
{quality.message && (
|
||||
<div className="absolute top-20 left-0 right-0 flex justify-center z-10">
|
||||
<div className="bg-yellow-500/90 text-black px-4 py-2 rounded-full text-sm font-medium">
|
||||
{quality.message}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/80 z-30">
|
||||
<div className="text-center p-4">
|
||||
<p className="text-white mb-4">{error}</p>
|
||||
<Button onClick={startCamera}>Försök igen</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Guide overlay */}
|
||||
{isStreaming && (
|
||||
<div className="absolute inset-0 pointer-events-none z-10">
|
||||
<div className="absolute top-20 left-8 right-8 bottom-36 border-2 border-white/30 rounded-lg">
|
||||
<div className="absolute top-0 left-0 w-8 h-8 border-t-2 border-l-2 border-white rounded-tl-lg" />
|
||||
<div className="absolute top-0 right-0 w-8 h-8 border-t-2 border-r-2 border-white rounded-tr-lg" />
|
||||
<div className="absolute bottom-0 left-0 w-8 h-8 border-b-2 border-l-2 border-white rounded-bl-lg" />
|
||||
<div className="absolute bottom-0 right-0 w-8 h-8 border-b-2 border-r-2 border-white rounded-br-lg" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Capture button - fixed at bottom */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 z-20 flex justify-center bg-gradient-to-t from-black/70 to-transparent pt-8 pb-8"
|
||||
style={{ paddingBottom: 'max(2rem, env(safe-area-inset-bottom, 2rem))' }}
|
||||
>
|
||||
<button
|
||||
onClick={captureImage}
|
||||
disabled={!isStreaming}
|
||||
className="h-20 w-20 rounded-full border-4 border-white flex items-center justify-center bg-white/20 active:bg-white/40 disabled:opacity-50 transition-colors shadow-2xl backdrop-blur-sm"
|
||||
>
|
||||
<Camera className="h-8 w-8 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Hidden canvas for image processing */}
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Flame, Receipt, CreditCard, Camera, ArrowRight } from 'lucide-react'
|
||||
import type { ReceiptQueueSummary } from '@/types'
|
||||
|
||||
interface ReceiptDashboardProps {
|
||||
summary: ReceiptQueueSummary
|
||||
onScanReceipt: () => void
|
||||
onViewReceiptQueue: () => void
|
||||
onViewTransactionQueue: () => void
|
||||
}
|
||||
|
||||
export default function ReceiptDashboard({
|
||||
summary,
|
||||
onScanReceipt,
|
||||
onViewReceiptQueue,
|
||||
onViewTransactionQueue,
|
||||
}: ReceiptDashboardProps) {
|
||||
const hasWork =
|
||||
summary.unmatched_receipts_count > 0 ||
|
||||
summary.unmatched_transactions_count > 0 ||
|
||||
summary.pending_review_count > 0
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Quick actions */}
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<Button
|
||||
variant="default"
|
||||
className="h-auto py-4 flex-col gap-2"
|
||||
onClick={onScanReceipt}
|
||||
>
|
||||
<Camera className="h-6 w-6" />
|
||||
<span>Skanna kvitto</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Streak counter */}
|
||||
{summary.streak_count > 0 && (
|
||||
<Card className="bg-gradient-to-r from-orange-500/10 to-yellow-500/10 border-orange-500/20">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-12 w-12 rounded-full bg-orange-500/20 flex items-center justify-center">
|
||||
<Flame className="h-6 w-6 text-orange-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{summary.streak_count}</p>
|
||||
<p className="text-sm text-muted-foreground">dagars streak!</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground max-w-24 text-right">
|
||||
Fortsätt kategorisera varje dag
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Queue cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{/* Pending review */}
|
||||
{summary.pending_review_count > 0 && (
|
||||
<Card
|
||||
className="cursor-pointer hover:border-primary/50 transition-colors"
|
||||
onClick={onViewReceiptQueue}
|
||||
>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-full bg-yellow-500/10 flex items-center justify-center">
|
||||
<Receipt className="h-5 w-5 text-yellow-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Att granska</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{summary.pending_review_count} kvitton
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary">{summary.pending_review_count}</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Unmatched receipts */}
|
||||
{summary.unmatched_receipts_count > 0 && (
|
||||
<Card
|
||||
className="cursor-pointer hover:border-primary/50 transition-colors"
|
||||
onClick={onViewReceiptQueue}
|
||||
>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-full bg-blue-500/10 flex items-center justify-center">
|
||||
<Receipt className="h-5 w-5 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Kvitton utan transaktion</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{summary.unmatched_receipts_count} st
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Unmatched transactions */}
|
||||
{summary.unmatched_transactions_count > 0 && (
|
||||
<Card
|
||||
className="cursor-pointer hover:border-primary/50 transition-colors"
|
||||
onClick={onViewTransactionQueue}
|
||||
>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-full bg-purple-500/10 flex items-center justify-center">
|
||||
<CreditCard className="h-5 w-5 text-purple-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Transaktioner utan kvitto</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{summary.unmatched_transactions_count} st
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* All done state */}
|
||||
{!hasWork && (
|
||||
<Card className="bg-gradient-to-r from-green-500/10 to-emerald-500/10 border-green-500/20">
|
||||
<CardContent className="pt-4 text-center py-8">
|
||||
<div className="h-16 w-16 rounded-full bg-green-500/20 flex items-center justify-center mx-auto mb-4">
|
||||
<svg
|
||||
className="h-8 w-8 text-green-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg">Allt är uppdaterat!</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Inga kvitton eller transaktioner att granska
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { getExpenseCategories, CATEGORY_LABELS } from '@/lib/receipts/receipt-categorizer'
|
||||
import type { ReceiptLineItem, TransactionCategory } from '@/types'
|
||||
|
||||
interface ReceiptLineItemRowProps {
|
||||
item: ReceiptLineItem
|
||||
onToggleBusiness: (id: string, isBusiness: boolean) => void
|
||||
onCategoryChange: (id: string, category: TransactionCategory) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function ReceiptLineItemRow({
|
||||
item,
|
||||
onToggleBusiness,
|
||||
onCategoryChange,
|
||||
disabled = false,
|
||||
}: ReceiptLineItemRowProps) {
|
||||
const expenseCategories = getExpenseCategories()
|
||||
const isBusiness = item.is_business === true
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3 border rounded-lg bg-card">
|
||||
{/* Description and amount */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm truncate">{item.description}</p>
|
||||
{item.quantity !== 1 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{item.quantity} x {item.unit_price ? formatCurrency(item.unit_price, 'SEK') : '—'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className="font-semibold">{formatCurrency(item.line_total, 'SEK')}</p>
|
||||
{item.vat_rate && (
|
||||
<p className="text-xs text-muted-foreground">{item.vat_rate}% moms</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Classification controls */}
|
||||
<div className="flex items-center gap-3 pt-2 border-t">
|
||||
{/* Business/Private toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs ${!isBusiness ? 'text-muted-foreground' : 'font-medium'}`}>
|
||||
Privat
|
||||
</span>
|
||||
<Switch
|
||||
checked={isBusiness}
|
||||
onCheckedChange={(checked) => onToggleBusiness(item.id, checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className={`text-xs ${isBusiness ? 'font-medium' : 'text-muted-foreground'}`}>
|
||||
Företag
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Category selector (only for business) */}
|
||||
{isBusiness && (
|
||||
<Select
|
||||
value={item.category || undefined}
|
||||
onValueChange={(value) => onCategoryChange(item.id, value as TransactionCategory)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-32 text-xs">
|
||||
<SelectValue placeholder="Kategori" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{expenseCategories.map((cat) => (
|
||||
<SelectItem key={cat.value} value={cat.value}>
|
||||
{cat.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{/* Confidence badge */}
|
||||
{item.extraction_confidence !== null && item.extraction_confidence < 0.8 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{Math.round((item.extraction_confidence || 0) * 100)}%
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* AI suggestion indicator */}
|
||||
{item.suggested_category && item.is_business === null && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Förslag: {CATEGORY_LABELS[`expense_${item.suggested_category}` as TransactionCategory] || item.suggested_category}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,418 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
X,
|
||||
Building,
|
||||
User,
|
||||
Wine,
|
||||
Utensils,
|
||||
Globe,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { calculateReceiptSplit, getDefaultClassification } from '@/lib/receipts/receipt-categorizer'
|
||||
import { calculateRepresentationLimits } from '@/lib/receipts/receipt-utils'
|
||||
import ReceiptLineItemRow from './ReceiptLineItemRow'
|
||||
import type { Receipt, ReceiptLineItem, TransactionCategory, ConfirmLineItemInput } from '@/types'
|
||||
|
||||
interface ReceiptReviewViewProps {
|
||||
receipt: Receipt & { line_items: ReceiptLineItem[] }
|
||||
onConfirm: (data: {
|
||||
line_items: ConfirmLineItemInput[]
|
||||
representation_persons?: number
|
||||
representation_purpose?: string
|
||||
}) => Promise<void>
|
||||
onCancel: () => void
|
||||
onFindMatches?: () => void
|
||||
}
|
||||
|
||||
export default function ReceiptReviewView({
|
||||
receipt,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
onFindMatches,
|
||||
}: ReceiptReviewViewProps) {
|
||||
// Initialize line items with their current state
|
||||
const [lineItems, setLineItems] = useState<ReceiptLineItem[]>(receipt.line_items || [])
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [showImage, setShowImage] = useState(false)
|
||||
|
||||
// Restaurant representation state
|
||||
const [representationPersons, setRepresentationPersons] = useState<number | undefined>(
|
||||
receipt.representation_persons || undefined
|
||||
)
|
||||
const [representationPurpose, setRepresentationPurpose] = useState<string>(
|
||||
receipt.representation_purpose || ''
|
||||
)
|
||||
|
||||
// Get default classification info
|
||||
const defaultClassification = getDefaultClassification(
|
||||
receipt.is_restaurant,
|
||||
receipt.is_systembolaget
|
||||
)
|
||||
|
||||
// Calculate split summary
|
||||
const splitSummary = useMemo(() => {
|
||||
return calculateReceiptSplit(lineItems.map(item => ({
|
||||
lineTotal: item.line_total,
|
||||
is_business: item.is_business,
|
||||
})))
|
||||
}, [lineItems])
|
||||
|
||||
// Calculate representation limits if restaurant
|
||||
const representationCalc = useMemo(() => {
|
||||
if (!receipt.is_restaurant || !representationPersons || !receipt.total_amount) return null
|
||||
return calculateRepresentationLimits(receipt.total_amount, representationPersons)
|
||||
}, [receipt.is_restaurant, receipt.total_amount, representationPersons])
|
||||
|
||||
// Toggle business status for a line item
|
||||
const handleToggleBusiness = (id: string, isBusiness: boolean) => {
|
||||
setLineItems((items) =>
|
||||
items.map((item) =>
|
||||
item.id === id
|
||||
? { ...item, is_business: isBusiness, category: isBusiness ? item.category : null }
|
||||
: item
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Change category for a line item
|
||||
const handleCategoryChange = (id: string, category: TransactionCategory) => {
|
||||
setLineItems((items) =>
|
||||
items.map((item) => (item.id === id ? { ...item, category } : item))
|
||||
)
|
||||
}
|
||||
|
||||
// Mark all as business
|
||||
const handleAllBusiness = () => {
|
||||
setLineItems((items) =>
|
||||
items.map((item) => ({
|
||||
...item,
|
||||
is_business: true,
|
||||
category: item.category || 'expense_other',
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
// Mark all as private
|
||||
const handleAllPrivate = () => {
|
||||
setLineItems((items) =>
|
||||
items.map((item) => ({
|
||||
...item,
|
||||
is_business: false,
|
||||
category: null,
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
// Confirm and save
|
||||
const handleConfirm = async () => {
|
||||
// Validate restaurant representation
|
||||
if (receipt.is_restaurant && splitSummary.businessTotal > 0) {
|
||||
if (!representationPersons || representationPersons < 1) {
|
||||
alert('Ange antal personer för restaurangrepresentation')
|
||||
return
|
||||
}
|
||||
if (!representationPurpose.trim()) {
|
||||
alert('Ange syfte för restaurangrepresentation')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
await onConfirm({
|
||||
line_items: lineItems.map((item) => ({
|
||||
id: item.id,
|
||||
is_business: item.is_business ?? false,
|
||||
category: item.category || undefined,
|
||||
bas_account: item.bas_account || undefined,
|
||||
})),
|
||||
representation_persons: representationPersons,
|
||||
representation_purpose: representationPurpose.trim() || undefined,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Confirm error:', error)
|
||||
} finally {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-background z-50 flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b flex-shrink-0">
|
||||
<Button variant="ghost" size="icon" onClick={onCancel} disabled={isProcessing} aria-label="Avbryt">
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="font-semibold">Granska kvitto</h1>
|
||||
<div className="w-10" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-4 space-y-4">
|
||||
{/* Merchant and total card */}
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg">{receipt.merchant_name || 'Okänd handlare'}</h2>
|
||||
{receipt.receipt_date && (
|
||||
<p className="text-sm text-muted-foreground">{formatDate(receipt.receipt_date)}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-bold">
|
||||
{formatCurrency(receipt.total_amount || 0, receipt.currency)}
|
||||
</p>
|
||||
{receipt.vat_amount && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
varav moms {formatCurrency(receipt.vat_amount, receipt.currency)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Special flags */}
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{receipt.is_restaurant && (
|
||||
<Badge variant="secondary">
|
||||
<Utensils className="mr-1 h-3 w-3" /> Restaurang
|
||||
</Badge>
|
||||
)}
|
||||
{receipt.is_systembolaget && (
|
||||
<Badge variant="secondary">
|
||||
<Wine className="mr-1 h-3 w-3" /> Systembolaget
|
||||
</Badge>
|
||||
)}
|
||||
{receipt.is_foreign_merchant && (
|
||||
<Badge variant="secondary">
|
||||
<Globe className="mr-1 h-3 w-3" /> Utländskt
|
||||
</Badge>
|
||||
)}
|
||||
{receipt.extraction_confidence && (
|
||||
<Badge
|
||||
variant={receipt.extraction_confidence > 0.8 ? 'outline' : receipt.extraction_confidence > 0.5 ? 'warning' : 'destructive'}
|
||||
className={receipt.extraction_confidence > 0.8 ? 'border-success/50 text-success' : ''}
|
||||
>
|
||||
{Math.round(receipt.extraction_confidence * 100)}% säkerhet
|
||||
{receipt.extraction_confidence <= 0.8 && ' — kontrollera'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Toggle image preview */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-2 text-xs"
|
||||
onClick={() => setShowImage(!showImage)}
|
||||
>
|
||||
{showImage ? <ChevronUp className="mr-1 h-3 w-3" /> : <ChevronDown className="mr-1 h-3 w-3" />}
|
||||
{showImage ? 'Dölj bild' : 'Visa bild'}
|
||||
</Button>
|
||||
|
||||
{showImage && receipt.image_url && (
|
||||
<img
|
||||
src={receipt.image_url}
|
||||
alt="Receipt"
|
||||
className="mt-2 max-h-48 rounded-lg object-contain"
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Warning message */}
|
||||
{defaultClassification.warningMessage && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-800">
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
{defaultClassification.warningMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Restaurant representation section */}
|
||||
{receipt.is_restaurant && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Utensils className="h-4 w-4" />
|
||||
Representation
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="persons" className="text-xs">
|
||||
Antal personer
|
||||
</Label>
|
||||
<Input
|
||||
id="persons"
|
||||
type="number"
|
||||
min={1}
|
||||
value={representationPersons || ''}
|
||||
onChange={(e) => setRepresentationPersons(parseInt(e.target.value) || undefined)}
|
||||
placeholder="2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Per person</Label>
|
||||
<p className="h-10 flex items-center font-medium">
|
||||
{representationPersons && receipt.total_amount
|
||||
? formatCurrency(receipt.total_amount / representationPersons, receipt.currency)
|
||||
: '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="purpose" className="text-xs">
|
||||
Syfte med representationen
|
||||
</Label>
|
||||
<Textarea
|
||||
id="purpose"
|
||||
value={representationPurpose}
|
||||
onChange={(e) => setRepresentationPurpose(e.target.value)}
|
||||
placeholder="T.ex. kundmöte, kontraktsförhandling..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{representationCalc && (
|
||||
<div className="p-2 rounded bg-muted text-xs space-y-1">
|
||||
<p>
|
||||
<span className="text-muted-foreground">Avdragsgill del:</span>{' '}
|
||||
<span className="font-medium text-green-600">
|
||||
{formatCurrency(representationCalc.deductibleAmount, receipt.currency)}
|
||||
</span>
|
||||
{' '}(max {representationCalc.maxDeductiblePerPerson} kr/person)
|
||||
</p>
|
||||
{representationCalc.nonDeductibleAmount > 0 && (
|
||||
<p>
|
||||
<span className="text-muted-foreground">Ej avdragsgill del:</span>{' '}
|
||||
<span className="font-medium text-orange-600">
|
||||
{formatCurrency(representationCalc.nonDeductibleAmount, receipt.currency)}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="flex-1" onClick={handleAllBusiness}>
|
||||
<Building className="mr-2 h-4 w-4" />
|
||||
Allt företag
|
||||
</Button>
|
||||
<Button variant="outline" className="flex-1" onClick={handleAllPrivate}>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Allt privat
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Line items */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium text-sm flex items-center justify-between">
|
||||
Artiklar ({lineItems.length})
|
||||
<span className="text-muted-foreground font-normal">
|
||||
Svajpa eller klicka för att klassificera
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
{lineItems.map((item) => (
|
||||
<ReceiptLineItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
onToggleBusiness={handleToggleBusiness}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Företag:</span>
|
||||
<span className="font-medium text-green-600">
|
||||
{formatCurrency(splitSummary.businessTotal, receipt.currency)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Privat:</span>
|
||||
<span className="font-medium text-orange-600">
|
||||
{formatCurrency(splitSummary.privateTotal, receipt.currency)}
|
||||
</span>
|
||||
</div>
|
||||
{splitSummary.unclassifiedTotal > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Oklassificerat:</span>
|
||||
<span className="font-medium text-yellow-600">
|
||||
{formatCurrency(splitSummary.unclassifiedTotal, receipt.currency)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="pt-2 border-t flex justify-between font-medium">
|
||||
<span>Totalt:</span>
|
||||
<span>{formatCurrency(receipt.total_amount || 0, receipt.currency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Footer actions - large touch targets for mobile */}
|
||||
<div className="p-4 border-t flex-shrink-0 space-y-2 safe-area-bottom">
|
||||
{onFindMatches && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-12 text-base"
|
||||
onClick={onFindMatches}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Hitta matchande transaktion
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="w-full h-14 text-base font-medium"
|
||||
onClick={handleConfirm}
|
||||
disabled={isProcessing || splitSummary.unclassifiedTotal > 0}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
Sparar...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-5 w-5" />
|
||||
Bekräfta klassificering
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{splitSummary.unclassifiedTotal > 0 && (
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
Klassificera alla artiklar innan du bekräftar
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { X, Check, Link2, AlertCircle, Search, Loader2 } from 'lucide-react'
|
||||
import type { Receipt, ReceiptMatchCandidate, Transaction } from '@/types'
|
||||
|
||||
interface TransactionMatcherProps {
|
||||
receipt: Receipt
|
||||
onMatch: (transactionId: string, confidence: number) => Promise<void>
|
||||
onSkip: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function TransactionMatcher({
|
||||
receipt,
|
||||
onMatch,
|
||||
onSkip,
|
||||
onClose,
|
||||
}: TransactionMatcherProps) {
|
||||
const [matches, setMatches] = useState<ReceiptMatchCandidate[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isMatching, setIsMatching] = useState(false)
|
||||
const [selectedMatch, setSelectedMatch] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Fetch matches on mount
|
||||
useEffect(() => {
|
||||
const fetchMatches = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await fetch(`/api/receipts/${receipt.id}/match`, {
|
||||
method: 'POST',
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok && data.data?.matches) {
|
||||
setMatches(data.data.matches)
|
||||
} else {
|
||||
setError(data.error || 'Kunde inte hämta matchningar')
|
||||
}
|
||||
} catch {
|
||||
setError('Nätverksfel vid hämtning av matchningar')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchMatches()
|
||||
}, [receipt.id])
|
||||
|
||||
// Handle match confirmation
|
||||
const handleConfirmMatch = async () => {
|
||||
if (!selectedMatch) return
|
||||
|
||||
const match = matches.find((m) => m.transaction.id === selectedMatch)
|
||||
if (!match) return
|
||||
|
||||
setIsMatching(true)
|
||||
try {
|
||||
await onMatch(selectedMatch, match.confidence)
|
||||
} catch {
|
||||
setError('Kunde inte koppla kvitto till transaktion')
|
||||
} finally {
|
||||
setIsMatching(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-background z-50 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="font-semibold">Matcha transaktion</h1>
|
||||
<div className="w-10" />
|
||||
</div>
|
||||
|
||||
{/* Receipt summary */}
|
||||
<Card className="m-4 mb-2">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">{receipt.merchant_name || 'Kvitto'}</p>
|
||||
{receipt.receipt_date && (
|
||||
<p className="text-sm text-muted-foreground">{formatDate(receipt.receipt_date)}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xl font-bold">
|
||||
{formatCurrency(receipt.total_amount || 0, receipt.currency)}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto px-4 pb-4">
|
||||
<h2 className="text-sm font-medium text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<Search className="h-4 w-4" />
|
||||
Möjliga matchningar
|
||||
</h2>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="pt-4">
|
||||
<Skeleton className="h-4 w-2/3 mb-2" />
|
||||
<Skeleton className="h-6 w-1/3 mb-2" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="pt-4 flex items-center gap-3 text-destructive">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<p>{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : matches.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-4 text-center py-8">
|
||||
<Search className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
|
||||
<p className="font-medium">Inga matchande transaktioner hittades</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Det finns ingen banktransaktion som matchar detta kvitto
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{matches.map((match) => (
|
||||
<Card
|
||||
key={match.transaction.id}
|
||||
className={`cursor-pointer transition-colors ${
|
||||
selectedMatch === match.transaction.id
|
||||
? 'border-primary ring-2 ring-primary/20'
|
||||
: 'hover:border-primary/50'
|
||||
}`}
|
||||
onClick={() => setSelectedMatch(match.transaction.id)}
|
||||
>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{match.transaction.description}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatDate(match.transaction.date)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className="font-bold">
|
||||
{formatCurrency(Math.abs(match.transaction.amount), match.transaction.currency)}
|
||||
</p>
|
||||
<Badge
|
||||
variant={match.confidence > 0.8 ? 'default' : match.confidence > 0.6 ? 'secondary' : 'outline'}
|
||||
>
|
||||
{Math.round(match.confidence * 100)}% match
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Match reasons */}
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{match.matchReasons.map((reason, idx) => (
|
||||
<Badge key={idx} variant="outline" className="text-xs">
|
||||
{reason}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Variance info */}
|
||||
<div className="flex gap-4 mt-2 text-xs text-muted-foreground">
|
||||
{match.dateVariance > 0 && (
|
||||
<span>Datum: ±{Math.round(match.dateVariance)} dagar</span>
|
||||
)}
|
||||
{match.amountVariance > 0.01 && (
|
||||
<span>Belopp: ±{Math.round(match.amountVariance * 100)}%</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selected indicator */}
|
||||
{selectedMatch === match.transaction.id && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<div className="h-6 w-6 rounded-full bg-primary flex items-center justify-center">
|
||||
<Check className="h-4 w-4 text-primary-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="p-4 border-t flex gap-3">
|
||||
<Button variant="outline" className="flex-1" onClick={onSkip}>
|
||||
Hoppa över
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={handleConfirmMatch}
|
||||
disabled={!selectedMatch || isMatching}
|
||||
>
|
||||
{isMatching ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Kopplar...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link2 className="mr-2 h-4 w-4" />
|
||||
Koppla transaktion
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export { default as ReceiptCamera } from './ReceiptCamera'
|
||||
export { default as ReceiptLineItemRow } from './ReceiptLineItemRow'
|
||||
export { default as ReceiptReviewView } from './ReceiptReviewView'
|
||||
export { default as TransactionMatcher } from './TransactionMatcher'
|
||||
export { default as ReceiptDashboard } from './ReceiptDashboard'
|
||||
@@ -1,349 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Bell, BellOff, Loader2, Moon } from 'lucide-react'
|
||||
import type { NotificationSettings as NotificationSettingsType } from '@/types'
|
||||
|
||||
interface NotificationSettingsProps {
|
||||
onSettingsChange?: () => void
|
||||
}
|
||||
|
||||
export function NotificationSettings({ onSettingsChange }: NotificationSettingsProps) {
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [settings, setSettings] = useState<NotificationSettingsType | null>(null)
|
||||
const [isPushSupported, setIsPushSupported] = useState(false)
|
||||
const [isPushSubscribed, setIsPushSubscribed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings()
|
||||
checkPushSupport()
|
||||
}, [])
|
||||
|
||||
const checkPushSupport = async () => {
|
||||
if ('serviceWorker' in navigator && 'PushManager' in window) {
|
||||
setIsPushSupported(true)
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const subscription = await registration.pushManager.getSubscription()
|
||||
setIsPushSubscribed(!!subscription)
|
||||
} catch (error) {
|
||||
console.error('Error checking push subscription:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSettings = async () => {
|
||||
setIsLoading(true)
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('notification_settings')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!error && data) {
|
||||
setSettings(data)
|
||||
} else {
|
||||
// Create default settings if not found
|
||||
const { data: newSettings, error: insertError } = await supabase
|
||||
.from('notification_settings')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
tax_deadlines_enabled: true,
|
||||
invoice_reminders_enabled: true,
|
||||
push_enabled: true,
|
||||
email_enabled: true,
|
||||
quiet_start: '21:00',
|
||||
quiet_end: '08:00',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (!insertError && newSettings) {
|
||||
setSettings(newSettings)
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const updateSetting = async (key: keyof NotificationSettingsType, value: boolean | string) => {
|
||||
if (!settings) return
|
||||
|
||||
setIsSaving(true)
|
||||
|
||||
const { error } = await supabase
|
||||
.from('notification_settings')
|
||||
.update({ [key]: value })
|
||||
.eq('id', settings.id)
|
||||
|
||||
if (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte spara inställning',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else {
|
||||
setSettings({ ...settings, [key]: value })
|
||||
onSettingsChange?.()
|
||||
}
|
||||
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
const subscribeToPush = async () => {
|
||||
try {
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission !== 'granted') {
|
||||
toast({
|
||||
title: 'Aviseringar avslagna',
|
||||
description: 'Du behöver ge tillåtelse för push-aviseringar i webbläsaren.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const vapidResponse = await fetch('/api/push/subscribe')
|
||||
const { vapidPublicKey } = await vapidResponse.json()
|
||||
|
||||
if (!vapidPublicKey) {
|
||||
throw new Error('Push not configured')
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
||||
})
|
||||
|
||||
const response = await fetch('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(subscription.toJSON()),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save subscription')
|
||||
}
|
||||
|
||||
setIsPushSubscribed(true)
|
||||
toast({
|
||||
title: 'Push-aviseringar aktiverade',
|
||||
description: 'Du kommer nu få push-aviseringar.',
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte aktivera push-aviseringar.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribeFromPush = async () => {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const subscription = await registration.pushManager.getSubscription()
|
||||
|
||||
if (subscription) {
|
||||
await subscription.unsubscribe()
|
||||
|
||||
await fetch('/api/push/subscribe', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ endpoint: subscription.endpoint }),
|
||||
})
|
||||
}
|
||||
|
||||
setIsPushSubscribed(false)
|
||||
toast({
|
||||
title: 'Push-aviseringar inaktiverade',
|
||||
description: 'Du kommer inte längre få push-aviseringar.',
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte inaktivera push-aviseringar.',
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
if (!settings) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Push notification status */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5" />
|
||||
Push-aviseringar
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Få påminnelser direkt i din webbläsare
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isPushSupported ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{isPushSubscribed ? 'Aktiverade' : 'Inaktiverade'}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isPushSubscribed
|
||||
? 'Du får push-aviseringar på denna enhet'
|
||||
: 'Aktivera för att få push-aviseringar'}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={isPushSubscribed ? 'outline' : 'default'}
|
||||
onClick={isPushSubscribed ? unsubscribeFromPush : subscribeToPush}
|
||||
>
|
||||
{isPushSubscribed ? (
|
||||
<>
|
||||
<BellOff className="mr-2 h-4 w-4" />
|
||||
Inaktivera
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Bell className="mr-2 h-4 w-4" />
|
||||
Aktivera
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Din webbläsare stöder inte push-aviseringar.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Category settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Aviseringskategorier</CardTitle>
|
||||
<CardDescription>
|
||||
Välj vilka typer av aviseringar du vill få
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="tax-deadlines">Skattedeadlines</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Moms, F-skatt, inkomstdeklaration m.m.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="tax-deadlines"
|
||||
checked={settings.tax_deadlines_enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSetting('tax_deadlines_enabled', checked)
|
||||
}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="invoice-reminders">Fakturapåminnelser</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Fakturor som förfaller eller är försenade
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="invoice-reminders"
|
||||
checked={settings.invoice_reminders_enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSetting('invoice_reminders_enabled', checked)
|
||||
}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quiet hours */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Moon className="h-5 w-5" />
|
||||
Tysta timmar
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Inga aviseringar skickas under dessa timmar
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quiet-start">Från</Label>
|
||||
<Input
|
||||
id="quiet-start"
|
||||
type="time"
|
||||
value={settings.quiet_start}
|
||||
onChange={(e) => updateSetting('quiet_start', e.target.value)}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quiet-end">Till</Label>
|
||||
<Input
|
||||
id="quiet-end"
|
||||
type="time"
|
||||
value={settings.quiet_end}
|
||||
onChange={(e) => updateSetting('quiet_end', e.target.value)}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Standard: 21:00 - 08:00 (Sveriges tid)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string): ArrayBuffer {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const rawData = window.atob(base64)
|
||||
const outputArray = new Uint8Array(rawData.length)
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i)
|
||||
}
|
||||
|
||||
return outputArray.buffer
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { SCHABLONAVDRAG_RATES, type HousingType } from '@/lib/tax/schablonavdrag'
|
||||
import { Home, Car, Loader2, Info, Building } from 'lucide-react'
|
||||
import type { SchablonavdragSettings as SchablonavdragSettingsType } from '@/types'
|
||||
|
||||
interface SchablonavdragSettingsProps {
|
||||
settings: SchablonavdragSettingsType
|
||||
onSave: (settings: SchablonavdragSettingsType) => Promise<void>
|
||||
}
|
||||
|
||||
export default function SchablonavdragSettings({
|
||||
settings: initialSettings,
|
||||
onSave,
|
||||
}: SchablonavdragSettingsProps) {
|
||||
const { toast } = useToast()
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [settings, setSettings] = useState<SchablonavdragSettingsType>(initialSettings)
|
||||
|
||||
const hasChanges =
|
||||
settings.hemmakontor_enabled !== initialSettings.hemmakontor_enabled ||
|
||||
settings.hemmakontor_housing_type !== initialSettings.hemmakontor_housing_type ||
|
||||
settings.bil_enabled !== initialSettings.bil_enabled
|
||||
|
||||
// Get the current hemmakontor amount based on housing type
|
||||
const housingType = settings.hemmakontor_housing_type || 'apartment'
|
||||
const hemmakontorAmount = housingType === 'villa'
|
||||
? SCHABLONAVDRAG_RATES.hemmakontor.villa
|
||||
: SCHABLONAVDRAG_RATES.hemmakontor.apartment
|
||||
|
||||
async function handleSave() {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await onSave(settings)
|
||||
toast({
|
||||
title: 'Sparat',
|
||||
description: 'Dina schablonavdragsinställningar har uppdaterats',
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte spara inställningar',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
function setHousingType(type: HousingType) {
|
||||
setSettings({ ...settings, hemmakontor_housing_type: type })
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
Schablonavdrag
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Aktivera schablonmässiga avdrag som inte kräver kvitton
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Hemmakontor */}
|
||||
<div className="p-4 border rounded-lg space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Home className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="hemmakontor" className="text-base font-medium">
|
||||
Hemmakontor
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Schablonavdrag för hemmakontor (minst 800 tim/år)
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium text-success">
|
||||
{formatCurrency(hemmakontorAmount)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">per år</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="hemmakontor"
|
||||
checked={settings.hemmakontor_enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings({ ...settings, hemmakontor_enabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Housing type selection */}
|
||||
{settings.hemmakontor_enabled && (
|
||||
<div className="ml-14 space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">Typ av bostad</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={housingType === 'apartment' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setHousingType('apartment')}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Building className="h-4 w-4" />
|
||||
Hyresrätt/Bostadsrätt
|
||||
<span className="text-xs opacity-70">({formatCurrency(SCHABLONAVDRAG_RATES.hemmakontor.apartment)})</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={housingType === 'villa' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setHousingType('villa')}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
Villa
|
||||
<span className="text-xs opacity-70">({formatCurrency(SCHABLONAVDRAG_RATES.hemmakontor.villa)})</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bilkostnader */}
|
||||
<div className="flex items-start justify-between p-4 border rounded-lg">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Car className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="bil" className="text-base font-medium">
|
||||
Bilkostnader (körjournal)
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Milersättning för tjänsteresor med egen bil
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium text-success">
|
||||
{SCHABLONAVDRAG_RATES.bil.rate_per_mil} kr/mil
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
({SCHABLONAVDRAG_RATES.bil.rate_per_km.toFixed(2)} kr/km)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="bil"
|
||||
checked={settings.bil_enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings({ ...settings, bil_enabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info box */}
|
||||
<div className="flex items-start gap-3 p-4 rounded-lg bg-muted/50">
|
||||
<Info className="h-5 w-5 text-muted-foreground flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground">Om schablonavdrag</p>
|
||||
<ul className="mt-2 space-y-1 list-disc list-inside">
|
||||
<li>
|
||||
<strong>Hemmakontor:</strong> Kräver minst 800 timmars arbete hemifrån per år.
|
||||
Avdrag: {formatCurrency(SCHABLONAVDRAG_RATES.hemmakontor.apartment)} (lägenhet) eller{' '}
|
||||
{formatCurrency(SCHABLONAVDRAG_RATES.hemmakontor.villa)} (villa).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Bilkostnader:</strong> Kräver att du loggar dina resor i körjournalen.
|
||||
Du får {SCHABLONAVDRAG_RATES.bil.rate_per_mil} kr per mil ({SCHABLONAVDRAG_RATES.bil.rate_per_km.toFixed(2)} kr/km).
|
||||
</li>
|
||||
<li>Schablonavdrag minskar din beskattningsbara inkomst</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save button */}
|
||||
{hasChanges && (
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sparar...
|
||||
</>
|
||||
) : (
|
||||
'Spara ändringar'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { SCHABLONAVDRAG_RATES, validateMileageEntry } from '@/lib/tax/schablonavdrag'
|
||||
import { Car, Loader2, MapPin, Calendar, FileText, X } from 'lucide-react'
|
||||
import type { CreateMileageEntryInput } from '@/types'
|
||||
|
||||
interface MileageEntryProps {
|
||||
onSave: (entry: CreateMileageEntryInput) => Promise<void>
|
||||
onCancel?: () => void
|
||||
initialDate?: string
|
||||
}
|
||||
|
||||
export default function MileageEntry({
|
||||
onSave,
|
||||
onCancel,
|
||||
initialDate,
|
||||
}: MileageEntryProps) {
|
||||
const { toast } = useToast()
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [errors, setErrors] = useState<string[]>([])
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const [formData, setFormData] = useState<CreateMileageEntryInput>({
|
||||
date: initialDate || today,
|
||||
distance_km: 0,
|
||||
purpose: '',
|
||||
from_location: '',
|
||||
to_location: '',
|
||||
})
|
||||
|
||||
// Calculate deduction preview
|
||||
const deductionPreview = formData.distance_km > 0
|
||||
? formData.distance_km * SCHABLONAVDRAG_RATES.bil.rate_per_km
|
||||
: 0
|
||||
|
||||
function handleChange(
|
||||
field: keyof CreateMileageEntryInput,
|
||||
value: string | number
|
||||
) {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||
setErrors([]) // Clear errors on change
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
|
||||
// Validate
|
||||
const validation = validateMileageEntry({
|
||||
date: formData.date,
|
||||
distance_km: formData.distance_km,
|
||||
purpose: formData.purpose,
|
||||
})
|
||||
|
||||
if (!validation.valid) {
|
||||
setErrors(validation.errors)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await onSave(formData)
|
||||
toast({
|
||||
title: 'Körning registrerad',
|
||||
description: `${formData.distance_km} km - avdrag ${formatCurrency(deductionPreview)}`,
|
||||
})
|
||||
// Reset form
|
||||
setFormData({
|
||||
date: today,
|
||||
distance_km: 0,
|
||||
purpose: '',
|
||||
from_location: '',
|
||||
to_location: '',
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte spara körningen',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Car className="h-5 w-5" />
|
||||
Registrera körning
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Logga en tjänsteresa för milersättning
|
||||
</CardDescription>
|
||||
</div>
|
||||
{onCancel && (
|
||||
<Button variant="ghost" size="icon" onClick={onCancel}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Error display */}
|
||||
{errors.length > 0 && (
|
||||
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/20">
|
||||
<ul className="text-sm text-destructive space-y-1">
|
||||
{errors.map((error, idx) => (
|
||||
<li key={idx}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Date and distance row */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="date" className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Datum
|
||||
</Label>
|
||||
<Input
|
||||
id="date"
|
||||
type="date"
|
||||
value={formData.date}
|
||||
max={today}
|
||||
onChange={(e) => handleChange('date', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="distance_km">Avstånd (km)</Label>
|
||||
<Input
|
||||
id="distance_km"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0.1"
|
||||
max="10000"
|
||||
placeholder="0"
|
||||
value={formData.distance_km || ''}
|
||||
onChange={(e) =>
|
||||
handleChange('distance_km', parseFloat(e.target.value) || 0)
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Locations row */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="from_location" className="flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4" />
|
||||
Från
|
||||
</Label>
|
||||
<Input
|
||||
id="from_location"
|
||||
placeholder="T.ex. Hemma"
|
||||
value={formData.from_location || ''}
|
||||
onChange={(e) => handleChange('from_location', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="to_location" className="flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4" />
|
||||
Till
|
||||
</Label>
|
||||
<Input
|
||||
id="to_location"
|
||||
placeholder="T.ex. Kundens kontor"
|
||||
value={formData.to_location || ''}
|
||||
onChange={(e) => handleChange('to_location', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Purpose */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="purpose" className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
Ändamål
|
||||
</Label>
|
||||
<Textarea
|
||||
id="purpose"
|
||||
placeholder="Beskriv syftet med resan, t.ex. kundmöte, fotografering, leverans..."
|
||||
value={formData.purpose}
|
||||
onChange={(e) => handleChange('purpose', e.target.value)}
|
||||
rows={2}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Deduction preview */}
|
||||
{formData.distance_km > 0 && (
|
||||
<div className="p-4 rounded-lg bg-success/10 border border-success/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Beräknat avdrag</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formData.distance_km} km × {SCHABLONAVDRAG_RATES.bil.rate_per_km.toFixed(2)} kr/km
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-success">
|
||||
{formatCurrency(deductionPreview)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit button */}
|
||||
<div className="flex justify-end gap-2">
|
||||
{onCancel && (
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Avbryt
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sparar...
|
||||
</>
|
||||
) : (
|
||||
'Spara körning'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -341,13 +341,13 @@ export default function SwipeCategorizationView({
|
||||
|
||||
{/* Potential Invoice Match */}
|
||||
{currentTransaction.potential_invoice && (
|
||||
<div className="p-4 rounded-lg border-2 border-green-500/50 bg-green-50/50 dark:bg-green-950/20 space-y-2">
|
||||
<div className="p-4 rounded-lg border-2 border-success/40 bg-success/5 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-green-600">
|
||||
<div className="flex items-center gap-2 text-success">
|
||||
<FileText className="h-5 w-5" />
|
||||
<span className="font-semibold text-sm">Fakturamatchning hittad</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-green-600 border-green-600">
|
||||
<Badge variant="outline" className="text-success border-success">
|
||||
Match
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -358,7 +358,7 @@ export default function SwipeCategorizationView({
|
||||
<p className="text-muted-foreground">
|
||||
{currentTransaction.potential_invoice.customer?.name || 'Okänd kund'}
|
||||
</p>
|
||||
<p className="font-medium text-green-600">
|
||||
<p className="font-medium text-success">
|
||||
{formatCurrency(
|
||||
currentTransaction.potential_invoice.total,
|
||||
currentTransaction.potential_invoice.currency
|
||||
@@ -410,7 +410,7 @@ export default function SwipeCategorizationView({
|
||||
{/* Invoice match button - primary action when there's a match */}
|
||||
{currentTransaction.potential_invoice && onMatchInvoice && (
|
||||
<Button
|
||||
className="w-full bg-green-600 hover:bg-green-700 text-white"
|
||||
className="w-full bg-success hover:bg-success/90 text-success-foreground"
|
||||
onClick={handleMatchInvoice}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
|
||||
@@ -8,7 +8,7 @@ const Card = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl border border-border/60 bg-card text-card-foreground shadow-sm transition-shadow duration-300 hover:shadow-md",
|
||||
"rounded-xl border border-border/50 bg-card text-card-foreground shadow-[0_1px_3px_rgba(30,25,20,0.04)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -47,11 +47,8 @@ export function EmptyState({
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center py-12 px-4 text-center', className)}>
|
||||
{Icon && (
|
||||
<div className="relative mb-6">
|
||||
{/* Background pattern */}
|
||||
<div className="absolute inset-0 -m-4 rounded-full bg-gradient-to-br from-secondary/60 to-muted/30 blur-xl" />
|
||||
{/* Icon container */}
|
||||
<div className="relative p-5 rounded-full bg-gradient-to-br from-muted/80 to-secondary/50 ring-1 ring-border/20">
|
||||
<div className="mb-6">
|
||||
<div className="p-5 rounded-full bg-muted">
|
||||
<Icon className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,7 +60,7 @@ export function EmptyState({
|
||||
<div className="flex flex-col sm:flex-row items-center gap-3">
|
||||
{actionHref && actionLabel && (
|
||||
<Link href={actionHref}>
|
||||
<Button className="bg-accent hover:bg-accent/90 text-accent-foreground">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{actionLabel}
|
||||
</Button>
|
||||
|
||||
@@ -8,11 +8,11 @@ interface PageHeaderProps {
|
||||
|
||||
export function PageHeader({ title, description, action }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{title}</h1>
|
||||
<h1 className="font-display text-2xl font-medium tracking-tight">{title}</h1>
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-balance">{description}</p>
|
||||
<p className="text-muted-foreground mt-1 text-balance">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{action && <div>{action}</div>}
|
||||
|
||||
Reference in New Issue
Block a user