Initial copy from influencer-biz

This commit is contained in:
Jakob Wennberg
2026-02-13 14:00:46 +01:00
commit a25d10a528
391 changed files with 96224 additions and 0 deletions
+167
View File
@@ -0,0 +1,167 @@
'use client'
import { Button } from '@/components/ui/button'
import { formatDate } from '@/lib/utils'
import { getDaysUntilExpiry, isConsentExpiringSoon } from '@/lib/banking/enable-banking'
import {
CreditCard,
AlertTriangle,
RefreshCw,
Trash2,
Loader2,
CheckCircle,
XCircle,
} from 'lucide-react'
import type { BankConnection } from '@/types'
interface BankConnectionStatusProps {
connection: BankConnection
onSync: (connectionId: string) => void
onDisconnect: (connectionId: string) => void
isSyncing?: boolean
}
export function BankConnectionStatus({
connection,
onSync,
onDisconnect,
isSyncing = false,
}: BankConnectionStatusProps) {
const daysUntilExpiry = getDaysUntilExpiry(connection.consent_expires_at)
const isExpiring = isConsentExpiringSoon(connection.consent_expires_at)
const statusConfig = {
active: {
icon: CheckCircle,
color: 'text-green-600',
label: 'Aktiv',
variant: 'success' as const,
},
pending: {
icon: Loader2,
color: 'text-yellow-600',
label: 'Väntar',
variant: 'warning' as const,
},
error: {
icon: XCircle,
color: 'text-red-600',
label: 'Fel',
variant: 'destructive' as const,
},
revoked: {
icon: XCircle,
color: 'text-gray-600',
label: 'Bortkopplad',
variant: 'secondary' as const,
},
}
const status = statusConfig[connection.status as keyof typeof statusConfig] || statusConfig.error
const StatusIcon = status.icon
// Parse accounts from connection
const accounts = (connection.accounts as Array<{
uid: string
iban?: string
name?: string
currency: string
balance?: number
}>) || []
return (
<div className="border rounded-lg p-4 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
<CreditCard className="h-5 w-5 text-primary" />
</div>
<div>
<p className="font-medium">{connection.bank_name}</p>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<StatusIcon className={`h-3 w-3 ${status.color}`} />
<span>{status.label}</span>
{connection.last_synced_at && (
<>
<span>-</span>
<span>Synkad {formatDate(connection.last_synced_at)}</span>
</>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{connection.status === 'active' && (
<Button
variant="outline"
size="sm"
onClick={() => onSync(connection.id)}
disabled={isSyncing}
>
{isSyncing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => onDisconnect(connection.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
{/* Consent expiry warning */}
{isExpiring && daysUntilExpiry !== null && (
<div className="flex items-center gap-2 p-3 bg-warning/10 rounded-lg">
<AlertTriangle className="h-4 w-4 text-warning" />
<span className="text-sm">
Samtycket går ut om {daysUntilExpiry} {daysUntilExpiry === 1 ? 'dag' : 'dagar'}.
Förnya genom att ansluta igen.
</span>
</div>
)}
{/* Accounts list */}
{accounts.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Konton</p>
<div className="space-y-2">
{accounts.map((account) => (
<div
key={account.uid}
className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"
>
<div>
<p className="text-sm font-medium">
{account.name || account.iban || 'Okänt konto'}
</p>
{account.iban && (
<p className="text-xs text-muted-foreground">
{account.iban.replace(/(.{4})/g, '$1 ').trim()}
</p>
)}
</div>
{account.balance !== undefined && (
<div className="text-right">
<p className="text-sm font-medium">
{new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency: account.currency,
}).format(account.balance)}
</p>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
)
}
+102
View File
@@ -0,0 +1,102 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import Script from 'next/script'
export interface Bank {
name: string
country: string
logo?: string
bic?: string
}
interface BankSelectorProps {
onSelect: (bank: Bank) => void
selectedBank?: Bank | null
isLoading?: boolean
className?: string
country?: string
sandbox?: boolean
}
export function BankSelector({
onSelect,
isLoading = false,
className,
country = 'SE',
sandbox = true, // Default to sandbox for development
}: BankSelectorProps) {
const widgetRef = useRef<HTMLElement | null>(null)
const [scriptLoaded, setScriptLoaded] = useState(false)
useEffect(() => {
if (!scriptLoaded) return
const widget = widgetRef.current
if (!widget) return
const handleSelected = (e: Event) => {
const customEvent = e as CustomEvent
const detail = customEvent.detail as {
name: string
country: string
psuType: string
sandbox: boolean
}
onSelect({
name: detail.name,
country: detail.country,
})
}
widget.addEventListener('selected', handleSelected)
return () => {
widget.removeEventListener('selected', handleSelected)
}
}, [onSelect, scriptLoaded])
const handleScriptLoad = () => {
setScriptLoaded(true)
}
if (isLoading) {
return (
<div className={cn('flex items-center justify-center p-8', className)}>
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">Ansluter...</span>
</div>
)
}
return (
<div className={cn('space-y-3', className)}>
<Script
src="https://tilisy.enablebanking.com/lib/widgets.umd.min.js"
onLoad={handleScriptLoad}
strategy="afterInteractive"
/>
<link
href="https://tilisy.enablebanking.com/lib/widgets.css"
rel="stylesheet"
/>
{/* @ts-expect-error - Enable Banking custom element */}
<enablebanking-aspsp-list
ref={widgetRef}
country={country}
psu-type="business"
service="AIS"
{...(sandbox ? { sandbox: true } : {})}
style={{ minHeight: '200px' }}
/>
<p className="text-xs text-muted-foreground text-center">
{sandbox ? 'Sandbox-läge: Använd testbanker för utveckling' : 'Välj din bank för att fortsätta'}
</p>
</div>
)
}
+2
View File
@@ -0,0 +1,2 @@
export { BankSelector, type Bank } from './BankSelector'
export { BankConnectionStatus } from './BankConnectionStatus'
+290
View File
@@ -0,0 +1,290 @@
'use client'
import { useState, useMemo } 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 { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
import { classifyGift, TAX_FREE_PROMO_THRESHOLD, getBookingTypeDisplayText } from '@/lib/benefits/gift-classifier'
import { formatCurrency } from '@/lib/utils'
import { Gift, CheckCircle, XCircle, AlertCircle, HelpCircle, AlertTriangle } from 'lucide-react'
import type { CreateGiftInput, GiftInput, GiftClassification } from '@/types'
interface GiftFormProps {
onSubmit: (data: CreateGiftInput) => Promise<void>
initialData?: Partial<CreateGiftInput & { returned?: boolean }>
isLoading?: boolean
isLightMode?: boolean
}
export default function GiftForm({ onSubmit, initialData, isLoading, isLightMode }: GiftFormProps) {
const [formData, setFormData] = useState<CreateGiftInput>({
date: initialData?.date || new Date().toISOString().split('T')[0],
brand_name: initialData?.brand_name || '',
description: initialData?.description || '',
estimated_value: initialData?.estimated_value || 0,
has_motprestation: initialData?.has_motprestation || false,
used_in_business: initialData?.used_in_business || false,
used_privately: initialData?.used_privately || false,
is_simple_promo: initialData?.is_simple_promo || false,
})
const [returned, setReturned] = useState(initialData?.returned || false)
const [valueOverridden, setValueOverridden] = useState(false)
// Compute classification from form data (derived state, no need for useEffect)
const classification = useMemo<GiftClassification | null>(() => {
if (formData.estimated_value > 0) {
const input: GiftInput = {
estimatedValue: formData.estimated_value,
hasMotprestation: formData.has_motprestation,
usedInBusiness: formData.used_in_business,
usedPrivately: formData.used_privately,
isSimplePromoItem: formData.is_simple_promo || false,
}
return classifyGift(input)
}
return null
}, [formData.estimated_value, formData.has_motprestation, formData.used_in_business, formData.used_privately, formData.is_simple_promo])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
await onSubmit({ ...formData, returned } as CreateGiftInput & { returned?: boolean })
}
const updateField = <K extends keyof CreateGiftInput>(field: K, value: CreateGiftInput[K]) => {
setFormData((prev) => ({ ...prev, [field]: value }))
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Gift className="h-5 w-5" />
Produktinformation
</CardTitle>
<CardDescription>Ange information om gåvan eller produkten du fått</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="date">Datum</Label>
<Input
id="date"
type="date"
value={formData.date}
onChange={(e) => updateField('date', e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="brand_name">Varumärke/Företag</Label>
<Input
id="brand_name"
placeholder="t.ex. Daniel Wellington"
value={formData.brand_name}
onChange={(e) => updateField('brand_name', e.target.value)}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">Beskrivning</Label>
<Textarea
id="description"
placeholder="t.ex. Klocka, modell Classic Petite"
value={formData.description}
onChange={(e) => updateField('description', e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="estimated_value">Uppskattat marknadsvärde (SEK)</Label>
<Input
id="estimated_value"
type="number"
min="0"
step="1"
placeholder="0"
value={formData.estimated_value || ''}
onChange={(e) => updateField('estimated_value', parseFloat(e.target.value) || 0)}
required
/>
<p className="text-xs text-muted-foreground">
Skattefritt om enklare reklamgåva under {formatCurrency(TAX_FREE_PROMO_THRESHOLD)}
</p>
</div>
<div className="flex items-center space-x-2">
<Switch
id="is_simple_promo"
checked={formData.is_simple_promo || false}
onCheckedChange={(checked) => updateField('is_simple_promo', checked)}
/>
<Label htmlFor="is_simple_promo" className="flex items-center gap-2">
Enklare reklamgåva
<HelpCircle className="h-4 w-4 text-muted-foreground" />
</Label>
</div>
<p className="text-xs text-muted-foreground ml-12">
T.ex. penna, mugg, t-shirt med företagslogo - inte exklusiva produkter
</p>
{/* Returned toggle */}
<div className="flex items-center space-x-2">
<Switch
id="returned"
checked={returned}
onCheckedChange={setReturned}
/>
<Label htmlFor="returned">Returnerad</Label>
</div>
{returned && (
<p className="text-xs text-success ml-12">
Returnerade gåvor räknas inte som skattepliktig inkomst.
</p>
)}
{/* Value override warning */}
{valueOverridden && (
<div className="flex items-start gap-2 p-3 bg-warning/10 border border-warning/30 rounded-lg">
<AlertTriangle className="h-4 w-4 text-warning mt-0.5 flex-shrink-0" />
<p className="text-xs text-warning">
Skatteverket kräver marknadsvärde vid mottagning (inkl. moms). Lägre värdering ökar revisionsrisken.
</p>
</div>
)}
</CardContent>
</Card>
{/* Decision Tree Questions */}
<Card>
<CardHeader>
<CardTitle>Skatteklassificering</CardTitle>
<CardDescription>Svara på frågorna nedan för korrekt skattehantering</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Question 1: Motprestation */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="has_motprestation" className="font-medium">
Fanns krav på att du skulle posta om denna produkt?
</Label>
<Switch
id="has_motprestation"
checked={formData.has_motprestation}
onCheckedChange={(checked) => updateField('has_motprestation', checked)}
/>
</div>
<p className="text-sm text-muted-foreground">
T.ex. sponsrat inlägg, samarbete, eller annat krav på att nämna/visa produkten
</p>
</div>
{/* Question 2: Business use - hidden for light mode */}
{!isLightMode && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="used_in_business" className="font-medium">
Använder du produkten i din content-produktion?
</Label>
<Switch
id="used_in_business"
checked={formData.used_in_business}
onCheckedChange={(checked) => updateField('used_in_business', checked)}
/>
</div>
<p className="text-sm text-muted-foreground">T.ex. som rekvisita, utrustning, eller i bakgrunden</p>
</div>
)}
{/* Question 3: Private use */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="used_privately" className="font-medium">
Använder du produkten privat?
</Label>
<Switch
id="used_privately"
checked={formData.used_privately}
onCheckedChange={(checked) => updateField('used_privately', checked)}
/>
</div>
<p className="text-sm text-muted-foreground">T.ex. bär klockan dagligen, använder sminket privat</p>
</div>
</CardContent>
</Card>
{/* Classification Preview */}
{classification && (
<Card
className={
classification.taxable
? classification.deductibleAsExpense
? 'border-warning/50 bg-warning/5'
: 'border-destructive/50 bg-destructive/5'
: 'border-success/50 bg-success/5'
}
>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{classification.taxable ? (
classification.deductibleAsExpense ? (
<AlertCircle className="h-5 w-5 text-warning" />
) : (
<XCircle className="h-5 w-5 text-destructive" />
)
) : (
<CheckCircle className="h-5 w-5 text-success" />
)}
Klassificering
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
<Badge variant={classification.taxable ? 'destructive' : 'default'}>
{classification.taxable ? 'Skattepliktig' : 'Skattefri'}
</Badge>
{classification.deductibleAsExpense && <Badge variant="outline">Avdragsgill</Badge>}
<Badge variant="secondary">{getBookingTypeDisplayText(classification.bookingType)}</Badge>
</div>
<div className="text-sm">
<p className="font-medium">Förklaring:</p>
<p className="text-muted-foreground">{classification.reasoning}</p>
</div>
{classification.taxable && (
<div className="text-sm border-t pt-4">
<p className="font-medium">Skatteeffekt:</p>
<p className="text-muted-foreground">
{isLightMode ? (
<>Gåvoskatt: ca {formatCurrency(classification.marketValue * 0.32)} ({Math.round(32)}% av marknadsvärde {formatCurrency(classification.marketValue)}). Skatten ingår inte i din paraplyföretags hantering.</>
) : (
<>
Marknadsvärdet {formatCurrency(classification.marketValue)} läggs till din beskattningsbara inkomst.
{classification.deductibleAsExpense && ' Eftersom produkten endast används i verksamheten är den avdragsgill som kostnad.'}
</>
)}
</p>
</div>
)}
</CardContent>
</Card>
)}
{/* Submit */}
<div className="flex justify-end gap-2">
<Button type="submit" disabled={isLoading || !formData.brand_name || !formData.description}>
{isLoading ? 'Sparar...' : initialData ? 'Uppdatera' : 'Spara gåva'}
</Button>
</div>
</form>
)
}
+198
View File
@@ -0,0 +1,198 @@
'use client'
import { useState } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { formatCurrency } from '@/lib/utils'
import { Gift, Pencil, Trash2, Calendar, Building } from 'lucide-react'
import type { Gift as GiftType } from '@/types'
interface GiftListProps {
gifts: GiftType[]
onEdit: (gift: GiftType) => void
onDelete: (id: string) => Promise<void>
isDeleting?: boolean
}
type FilterType = 'all' | 'taxable' | 'tax_free' | 'deductible'
export default function GiftList({ gifts, onEdit, onDelete, isDeleting }: GiftListProps) {
const [filter, setFilter] = useState<FilterType>('all')
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [giftToDelete, setGiftToDelete] = useState<GiftType | null>(null)
// Filter gifts based on selection
const filteredGifts = gifts.filter((gift) => {
if (filter === 'all') return true
if (filter === 'taxable') return gift.classification?.taxable
if (filter === 'tax_free') return !gift.classification?.taxable
if (filter === 'deductible') return gift.classification?.deductibleAsExpense
return true
})
const handleDeleteClick = (gift: GiftType) => {
setGiftToDelete(gift)
setDeleteDialogOpen(true)
}
const handleConfirmDelete = async () => {
if (giftToDelete) {
await onDelete(giftToDelete.id)
setDeleteDialogOpen(false)
setGiftToDelete(null)
}
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('sv-SE', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
if (gifts.length === 0) {
return (
<Card>
<CardContent className="pt-6">
<div className="text-center py-8">
<Gift className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<h3 className="text-lg font-medium">Inga gåvor registrerade</h3>
<p className="text-sm text-muted-foreground mt-1">
Lägg till gåvor och produkter du fått för att hålla koll på skatten
</p>
</div>
</CardContent>
</Card>
)
}
return (
<div className="space-y-4">
{/* Filter */}
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Visar {filteredGifts.length} av {gifts.length} gåvor
</p>
<Select value={filter} onValueChange={(value: FilterType) => setFilter(value)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filtrera" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alla</SelectItem>
<SelectItem value="taxable">Skattepliktiga</SelectItem>
<SelectItem value="tax_free">Skattefria</SelectItem>
<SelectItem value="deductible">Avdragsgilla</SelectItem>
</SelectContent>
</Select>
</div>
{/* Gift list */}
<div className="space-y-3">
{filteredGifts.map((gift) => (
<Card key={gift.id}>
<CardContent className="pt-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium truncate">{gift.description}</h3>
{gift.classification?.taxable ? (
<Badge variant="destructive" className="flex-shrink-0">
Skattepliktig
</Badge>
) : (
<Badge variant="default" className="flex-shrink-0">
Skattefri
</Badge>
)}
{gift.classification?.deductibleAsExpense && (
<Badge variant="outline" className="flex-shrink-0">
Avdragsgill
</Badge>
)}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Building className="h-3 w-3" />
{gift.brand_name}
</span>
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{formatDate(gift.date)}
</span>
<span className="font-medium text-foreground">
{formatCurrency(Number(gift.estimated_value))}
</span>
</div>
{gift.classification?.reasoning && (
<p className="text-xs text-muted-foreground mt-2">
{gift.classification.reasoning}
</p>
)}
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => onEdit(gift)}
title="Redigera"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteClick(gift)}
title="Ta bort"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
{/* Delete confirmation dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Ta bort gåva?</DialogTitle>
<DialogDescription>
Är du säker på att du vill ta bort &quot;{giftToDelete?.description}&quot; från{' '}
{giftToDelete?.brand_name}? Detta kan inte ångras.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
Avbryt
</Button>
<Button variant="destructive" onClick={handleConfirmDelete} disabled={isDeleting}>
{isDeleting ? 'Tar bort...' : 'Ta bort'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
+55
View File
@@ -0,0 +1,55 @@
'use client'
import Link from 'next/link'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { formatCurrency } from '@/lib/utils'
import { Gift, TrendingUp } from 'lucide-react'
import type { GiftSummary } from '@/types'
interface GiftSummaryCardProps {
summary: GiftSummary | null
}
export default function GiftSummaryCard({ summary }: GiftSummaryCardProps) {
// Don't show card if no gifts
if (!summary || summary.total_count === 0) {
return null
}
return (
<Card>
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<Gift className="h-5 w-5 flex-shrink-0 text-primary" />
<div>
<p className="font-medium">Gåvor & Förmåner</p>
<p className="text-sm text-muted-foreground">
{summary.total_count} produkter · {formatCurrency(summary.total_value)} totalt
</p>
{summary.taxable_count > 0 && (
<div className="flex items-center gap-2 mt-1">
<TrendingUp className="h-3 w-3 text-destructive" />
<span className="text-xs text-destructive">
{formatCurrency(summary.taxable_value)} skattepliktig inkomst
</span>
</div>
)}
{summary.deductible_count > 0 && (
<p className="text-xs text-muted-foreground mt-1">
{formatCurrency(summary.deductible_value)} avdragsgill kostnad
</p>
)}
</div>
</div>
<Link href="/gifts">
<Button variant="outline" size="sm">
Visa
</Button>
</Link>
</div>
</CardContent>
</Card>
)
}
+166
View File
@@ -0,0 +1,166 @@
'use client'
import { useState, useEffect } from 'react'
import { createClient } from '@/lib/supabase/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ChevronDown, ChevronRight, Search } from 'lucide-react'
import type { BASAccount } from '@/types'
const CLASS_LABELS: Record<number, string> = {
1: 'Tillgångar',
2: 'Eget kapital och skulder',
3: 'Intäkter',
4: 'Varor och material',
5: 'Övriga externa kostnader',
6: 'Övriga externa kostnader',
7: 'Personal och avskrivningar',
8: 'Finansiella poster',
}
export default function ChartOfAccounts() {
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [loading, setLoading] = useState(true)
const [expandedClasses, setExpandedClasses] = useState<Set<number>>(new Set())
const [searchQuery, setSearchQuery] = useState('')
useEffect(() => {
fetchAccounts()
}, [])
async function fetchAccounts() {
const res = await fetch('/api/bookkeeping/accounts')
const { data } = await res.json()
setAccounts(data || [])
setLoading(false)
}
const toggleClass = (cls: number) => {
const next = new Set(expandedClasses)
if (next.has(cls)) {
next.delete(cls)
} else {
next.add(cls)
}
setExpandedClasses(next)
}
const filteredAccounts = accounts.filter((a) => {
if (!searchQuery) return true
const q = searchQuery.toLowerCase()
return (
a.account_number.includes(q) ||
a.account_name.toLowerCase().includes(q)
)
})
const groupedByClass = filteredAccounts.reduce(
(acc, account) => {
const cls = account.account_class
if (!acc[cls]) acc[cls] = []
acc[cls].push(account)
return acc
},
{} as Record<number, BASAccount[]>
)
if (loading) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Laddar kontoplan...
</CardContent>
</Card>
)
}
return (
<div className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Sök konto (nummer eller namn)..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<div className="space-y-2">
{Object.entries(groupedByClass)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([cls, classAccounts]) => {
const classNum = Number(cls)
const isExpanded = expandedClasses.has(classNum) || !!searchQuery
return (
<Card key={cls}>
<button
onClick={() => toggleClass(classNum)}
className="w-full flex items-center justify-between p-4 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
<span className="font-semibold">
Klass {cls}: {CLASS_LABELS[classNum] || ''}
</span>
<Badge variant="secondary">{classAccounts.length} konton</Badge>
</div>
</button>
{isExpanded && (
<CardContent className="pt-0 pb-4">
<table className="w-full text-sm">
<thead>
<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-24 text-center">Typ</th>
<th className="py-2 w-24 text-center">Normal</th>
</tr>
</thead>
<tbody>
{classAccounts.map((account) => (
<tr
key={account.id}
className={`border-b last:border-0 ${
!account.is_active ? 'opacity-50' : ''
}`}
>
<td className="py-2 font-mono">{account.account_number}</td>
<td className="py-2">{account.account_name}</td>
<td className="py-2 text-center">
<Badge variant="outline" className="text-xs">
{account.account_type === 'asset'
? 'Tillgång'
: account.account_type === 'liability'
? 'Skuld'
: account.account_type === 'equity'
? 'EK'
: account.account_type === 'revenue'
? 'Intäkt'
: 'Kostnad'}
</Badge>
</td>
<td className="py-2 text-center text-xs text-muted-foreground">
{account.normal_balance === 'debit' ? 'Debet' : 'Kredit'}
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
)}
</Card>
)
})}
</div>
</div>
)
}
+288
View File
@@ -0,0 +1,288 @@
'use client'
import { useState, useEffect } from 'react'
import { Card, CardContent, 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 { useToast } from '@/components/ui/use-toast'
import { Plus, Trash2 } from 'lucide-react'
import type { CreateJournalEntryLineInput, FiscalPeriod } from '@/types'
interface Props {
onCreated?: () => void
}
interface FormLine {
account_number: string
debit_amount: string
credit_amount: string
line_description: string
}
export default function JournalEntryForm({ onCreated }: Props) {
const { toast } = useToast()
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
const [selectedPeriod, setSelectedPeriod] = useState('')
const [entryDate, setEntryDate] = useState(new Date().toISOString().split('T')[0])
const [description, setDescription] = useState('')
const [lines, setLines] = useState<FormLine[]>([
{ account_number: '', debit_amount: '', credit_amount: '', line_description: '' },
{ account_number: '', debit_amount: '', credit_amount: '', line_description: '' },
])
const [isSubmitting, setIsSubmitting] = useState(false)
useEffect(() => {
fetchPeriods()
}, [])
async function fetchPeriods() {
const res = await fetch('/api/bookkeeping/fiscal-periods')
const { data } = await res.json()
setPeriods(data || [])
if (data && data.length > 0) {
setSelectedPeriod(data[0].id)
}
}
const addLine = () => {
setLines([
...lines,
{ account_number: '', debit_amount: '', credit_amount: '', line_description: '' },
])
}
const removeLine = (index: number) => {
if (lines.length <= 2) return
setLines(lines.filter((_, i) => i !== index))
}
const updateLine = (index: number, field: keyof FormLine, value: string) => {
const updated = [...lines]
updated[index] = { ...updated[index], [field]: value }
// If entering debit, clear credit and vice versa
if (field === 'debit_amount' && value) {
updated[index].credit_amount = ''
} else if (field === 'credit_amount' && value) {
updated[index].debit_amount = ''
}
setLines(updated)
}
const totalDebit = lines.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0)
const totalCredit = lines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0)
const isBalanced = Math.abs(totalDebit - totalCredit) < 0.01 && totalDebit > 0
const handleSubmit = async () => {
if (!selectedPeriod || !description || !isBalanced) return
setIsSubmitting(true)
const entryLines: CreateJournalEntryLineInput[] = lines
.filter((l) => l.account_number && (l.debit_amount || l.credit_amount))
.map((l) => ({
account_number: l.account_number,
debit_amount: parseFloat(l.debit_amount) || 0,
credit_amount: parseFloat(l.credit_amount) || 0,
line_description: l.line_description || undefined,
}))
const res = await fetch('/api/bookkeeping/journal-entries', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fiscal_period_id: selectedPeriod,
entry_date: entryDate,
description,
source_type: 'manual',
lines: entryLines,
}),
})
const result = await res.json()
if (result.error) {
toast({
title: 'Fel',
description: result.error,
variant: 'destructive',
})
} else {
toast({
title: 'Verifikation skapad',
description: `Verifikation ${result.data?.voucher_series}${result.data?.voucher_number} har skapats.`,
})
// Reset form
setDescription('')
setLines([
{ account_number: '', debit_amount: '', credit_amount: '', line_description: '' },
{ account_number: '', debit_amount: '', credit_amount: '', line_description: '' },
])
onCreated?.()
}
setIsSubmitting(false)
}
return (
<Card>
<CardHeader>
<CardTitle>Ny verifikation</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div>
<Label>Räkenskapsår</Label>
<select
value={selectedPeriod}
onChange={(e) => setSelectedPeriod(e.target.value)}
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
>
{periods.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
<div>
<Label>Datum</Label>
<Input
type="date"
value={entryDate}
onChange={(e) => setEntryDate(e.target.value)}
/>
</div>
<div>
<Label>Beskrivning</Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Verifikationstext..."
/>
</div>
</div>
{/* Entry lines */}
<div>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 w-24">Konto</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-32 text-right">Debet</th>
<th className="py-2 w-32 text-right">Kredit</th>
<th className="py-2 w-10"></th>
</tr>
</thead>
<tbody>
{lines.map((line, index) => (
<tr key={index} className="border-b">
<td className="py-1">
<Input
value={line.account_number}
onChange={(e) => updateLine(index, 'account_number', e.target.value)}
placeholder="1930"
className="font-mono h-8"
maxLength={4}
/>
</td>
<td className="py-1 px-1">
<Input
value={line.line_description}
onChange={(e) => updateLine(index, 'line_description', e.target.value)}
placeholder="Radtext..."
className="h-8"
/>
</td>
<td className="py-1">
<Input
type="number"
value={line.debit_amount}
onChange={(e) => updateLine(index, 'debit_amount', e.target.value)}
placeholder="0,00"
className="text-right h-8"
min="0"
step="0.01"
/>
</td>
<td className="py-1">
<Input
type="number"
value={line.credit_amount}
onChange={(e) => updateLine(index, 'credit_amount', e.target.value)}
placeholder="0,00"
className="text-right h-8"
min="0"
step="0.01"
/>
</td>
<td className="py-1">
<Button
variant="ghost"
size="sm"
onClick={() => removeLine(index)}
disabled={lines.length <= 2}
className="h-8 w-8 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="font-semibold">
<td colSpan={2} className="py-2">
Summa
</td>
<td
className={`py-2 text-right ${
isBalanced ? 'text-green-600' : 'text-red-600'
}`}
>
{totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</td>
<td
className={`py-2 text-right ${
isBalanced ? 'text-green-600' : 'text-red-600'
}`}
>
{totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</td>
<td></td>
</tr>
</tfoot>
</table>
<Button
variant="outline"
size="sm"
onClick={addLine}
className="mt-2"
>
<Plus className="h-3 w-3 mr-1" />
Lägg till rad
</Button>
</div>
{!isBalanced && totalDebit > 0 && (
<p className="text-sm text-red-600">
Differens: {Math.abs(totalDebit - totalCredit).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} kr
</p>
)}
<div className="flex justify-end">
<Button
onClick={handleSubmit}
disabled={!isBalanced || !description || !selectedPeriod || isSubmitting}
>
{isSubmitting ? 'Sparar...' : 'Skapa verifikation'}
</Button>
</div>
</CardContent>
</Card>
)
}
+213
View File
@@ -0,0 +1,213 @@
'use client'
import { useState, useEffect } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ChevronDown, ChevronRight } from 'lucide-react'
import type { JournalEntry, JournalEntryLine } from '@/types'
interface Props {
periodId?: string
}
export default function JournalEntryList({ periodId }: Props) {
const [entries, setEntries] = useState<JournalEntry[]>([])
const [loading, setLoading] = useState(true)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [count, setCount] = useState(0)
const [page, setPage] = useState(0)
const pageSize = 20
useEffect(() => {
fetchEntries()
}, [periodId, page])
async function fetchEntries() {
setLoading(true)
const params = new URLSearchParams({
limit: String(pageSize),
offset: String(page * pageSize),
})
if (periodId) params.set('period_id', periodId)
const res = await fetch(`/api/bookkeeping/journal-entries?${params}`)
const { data, count: total } = await res.json()
setEntries(data || [])
setCount(total || 0)
setLoading(false)
}
const toggleExpand = (id: string) => {
setExpandedId(expandedId === id ? null : id)
}
const statusLabel = (status: string) => {
switch (status) {
case 'posted':
return <Badge className="bg-green-100 text-green-800">Bokförd</Badge>
case 'draft':
return <Badge variant="secondary">Utkast</Badge>
case 'reversed':
return <Badge variant="destructive">Makulerad</Badge>
default:
return <Badge variant="outline">{status}</Badge>
}
}
const sourceLabel = (source: string) => {
const labels: Record<string, string> = {
manual: 'Manuell',
bank_transaction: 'Banktransaktion',
invoice_created: 'Faktura',
invoice_paid: 'Betalning',
credit_note: 'Kreditfaktura',
salary_payment: 'Lön',
opening_balance: 'Ingående balans',
year_end: 'Årsbokslut',
}
return labels[source] || source
}
if (loading) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Laddar verifikationer...
</CardContent>
</Card>
)
}
if (entries.length === 0) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Inga verifikationer hittades.
</CardContent>
</Card>
)
}
return (
<div className="space-y-4">
<div className="space-y-2">
{entries.map((entry) => {
const isExpanded = expandedId === entry.id
const lines = (entry.lines || []) as JournalEntryLine[]
return (
<Card key={entry.id}>
<button
onClick={() => toggleExpand(entry.id)}
className="w-full flex items-center justify-between p-4 text-left hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3 flex-1">
{isExpanded ? (
<ChevronDown className="h-4 w-4 shrink-0" />
) : (
<ChevronRight className="h-4 w-4 shrink-0" />
)}
<span className="font-mono text-sm text-muted-foreground w-16">
{entry.voucher_series}{entry.voucher_number}
</span>
<span className="text-sm text-muted-foreground w-24">
{entry.entry_date}
</span>
<span className="flex-1 truncate">{entry.description}</span>
<Badge variant="outline" className="text-xs mr-2">
{sourceLabel(entry.source_type)}
</Badge>
{statusLabel(entry.status)}
</div>
</button>
{isExpanded && lines.length > 0 && (
<CardContent className="pt-0 pb-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 w-24">Konto</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-28 text-right">Debet</th>
<th className="py-2 w-28 text-right">Kredit</th>
</tr>
</thead>
<tbody>
{lines
.sort((a, b) => a.sort_order - b.sort_order)
.map((line) => (
<tr key={line.id} className="border-b last:border-0">
<td className="py-2 font-mono">{line.account_number}</td>
<td className="py-2 text-muted-foreground">
{line.line_description || ''}
</td>
<td className="py-2 text-right">
{Number(line.debit_amount) > 0
? Number(line.debit_amount).toLocaleString('sv-SE', {
minimumFractionDigits: 2,
})
: ''}
</td>
<td className="py-2 text-right">
{Number(line.credit_amount) > 0
? Number(line.credit_amount).toLocaleString('sv-SE', {
minimumFractionDigits: 2,
})
: ''}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="font-semibold">
<td colSpan={2} className="py-2">
Summa
</td>
<td className="py-2 text-right">
{lines
.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0)
.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</td>
<td className="py-2 text-right">
{lines
.reduce((sum, l) => sum + (Number(l.credit_amount) || 0), 0)
.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</td>
</tr>
</tfoot>
</table>
</CardContent>
)}
</Card>
)
})}
</div>
{/* Pagination */}
{count > pageSize && (
<div className="flex justify-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page === 0}
onClick={() => setPage(page - 1)}
>
Föregående
</Button>
<span className="text-sm text-muted-foreground self-center">
Sida {page + 1} av {Math.ceil(count / pageSize)}
</span>
<Button
variant="outline"
size="sm"
disabled={(page + 1) * pageSize >= count}
onClick={() => setPage(page + 1)}
>
Nästa
</Button>
</div>
)}
</div>
)
}
+165
View File
@@ -0,0 +1,165 @@
'use client'
import { cn } from '@/lib/utils'
import { Invoice, Deadline, DeadlineStatus } from '@/types'
import { isInvoiceOverdue, isSameDay, formatDateISO, STATUS_COLORS } from '@/lib/calendar/utils'
interface CalendarDayCellProps {
date: Date
currentMonth: number
invoices: Invoice[]
deadlines: Deadline[]
onDayClick: (date: Date) => void
}
export function CalendarDayCell({
date,
currentMonth,
invoices,
deadlines,
onDayClick,
}: CalendarDayCellProps) {
const dateStr = formatDateISO(date)
const isCurrentMonth = date.getMonth() === currentMonth
const isToday = isSameDay(date, new Date())
// Filter invoices and deadlines for this day
const dayInvoices = invoices.filter(inv => inv.due_date === dateStr)
const dayDeadlines = deadlines.filter(d => d.due_date === dateStr)
// Count invoices by status
const overdueInvoices = dayInvoices.filter(isInvoiceOverdue)
const paidInvoices = dayInvoices.filter(inv => inv.status === 'paid')
const pendingInvoices = dayInvoices.filter(
inv => inv.status !== 'paid' && inv.status !== 'cancelled' && inv.status !== 'credited' && !isInvoiceOverdue(inv)
)
// Group deadlines by status
const deadlinesByStatus = dayDeadlines.reduce((acc, d) => {
if (d.is_completed) {
acc.completed = (acc.completed || 0) + 1
} else if (d.status) {
acc[d.status] = (acc[d.status] || 0) + 1
}
return acc
}, {} as Record<string, number>)
const hasItems = dayInvoices.length > 0 || dayDeadlines.length > 0
const hasOverdue = overdueInvoices.length > 0 || deadlinesByStatus.overdue > 0
const hasActionNeeded = deadlinesByStatus.action_needed > 0
return (
<button
onClick={() => onDayClick(date)}
className={cn(
'min-h-[80px] p-1 border-b border-r text-left transition-colors',
'hover:bg-muted/50 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-inset',
!isCurrentMonth && 'bg-muted/30 text-muted-foreground',
isToday && 'bg-primary/5',
hasOverdue && 'border-l-2 border-l-destructive',
!hasOverdue && hasActionNeeded && 'border-l-2 border-l-orange-500'
)}
>
<div className={cn(
'text-sm font-medium mb-1',
isToday && 'text-primary font-bold'
)}>
{date.getDate()}
</div>
{hasItems && (
<div className="space-y-0.5">
{/* Overdue invoices */}
{overdueInvoices.length > 0 && (
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-destructive" />
<span className="text-xs text-destructive truncate">
{overdueInvoices.length} förfallen
</span>
</div>
)}
{/* Pending invoices */}
{pendingInvoices.length > 0 && (
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-primary" />
<span className="text-xs text-muted-foreground truncate">
{pendingInvoices.length} faktura
</span>
</div>
)}
{/* Paid invoices */}
{paidInvoices.length > 0 && (
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-success" />
<span className="text-xs text-muted-foreground truncate">
{paidInvoices.length} betald
</span>
</div>
)}
{/* Overdue deadlines */}
{deadlinesByStatus.overdue > 0 && (
<div className="flex items-center gap-1">
<div className={cn('w-2 h-2 rounded-sm', STATUS_COLORS.overdue.dot)} />
<span className="text-xs text-destructive truncate">
{deadlinesByStatus.overdue} försenad
</span>
</div>
)}
{/* Action needed deadlines */}
{deadlinesByStatus.action_needed > 0 && (
<div className="flex items-center gap-1">
<div className={cn('w-2 h-2 rounded-sm', STATUS_COLORS.action_needed.dot)} />
<span className="text-xs text-orange-700 truncate">
{deadlinesByStatus.action_needed} åtgärd
</span>
</div>
)}
{/* Upcoming deadlines */}
{deadlinesByStatus.upcoming > 0 && (
<div className="flex items-center gap-1">
<div className={cn('w-2 h-2 rounded-sm', STATUS_COLORS.upcoming.dot)} />
<span className="text-xs text-muted-foreground truncate">
{deadlinesByStatus.upcoming} deadline
</span>
</div>
)}
{/* In progress deadlines */}
{deadlinesByStatus.in_progress > 0 && (
<div className="flex items-center gap-1">
<div className={cn('w-2 h-2 rounded-sm', STATUS_COLORS.in_progress.dot)} />
<span className="text-xs text-yellow-700 truncate">
{deadlinesByStatus.in_progress} pågår
</span>
</div>
)}
{/* Submitted deadlines */}
{deadlinesByStatus.submitted > 0 && (
<div className="flex items-center gap-1">
<div className={cn('w-2 h-2 rounded-sm', STATUS_COLORS.submitted.dot)} />
<span className="text-xs text-purple-700 truncate">
{deadlinesByStatus.submitted} inskickad
</span>
</div>
)}
{/* Completed/Confirmed deadlines */}
{(deadlinesByStatus.completed > 0 || deadlinesByStatus.confirmed > 0) && (
<div className="flex items-center gap-1">
<div className={cn('w-2 h-2 rounded-sm', STATUS_COLORS.confirmed.dot)} />
<span className="text-xs text-muted-foreground truncate">
{(deadlinesByStatus.completed || 0) + (deadlinesByStatus.confirmed || 0)} klar
</span>
</div>
)}
</div>
)}
</button>
)
}
+245
View File
@@ -0,0 +1,245 @@
'use client'
import { Invoice, Deadline } from '@/types'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Plus } from 'lucide-react'
import {
formatDateISO,
isSameDay,
isInvoiceOverdue,
isDeadlineOverdue,
getTimeSlots,
formatDayViewHeader,
DEADLINE_TYPE_LABELS,
PRIORITY_LABELS,
} from '@/lib/calendar/utils'
interface CalendarDayViewProps {
date: Date
invoices: Invoice[]
deadlines: Deadline[]
onAddDeadline: (date: Date) => void
}
export function CalendarDayView({
date,
invoices,
deadlines,
onAddDeadline,
}: CalendarDayViewProps) {
const timeSlots = getTimeSlots(8, 20)
const today = new Date()
const isToday = isSameDay(date, today)
const dateStr = formatDateISO(date)
// Filter items for this day
const dayInvoices = invoices.filter(inv => inv.due_date === dateStr)
const dayDeadlines = deadlines.filter(d => d.due_date === dateStr)
// Categorize items
const activeInvoices = dayInvoices.filter(inv =>
inv.status !== 'paid' && inv.status !== 'cancelled' && inv.status !== 'credited'
)
const paidInvoices = dayInvoices.filter(inv => inv.status === 'paid')
const activeDeadlines = dayDeadlines.filter(d => !d.is_completed)
const completedDeadlines = dayDeadlines.filter(d => d.is_completed)
return (
<div className="border rounded-lg overflow-hidden">
{/* Header */}
<div className={cn(
'p-4 border-b flex items-center justify-between',
isToday && 'bg-primary/10'
)}>
<h3 className={cn(
'text-lg font-semibold capitalize',
isToday && 'text-primary'
)}>
{formatDayViewHeader(date)}
{isToday && <span className="ml-2 text-sm font-normal text-muted-foreground">(Idag)</span>}
</h3>
<Button
variant="outline"
size="sm"
onClick={() => onAddDeadline(date)}
>
<Plus className="h-4 w-4 mr-1" />
Lägg till deadline
</Button>
</div>
{/* All-day events section */}
<div className="border-b">
<div className="p-2 bg-muted/30">
<div className="text-xs font-medium text-muted-foreground mb-2">Hela dagen</div>
{/* Active invoices */}
{activeInvoices.length > 0 && (
<div className="space-y-1 mb-2">
{activeInvoices.map((invoice) => (
<div
key={invoice.id}
className={cn(
'p-2 rounded-md',
isInvoiceOverdue(invoice)
? 'bg-destructive/10 border border-destructive/30'
: 'bg-primary/10 border border-primary/30'
)}
>
<div className="flex items-center gap-2">
<div className={cn(
'w-3 h-3 rounded-full flex-shrink-0',
isInvoiceOverdue(invoice) ? 'bg-destructive' : 'bg-primary'
)} />
<div className="flex-1 min-w-0">
<div className={cn(
'text-sm font-medium',
isInvoiceOverdue(invoice) ? 'text-destructive' : 'text-primary'
)}>
Faktura {invoice.invoice_number}
{isInvoiceOverdue(invoice) && (
<span className="ml-2 text-xs bg-destructive/20 px-1.5 py-0.5 rounded">
Förfallen
</span>
)}
</div>
<div className="text-xs text-muted-foreground">
{invoice.customer?.name} • {(invoice.total_sek || invoice.total).toLocaleString('sv-SE')} kr
</div>
</div>
</div>
</div>
))}
</div>
)}
{/* Paid invoices */}
{paidInvoices.length > 0 && (
<div className="space-y-1 mb-2">
{paidInvoices.map((invoice) => (
<div
key={invoice.id}
className="p-2 rounded-md bg-green-500/10 border border-green-500/30"
>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-green-700">
Faktura {invoice.invoice_number}
<span className="ml-2 text-xs bg-green-500/20 px-1.5 py-0.5 rounded">
Betald
</span>
</div>
<div className="text-xs text-muted-foreground">
{invoice.customer?.name} • {(invoice.total_sek || invoice.total).toLocaleString('sv-SE')} kr
</div>
</div>
</div>
</div>
))}
</div>
)}
{/* Active deadlines */}
{activeDeadlines.length > 0 && (
<div className="space-y-1 mb-2">
{activeDeadlines.map((deadline) => (
<div
key={deadline.id}
className={cn(
'p-2 rounded-md',
isDeadlineOverdue(deadline)
? 'bg-destructive/10 border border-destructive/30'
: 'bg-orange-500/10 border border-orange-500/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'
)} />
<div className="flex-1 min-w-0">
<div className={cn(
'text-sm font-medium',
isDeadlineOverdue(deadline) ? 'text-destructive' : 'text-orange-700'
)}>
{deadline.title}
{isDeadlineOverdue(deadline) && (
<span className="ml-2 text-xs bg-destructive/20 px-1.5 py-0.5 rounded">
Försenad
</span>
)}
</div>
<div className="text-xs text-muted-foreground">
{DEADLINE_TYPE_LABELS[deadline.deadline_type] || deadline.deadline_type}
{deadline.priority !== 'normal' && (
<span className={cn(
'ml-2 px-1.5 py-0.5 rounded',
deadline.priority === 'critical' && 'bg-red-100 text-red-700',
deadline.priority === 'important' && 'bg-orange-100 text-orange-700'
)}>
{PRIORITY_LABELS[deadline.priority]}
</span>
)}
{deadline.customer?.name && ` • ${deadline.customer.name}`}
</div>
</div>
</div>
</div>
))}
</div>
)}
{/* Completed deadlines */}
{completedDeadlines.length > 0 && (
<div className="space-y-1">
{completedDeadlines.map((deadline) => (
<div
key={deadline.id}
className="p-2 rounded-md bg-green-500/10 border border-green-500/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="flex-1 min-w-0">
<div className="text-sm font-medium text-green-700 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>
</div>
</div>
</div>
</div>
))}
</div>
)}
{/* Empty state */}
{dayInvoices.length === 0 && dayDeadlines.length === 0 && (
<div className="text-sm text-muted-foreground py-4 text-center">
Inga händelser för denna dag
</div>
)}
</div>
</div>
{/* Time grid */}
<div className="max-h-[400px] overflow-y-auto">
{timeSlots.map((time) => (
<button
key={time}
onClick={() => onAddDeadline(date)}
className="w-full flex border-b last:border-b-0 hover:bg-muted/50 transition-colors"
>
<div className="w-16 p-2 border-r text-xs text-muted-foreground text-right pr-2 flex-shrink-0">
{time}
</div>
<div className="flex-1 min-h-[40px]" />
</button>
))}
</div>
</div>
)
}
+55
View File
@@ -0,0 +1,55 @@
'use client'
import { Invoice, Deadline } from '@/types'
import { SWEDISH_DAYS, getMonthGrid } from '@/lib/calendar/utils'
import { CalendarDayCell } from './CalendarDayCell'
interface CalendarGridProps {
year: number
month: number
invoices: Invoice[]
deadlines: Deadline[]
onDayClick: (date: Date) => void
}
export function CalendarGrid({
year,
month,
invoices,
deadlines,
onDayClick,
}: CalendarGridProps) {
const weeks = getMonthGrid(year, month)
return (
<div className="border rounded-lg overflow-hidden">
{/* Day headers */}
<div className="grid grid-cols-7 bg-muted">
{SWEDISH_DAYS.map((day) => (
<div
key={day}
className="py-2 text-center text-sm font-medium text-muted-foreground border-r last:border-r-0"
>
{day}
</div>
))}
</div>
{/* Week rows */}
{weeks.map((week, weekIndex) => (
<div key={weekIndex} className="grid grid-cols-7">
{week.map((date, dayIndex) => (
<CalendarDayCell
key={dayIndex}
date={date}
currentMonth={month}
invoices={invoices}
deadlines={deadlines}
onDayClick={onDayClick}
/>
))}
</div>
))}
</div>
)
}
+85
View File
@@ -0,0 +1,85 @@
'use client'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { CalendarViewMode } from '@/types'
import {
SWEDISH_MONTHS,
getWeekNumber,
formatDayViewHeader,
} from '@/lib/calendar/utils'
import { ViewModeSelector } from './ViewModeSelector'
interface CalendarHeaderProps {
year: number
month: number
currentDate?: Date
viewMode?: CalendarViewMode
onPrevious: () => void
onNext: () => void
onToday: () => void
onViewModeChange?: (mode: CalendarViewMode) => void
}
export function CalendarHeader({
year,
month,
currentDate,
viewMode = 'month',
onPrevious,
onNext,
onToday,
onViewModeChange,
}: CalendarHeaderProps) {
// Generate title based on view mode
const getTitle = () => {
switch (viewMode) {
case 'week':
if (currentDate) {
const weekNum = getWeekNumber(currentDate)
return `Vecka ${weekNum}, ${currentDate.getFullYear()}`
}
return `${SWEDISH_MONTHS[month]} ${year}`
case 'day':
if (currentDate) {
return formatDayViewHeader(currentDate)
}
return `${SWEDISH_MONTHS[month]} ${year}`
case 'month':
default:
return `${SWEDISH_MONTHS[month]} ${year}`
}
}
return (
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" onClick={onPrevious}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={onNext}>
<ChevronRight className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" onClick={onToday}>
Idag
</Button>
</div>
<h2 className="text-xl font-semibold capitalize">
{getTitle()}
</h2>
{onViewModeChange && (
<ViewModeSelector
viewMode={viewMode}
onViewModeChange={onViewModeChange}
/>
)}
{/* Spacer when no view mode selector */}
{!onViewModeChange && <div />}
</div>
)
}
+157
View File
@@ -0,0 +1,157 @@
'use client'
import { Invoice, Deadline } from '@/types'
import { cn } from '@/lib/utils'
import {
getWeekDays,
formatWeekDayHeader,
formatDateISO,
isSameDay,
isInvoiceOverdue,
isDeadlineOverdue,
getTimeSlots,
} from '@/lib/calendar/utils'
interface CalendarWeekViewProps {
currentDate: Date
invoices: Invoice[]
deadlines: Deadline[]
onDayClick: (date: Date) => void
}
export function CalendarWeekView({
currentDate,
invoices,
deadlines,
onDayClick,
}: CalendarWeekViewProps) {
const weekDays = getWeekDays(currentDate)
const timeSlots = getTimeSlots(8, 20)
const today = new Date()
// Group invoices and deadlines by date for quick lookup
const getItemsForDate = (date: Date) => {
const dateStr = formatDateISO(date)
const dayInvoices = invoices.filter(inv => inv.due_date === dateStr)
const dayDeadlines = deadlines.filter(d => d.due_date === dateStr)
return { invoices: dayInvoices, deadlines: dayDeadlines }
}
return (
<div className="border rounded-lg overflow-hidden">
{/* Header row with day names and dates */}
<div className="grid grid-cols-[60px_repeat(7,1fr)] bg-muted border-b">
<div className="p-2 border-r" /> {/* Empty corner cell */}
{weekDays.map((date, index) => {
const isToday = isSameDay(date, today)
return (
<button
key={index}
onClick={() => onDayClick(date)}
className={cn(
'p-2 text-center border-r last:border-r-0 hover:bg-muted/80 transition-colors',
isToday && 'bg-primary/10'
)}
>
<div className={cn(
'text-sm font-medium',
isToday && 'text-primary font-bold'
)}>
{formatWeekDayHeader(date)}
</div>
</button>
)
})}
</div>
{/* All-day events section */}
<div className="grid grid-cols-[60px_repeat(7,1fr)] border-b bg-muted/30">
<div className="p-2 border-r text-xs text-muted-foreground">
Heldag
</div>
{weekDays.map((date, index) => {
const items = getItemsForDate(date)
const isToday = isSameDay(date, today)
// Filter to only show non-timed items (all invoices and deadlines without times are "all-day")
const allDayInvoices = items.invoices.filter(inv =>
inv.status !== 'paid' && inv.status !== 'cancelled' && inv.status !== 'credited'
)
const allDayDeadlines = items.deadlines.filter(d => !d.is_completed)
return (
<div
key={index}
className={cn(
'min-h-[60px] p-1 border-r last:border-r-0',
isToday && 'bg-primary/5'
)}
>
{/* Invoice indicators */}
{allDayInvoices.map((invoice) => (
<div
key={invoice.id}
className={cn(
'text-xs p-1 mb-1 rounded truncate',
isInvoiceOverdue(invoice)
? 'bg-destructive/20 text-destructive'
: 'bg-primary/20 text-primary'
)}
title={`Faktura ${invoice.invoice_number} - ${invoice.customer?.name || ''}`}
>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-current flex-shrink-0" />
<span className="truncate">F#{invoice.invoice_number}</span>
</div>
</div>
))}
{/* Deadline indicators */}
{allDayDeadlines.map((deadline) => (
<div
key={deadline.id}
className={cn(
'text-xs p-1 mb-1 rounded truncate',
isDeadlineOverdue(deadline)
? 'bg-destructive/20 text-destructive'
: 'bg-orange-500/20 text-orange-700'
)}
title={deadline.title}
>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-current flex-shrink-0" />
<span className="truncate">{deadline.title}</span>
</div>
</div>
))}
</div>
)
})}
</div>
{/* Time grid */}
<div className="max-h-[500px] overflow-y-auto">
{timeSlots.map((time, timeIndex) => (
<div key={time} className="grid grid-cols-[60px_repeat(7,1fr)] border-b last:border-b-0">
<div className="p-2 border-r text-xs text-muted-foreground text-right pr-2">
{time}
</div>
{weekDays.map((date, dayIndex) => {
const isToday = isSameDay(date, today)
return (
<button
key={dayIndex}
onClick={() => onDayClick(date)}
className={cn(
'min-h-[40px] border-r last:border-r-0 hover:bg-muted/50 transition-colors',
isToday && 'bg-primary/5'
)}
/>
)
})}
</div>
))}
</div>
</div>
)
}
+240
View File
@@ -0,0 +1,240 @@
'use client'
import Link from 'next/link'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Invoice, Deadline } from '@/types'
import { formatCurrency, formatDate } from '@/lib/utils'
import {
isInvoiceOverdue,
isDeadlineOverdue,
formatDateISO,
DEADLINE_TYPE_LABELS,
PRIORITY_LABELS,
PRIORITY_COLORS,
} from '@/lib/calendar/utils'
import { FileText, CheckCircle, Clock, AlertTriangle, Plus } from 'lucide-react'
interface DayDetailModalProps {
date: Date | null
invoices: Invoice[]
deadlines: Deadline[]
open: boolean
onOpenChange: (open: boolean) => void
onAddDeadline: (date: Date) => void
onToggleDeadline: (deadline: Deadline) => void
}
export function DayDetailModal({
date,
invoices,
deadlines,
open,
onOpenChange,
onAddDeadline,
onToggleDeadline,
}: DayDetailModalProps) {
if (!date) return null
const dateStr = formatDateISO(date)
const dayInvoices = invoices.filter(inv => inv.due_date === dateStr)
const dayDeadlines = deadlines.filter(d => d.due_date === dateStr)
const formattedDate = date.toLocaleDateString('sv-SE', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="capitalize">{formattedDate}</DialogTitle>
</DialogHeader>
<div className="space-y-6">
{/* Invoices section */}
{dayInvoices.length > 0 && (
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-2">
Fakturor ({dayInvoices.length})
</h3>
<div className="space-y-2">
{dayInvoices.map((invoice) => {
const overdue = isInvoiceOverdue(invoice)
const paid = invoice.status === 'paid'
return (
<Link
key={invoice.id}
href={`/invoices/${invoice.id}`}
className="block"
>
<div
className={`p-3 rounded-lg border transition-colors hover:bg-muted/50 ${
overdue ? 'border-destructive/50 bg-destructive/5' :
paid ? 'border-success/50 bg-success/5' :
'border-border'
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2">
{overdue ? (
<AlertTriangle className="h-4 w-4 text-destructive mt-0.5" />
) : paid ? (
<CheckCircle className="h-4 w-4 text-success mt-0.5" />
) : (
<FileText className="h-4 w-4 text-muted-foreground mt-0.5" />
)}
<div>
<p className="font-medium text-sm">
{invoice.invoice_number}
</p>
<p className="text-xs text-muted-foreground">
{(invoice.customer as { name: string })?.name || 'Okänd kund'}
</p>
</div>
</div>
<div className="text-right">
<p className="font-medium text-sm">
{formatCurrency(invoice.total, invoice.currency)}
</p>
<Badge
variant={
overdue ? 'destructive' :
paid ? 'success' :
'secondary'
}
className="text-xs"
>
{overdue ? 'Förfallen' : paid ? 'Betald' : 'Väntande'}
</Badge>
</div>
</div>
</div>
</Link>
)
})}
</div>
</div>
)}
{/* Deadlines section */}
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-muted-foreground">
Deadlines ({dayDeadlines.length})
</h3>
<Button
variant="ghost"
size="sm"
onClick={() => onAddDeadline(date)}
>
<Plus className="h-4 w-4 mr-1" />
Lägg till
</Button>
</div>
{dayDeadlines.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
Inga deadlines denna dag
</p>
) : (
<div className="space-y-2">
{dayDeadlines.map((deadline) => {
const overdue = isDeadlineOverdue(deadline)
const completed = deadline.is_completed
const priorityStyle = PRIORITY_COLORS[deadline.priority]
return (
<div
key={deadline.id}
className={`p-3 rounded-lg border transition-colors ${
overdue && !completed
? 'border-destructive/50 bg-destructive/5'
: completed
? 'border-success/50 bg-success/5'
: `${priorityStyle.border} ${priorityStyle.bg}`
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2">
<button
onClick={() => onToggleDeadline(deadline)}
className="mt-0.5"
>
{completed ? (
<CheckCircle className="h-4 w-4 text-success" />
) : overdue ? (
<AlertTriangle className="h-4 w-4 text-destructive" />
) : (
<Clock className="h-4 w-4 text-muted-foreground" />
)}
</button>
<div>
<p className={`font-medium text-sm ${completed ? 'line-through text-muted-foreground' : ''}`}>
{deadline.title}
</p>
{deadline.due_time && (
<p className="text-xs text-muted-foreground">
kl. {deadline.due_time.slice(0, 5)}
</p>
)}
{deadline.customer && (
<p className="text-xs text-muted-foreground">
{deadline.customer.name}
</p>
)}
</div>
</div>
<div className="flex flex-col items-end gap-1">
<Badge variant="outline" className="text-xs">
{DEADLINE_TYPE_LABELS[deadline.deadline_type]}
</Badge>
{!completed && deadline.priority !== 'normal' && (
<Badge
variant={deadline.priority === 'critical' ? 'destructive' : 'warning'}
className="text-xs"
>
{PRIORITY_LABELS[deadline.priority]}
</Badge>
)}
</div>
</div>
</div>
)
})}
</div>
)}
</div>
{/* Empty state */}
{dayInvoices.length === 0 && dayDeadlines.length === 0 && (
<div className="text-center py-6">
<Clock className="h-8 w-8 text-muted-foreground mx-auto mb-2" />
<p className="text-sm text-muted-foreground">
Inga händelser denna dag
</p>
<Button
variant="outline"
size="sm"
className="mt-2"
onClick={() => onAddDeadline(date)}
>
<Plus className="h-4 w-4 mr-1" />
Lägg till deadline
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
)
}
+175
View File
@@ -0,0 +1,175 @@
'use client'
import { Deadline } from '@/types'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import {
isDeadlineOverdue,
DEADLINE_TYPE_LABELS,
PRIORITY_LABELS,
PRIORITY_COLORS,
} from '@/lib/calendar/utils'
import { CheckCircle, Circle, Clock, AlertTriangle, Trash2, Edit2 } from 'lucide-react'
interface DeadlineCardProps {
deadline: Deadline
onToggle: (deadline: Deadline) => void
onEdit?: (deadline: Deadline) => void
onDelete?: (deadline: Deadline) => void
compact?: boolean
}
export function DeadlineCard({
deadline,
onToggle,
onEdit,
onDelete,
compact = false,
}: DeadlineCardProps) {
const overdue = isDeadlineOverdue(deadline)
const completed = deadline.is_completed
const priorityStyle = PRIORITY_COLORS[deadline.priority]
const formatDueDate = () => {
const date = new Date(deadline.due_date)
const formatted = date.toLocaleDateString('sv-SE', {
day: 'numeric',
month: 'short',
})
if (deadline.due_time) {
return `${formatted} kl. ${deadline.due_time.slice(0, 5)}`
}
return formatted
}
if (compact) {
return (
<div
className={cn(
'flex items-center gap-2 p-2 rounded-lg border transition-colors',
overdue && !completed
? 'border-destructive/50 bg-destructive/5'
: completed
? 'border-success/50 bg-success/5'
: `${priorityStyle.border} ${priorityStyle.bg}`
)}
>
<button onClick={() => onToggle(deadline)} className="flex-shrink-0">
{completed ? (
<CheckCircle className="h-4 w-4 text-success" />
) : (
<Circle className="h-4 w-4 text-muted-foreground" />
)}
</button>
<div className="flex-1 min-w-0">
<p
className={cn(
'text-sm font-medium truncate',
completed && 'line-through text-muted-foreground'
)}
>
{deadline.title}
</p>
</div>
{deadline.priority !== 'normal' && !completed && (
<div
className={cn(
'w-2 h-2 rounded-full flex-shrink-0',
deadline.priority === 'critical' ? 'bg-destructive' : 'bg-orange-500'
)}
/>
)}
</div>
)
}
return (
<div
className={cn(
'p-4 rounded-lg border transition-colors',
overdue && !completed
? 'border-destructive/50 bg-destructive/5'
: completed
? 'border-success/50 bg-success/5'
: `${priorityStyle.border} ${priorityStyle.bg}`
)}
>
<div className="flex items-start gap-3">
<button onClick={() => onToggle(deadline)} className="mt-0.5 flex-shrink-0">
{completed ? (
<CheckCircle className="h-5 w-5 text-success" />
) : overdue ? (
<AlertTriangle className="h-5 w-5 text-destructive" />
) : (
<Clock className="h-5 w-5 text-muted-foreground" />
)}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div>
<p
className={cn(
'font-medium',
completed && 'line-through text-muted-foreground'
)}
>
{deadline.title}
</p>
<p className="text-sm text-muted-foreground">{formatDueDate()}</p>
{deadline.customer && (
<p className="text-sm text-muted-foreground">{deadline.customer.name}</p>
)}
</div>
<div className="flex flex-col items-end gap-1">
<Badge variant="outline" className="text-xs">
{DEADLINE_TYPE_LABELS[deadline.deadline_type]}
</Badge>
{!completed && deadline.priority !== 'normal' && (
<Badge
variant={deadline.priority === 'critical' ? 'destructive' : 'warning'}
className="text-xs"
>
{PRIORITY_LABELS[deadline.priority]}
</Badge>
)}
{overdue && !completed && (
<Badge variant="destructive" className="text-xs">
Förfallen
</Badge>
)}
</div>
</div>
{deadline.notes && (
<p className="text-sm text-muted-foreground mt-2 line-clamp-2">
{deadline.notes}
</p>
)}
</div>
</div>
{(onEdit || onDelete) && (
<div className="flex justify-end gap-1 mt-2 pt-2 border-t">
{onEdit && (
<Button variant="ghost" size="sm" onClick={() => onEdit(deadline)}>
<Edit2 className="h-4 w-4" />
</Button>
)}
{onDelete && (
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(deadline)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
)}
</div>
)
}
+67
View File
@@ -0,0 +1,67 @@
'use client'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { DeadlineType } from '@/types'
import { DEADLINE_TYPE_LABELS } from '@/lib/calendar/utils'
import { X } from 'lucide-react'
interface DeadlineFiltersProps {
status: 'all' | 'pending' | 'completed'
type: DeadlineType | 'all'
onStatusChange: (status: 'all' | 'pending' | 'completed') => void
onTypeChange: (type: DeadlineType | 'all') => void
onReset: () => void
}
export function DeadlineFilters({
status,
type,
onStatusChange,
onTypeChange,
onReset,
}: DeadlineFiltersProps) {
const hasFilters = status !== 'all' || type !== 'all'
return (
<div className="flex flex-wrap items-center gap-2">
<Select value={status} onValueChange={(v) => onStatusChange(v as typeof status)}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alla</SelectItem>
<SelectItem value="pending">Ej klara</SelectItem>
<SelectItem value="completed">Klara</SelectItem>
</SelectContent>
</Select>
<Select value={type} onValueChange={(v) => onTypeChange(v as typeof type)}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Typ" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alla typer</SelectItem>
{Object.entries(DEADLINE_TYPE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
{hasFilters && (
<Button variant="ghost" size="sm" onClick={onReset}>
<X className="h-4 w-4 mr-1" />
Rensa
</Button>
)}
</div>
)
}
+269
View File
@@ -0,0 +1,269 @@
'use client'
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Deadline, DeadlineType, DeadlinePriority } from '@/types'
import { formatDateISO, DEADLINE_TYPE_LABELS, PRIORITY_LABELS } from '@/lib/calendar/utils'
interface DeadlineFormProps {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (data: Omit<Deadline, 'id' | 'user_id' | 'created_at' | 'updated_at'>) => Promise<void>
initialData?: Partial<Deadline>
initialDate?: Date | null
customers: { id: string; name: string }[]
}
export function DeadlineForm({
open,
onOpenChange,
onSubmit,
initialData,
initialDate,
customers,
}: DeadlineFormProps) {
const [isLoading, setIsLoading] = useState(false)
const [formData, setFormData] = useState({
title: '',
due_date: '',
due_time: '',
deadline_type: 'other' as DeadlineType,
priority: 'normal' as DeadlinePriority,
customer_id: '',
notes: '',
})
// Reset form when dialog opens with new data
useEffect(() => {
if (open) {
if (initialData) {
setFormData({
title: initialData.title || '',
due_date: initialData.due_date || formatDateISO(new Date()),
due_time: initialData.due_time || '',
deadline_type: initialData.deadline_type || 'other',
priority: initialData.priority || 'normal',
customer_id: initialData.customer_id || '',
notes: initialData.notes || '',
})
} else if (initialDate) {
setFormData({
title: '',
due_date: formatDateISO(initialDate),
due_time: '',
deadline_type: 'other',
priority: 'normal',
customer_id: '',
notes: '',
})
} else {
setFormData({
title: '',
due_date: formatDateISO(new Date()),
due_time: '',
deadline_type: 'other',
priority: 'normal',
customer_id: '',
notes: '',
})
}
}
}, [open, initialData, initialDate])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
try {
await onSubmit({
title: formData.title,
due_date: formData.due_date,
due_time: formData.due_time || null,
deadline_type: formData.deadline_type,
priority: formData.priority,
customer_id: formData.customer_id || null,
notes: formData.notes || null,
is_completed: initialData?.is_completed || false,
completed_at: initialData?.completed_at || null,
campaign_id: null,
deliverable_id: null,
is_auto_generated: false,
date_calculation_type: null,
reference_event: null,
offset_days: null,
// New tax deadline fields with defaults for user-created deadlines
tax_deadline_type: null,
tax_period: null,
source: 'user',
reminder_offsets: [14, 7, 1, 0],
status: 'upcoming',
status_changed_at: new Date().toISOString(),
linked_report_type: null,
linked_report_period: null,
})
} finally {
setIsLoading(false)
}
}
const updateField = <K extends keyof typeof formData>(
field: K,
value: (typeof formData)[K]
) => {
setFormData((prev) => ({ ...prev, [field]: value }))
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{initialData?.id ? 'Redigera deadline' : 'Ny deadline'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Title */}
<div className="space-y-2">
<Label htmlFor="title">Titel *</Label>
<Input
id="title"
placeholder="t.ex. Leverera video till Företag AB"
value={formData.title}
onChange={(e) => updateField('title', e.target.value)}
required
/>
</div>
{/* Date and Time */}
<div className="grid gap-4 grid-cols-2">
<div className="space-y-2">
<Label htmlFor="due_date">Datum *</Label>
<Input
id="due_date"
type="date"
value={formData.due_date}
onChange={(e) => updateField('due_date', e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="due_time">Tid (valfritt)</Label>
<Input
id="due_time"
type="time"
value={formData.due_time}
onChange={(e) => updateField('due_time', e.target.value)}
/>
</div>
</div>
{/* Type and Priority */}
<div className="grid gap-4 grid-cols-2">
<div className="space-y-2">
<Label>Typ *</Label>
<Select
value={formData.deadline_type}
onValueChange={(v) => updateField('deadline_type', v as DeadlineType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(DEADLINE_TYPE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Prioritet</Label>
<Select
value={formData.priority}
onValueChange={(v) => updateField('priority', v as DeadlinePriority)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(PRIORITY_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Customer */}
{customers.length > 0 && (
<div className="space-y-2">
<Label>Kund (valfritt)</Label>
<Select
value={formData.customer_id}
onValueChange={(v) => updateField('customer_id', v)}
>
<SelectTrigger>
<SelectValue placeholder="Välj kund..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Ingen kund</SelectItem>
{customers.map((customer) => (
<SelectItem key={customer.id} value={customer.id}>
{customer.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Notes */}
<div className="space-y-2">
<Label htmlFor="notes">Anteckningar (valfritt)</Label>
<Textarea
id="notes"
placeholder="Lägg till anteckningar..."
value={formData.notes}
onChange={(e) => updateField('notes', e.target.value)}
rows={3}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Avbryt
</Button>
<Button type="submit" disabled={isLoading || !formData.title}>
{isLoading ? 'Sparar...' : initialData?.id ? 'Spara' : 'Skapa'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
+217
View File
@@ -0,0 +1,217 @@
'use client'
import { useState, useMemo } from 'react'
import { Deadline, DeadlineType } from '@/types'
import { Button } from '@/components/ui/button'
import { DeadlineCard } from './DeadlineCard'
import { DeadlineFilters } from './DeadlineFilters'
import { DeadlineForm } from './DeadlineForm'
import { isDeadlineOverdue } from '@/lib/calendar/utils'
import { Plus, Calendar } from 'lucide-react'
interface DeadlineListProps {
deadlines: Deadline[]
customers: { id: string; name: string }[]
onDeadlineCreate: (data: Omit<Deadline, 'id' | 'user_id' | 'created_at' | 'updated_at'>) => Promise<void>
onDeadlineToggle: (deadline: Deadline) => Promise<void>
onDeadlineEdit: (deadline: Deadline) => Promise<void>
onDeadlineDelete: (deadline: Deadline) => Promise<void>
}
export function DeadlineList({
deadlines,
customers,
onDeadlineCreate,
onDeadlineToggle,
onDeadlineEdit,
onDeadlineDelete,
}: DeadlineListProps) {
const [statusFilter, setStatusFilter] = useState<'all' | 'pending' | 'completed'>('pending')
const [typeFilter, setTypeFilter] = useState<DeadlineType | 'all'>('all')
const [showForm, setShowForm] = useState(false)
const [editingDeadline, setEditingDeadline] = useState<Deadline | null>(null)
const filteredDeadlines = useMemo(() => {
return deadlines.filter((d) => {
// Status filter
if (statusFilter === 'pending' && d.is_completed) return false
if (statusFilter === 'completed' && !d.is_completed) return false
// Type filter
if (typeFilter !== 'all' && d.deadline_type !== typeFilter) return false
return true
})
}, [deadlines, statusFilter, typeFilter])
// Group by overdue, today, upcoming
const groupedDeadlines = useMemo(() => {
const today = new Date().toISOString().split('T')[0]
const overdue: Deadline[] = []
const todayDeadlines: Deadline[] = []
const upcoming: Deadline[] = []
const completed: Deadline[] = []
for (const d of filteredDeadlines) {
if (d.is_completed) {
completed.push(d)
} else if (isDeadlineOverdue(d)) {
overdue.push(d)
} else if (d.due_date === today) {
todayDeadlines.push(d)
} else {
upcoming.push(d)
}
}
return { overdue, today: todayDeadlines, upcoming, completed }
}, [filteredDeadlines])
const handleResetFilters = () => {
setStatusFilter('all')
setTypeFilter('all')
}
const handleFormSubmit = async (data: Omit<Deadline, 'id' | 'user_id' | 'created_at' | 'updated_at'>) => {
if (editingDeadline) {
await onDeadlineEdit({ ...editingDeadline, ...data })
} else {
await onDeadlineCreate(data)
}
setShowForm(false)
setEditingDeadline(null)
}
const handleEdit = (deadline: Deadline) => {
setEditingDeadline(deadline)
setShowForm(true)
}
const handleFormClose = () => {
setShowForm(false)
setEditingDeadline(null)
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<DeadlineFilters
status={statusFilter}
type={typeFilter}
onStatusChange={setStatusFilter}
onTypeChange={setTypeFilter}
onReset={handleResetFilters}
/>
<Button onClick={() => setShowForm(true)}>
<Plus className="h-4 w-4 mr-2" />
Ny deadline
</Button>
</div>
{/* Deadline groups */}
{filteredDeadlines.length === 0 ? (
<div className="text-center py-12">
<Calendar className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium">Inga deadlines</h3>
<p className="text-muted-foreground mt-1">
{statusFilter !== 'all' || typeFilter !== 'all'
? 'Inga deadlines matchar dina filter'
: 'Skapa din första deadline för att komma igång'}
</p>
</div>
) : (
<div className="space-y-6">
{/* Overdue */}
{groupedDeadlines.overdue.length > 0 && (
<div>
<h3 className="text-sm font-medium text-destructive mb-2">
Förfallna ({groupedDeadlines.overdue.length})
</h3>
<div className="space-y-2">
{groupedDeadlines.overdue.map((deadline) => (
<DeadlineCard
key={deadline.id}
deadline={deadline}
onToggle={onDeadlineToggle}
onEdit={handleEdit}
onDelete={onDeadlineDelete}
/>
))}
</div>
</div>
)}
{/* Today */}
{groupedDeadlines.today.length > 0 && (
<div>
<h3 className="text-sm font-medium text-warning mb-2">
Idag ({groupedDeadlines.today.length})
</h3>
<div className="space-y-2">
{groupedDeadlines.today.map((deadline) => (
<DeadlineCard
key={deadline.id}
deadline={deadline}
onToggle={onDeadlineToggle}
onEdit={handleEdit}
onDelete={onDeadlineDelete}
/>
))}
</div>
</div>
)}
{/* Upcoming */}
{groupedDeadlines.upcoming.length > 0 && (
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-2">
Kommande ({groupedDeadlines.upcoming.length})
</h3>
<div className="space-y-2">
{groupedDeadlines.upcoming.map((deadline) => (
<DeadlineCard
key={deadline.id}
deadline={deadline}
onToggle={onDeadlineToggle}
onEdit={handleEdit}
onDelete={onDeadlineDelete}
/>
))}
</div>
</div>
)}
{/* Completed */}
{groupedDeadlines.completed.length > 0 && (
<div>
<h3 className="text-sm font-medium text-success mb-2">
Klara ({groupedDeadlines.completed.length})
</h3>
<div className="space-y-2">
{groupedDeadlines.completed.map((deadline) => (
<DeadlineCard
key={deadline.id}
deadline={deadline}
onToggle={onDeadlineToggle}
onEdit={handleEdit}
onDelete={onDeadlineDelete}
/>
))}
</div>
</div>
)}
</div>
)}
{/* Deadline form dialog */}
<DeadlineForm
open={showForm}
onOpenChange={handleFormClose}
onSubmit={handleFormSubmit}
initialData={editingDeadline || undefined}
customers={customers}
/>
</div>
)
}
+200
View File
@@ -0,0 +1,200 @@
'use client'
import { useState, useCallback } from 'react'
import { Invoice, Deadline, CalendarViewMode } from '@/types'
import {
getWeekStart,
} from '@/lib/calendar/utils'
import { CalendarHeader } from './CalendarHeader'
import { CalendarGrid } from './CalendarGrid'
import { CalendarWeekView } from './CalendarWeekView'
import { CalendarDayView } from './CalendarDayView'
import { DayDetailModal } from './DayDetailModal'
import { DeadlineForm } from './DeadlineForm'
interface PaymentCalendarProps {
invoices: Invoice[]
deadlines: Deadline[]
customers: { id: string; name: string }[]
onDeadlineCreate: (data: Omit<Deadline, 'id' | 'user_id' | 'created_at' | 'updated_at'>) => Promise<void>
onDeadlineToggle: (deadline: Deadline) => Promise<void>
}
export function PaymentCalendar({
invoices,
deadlines,
customers,
onDeadlineCreate,
onDeadlineToggle,
}: PaymentCalendarProps) {
const today = new Date()
const [year, setYear] = useState(today.getFullYear())
const [month, setMonth] = useState(today.getMonth())
const [viewMode, setViewMode] = useState<CalendarViewMode>('month')
const [selectedDate, setSelectedDate] = useState<Date>(today)
const [showDayModal, setShowDayModal] = useState(false)
const [modalDate, setModalDate] = useState<Date | null>(null)
const [showDeadlineForm, setShowDeadlineForm] = useState(false)
const [deadlineFormDate, setDeadlineFormDate] = useState<Date | null>(null)
// Navigation handlers based on view mode
const handlePrevious = useCallback(() => {
if (viewMode === 'month') {
if (month === 0) {
setMonth(11)
setYear(year - 1)
} else {
setMonth(month - 1)
}
} else if (viewMode === 'week') {
const newDate = new Date(selectedDate)
newDate.setDate(newDate.getDate() - 7)
setSelectedDate(newDate)
setYear(newDate.getFullYear())
setMonth(newDate.getMonth())
} else if (viewMode === 'day') {
const newDate = new Date(selectedDate)
newDate.setDate(newDate.getDate() - 1)
setSelectedDate(newDate)
setYear(newDate.getFullYear())
setMonth(newDate.getMonth())
}
}, [viewMode, month, year, selectedDate])
const handleNext = useCallback(() => {
if (viewMode === 'month') {
if (month === 11) {
setMonth(0)
setYear(year + 1)
} else {
setMonth(month + 1)
}
} else if (viewMode === 'week') {
const newDate = new Date(selectedDate)
newDate.setDate(newDate.getDate() + 7)
setSelectedDate(newDate)
setYear(newDate.getFullYear())
setMonth(newDate.getMonth())
} else if (viewMode === 'day') {
const newDate = new Date(selectedDate)
newDate.setDate(newDate.getDate() + 1)
setSelectedDate(newDate)
setYear(newDate.getFullYear())
setMonth(newDate.getMonth())
}
}, [viewMode, month, year, selectedDate])
const handleToday = useCallback(() => {
const today = new Date()
setYear(today.getFullYear())
setMonth(today.getMonth())
setSelectedDate(today)
}, [])
const handleViewModeChange = useCallback((mode: CalendarViewMode) => {
setViewMode(mode)
// When switching to week view, ensure selectedDate is set properly
if (mode === 'week') {
const weekStart = getWeekStart(selectedDate)
setSelectedDate(weekStart)
}
}, [selectedDate])
const handleDayClick = useCallback((date: Date) => {
if (viewMode === 'month') {
// In month view, clicking a day shows the modal
setModalDate(date)
setShowDayModal(true)
} else if (viewMode === 'week') {
// In week view, clicking a day switches to day view
setSelectedDate(date)
setViewMode('day')
setYear(date.getFullYear())
setMonth(date.getMonth())
}
}, [viewMode])
const handleAddDeadline = useCallback((date: Date) => {
setDeadlineFormDate(date)
setShowDayModal(false)
setShowDeadlineForm(true)
}, [])
const handleDeadlineFormClose = useCallback(() => {
setShowDeadlineForm(false)
setDeadlineFormDate(null)
}, [])
const handleDeadlineSubmit = useCallback(async (data: Omit<Deadline, 'id' | 'user_id' | 'created_at' | 'updated_at'>) => {
await onDeadlineCreate(data)
handleDeadlineFormClose()
}, [onDeadlineCreate])
return (
<div className="space-y-4">
<div>
<CalendarHeader
year={year}
month={month}
currentDate={selectedDate}
viewMode={viewMode}
onPrevious={handlePrevious}
onNext={handleNext}
onToday={handleToday}
onViewModeChange={handleViewModeChange}
/>
{/* Month View */}
{viewMode === 'month' && (
<CalendarGrid
year={year}
month={month}
invoices={invoices}
deadlines={deadlines}
onDayClick={handleDayClick}
/>
)}
{/* Week View */}
{viewMode === 'week' && (
<CalendarWeekView
currentDate={selectedDate}
invoices={invoices}
deadlines={deadlines}
onDayClick={handleDayClick}
/>
)}
{/* Day View */}
{viewMode === 'day' && (
<CalendarDayView
date={selectedDate}
invoices={invoices}
deadlines={deadlines}
onAddDeadline={handleAddDeadline}
/>
)}
</div>
{/* Day detail modal (for month view clicks) */}
<DayDetailModal
date={modalDate}
invoices={invoices}
deadlines={deadlines}
open={showDayModal}
onOpenChange={setShowDayModal}
onAddDeadline={handleAddDeadline}
onToggleDeadline={onDeadlineToggle}
/>
{/* Deadline form dialog */}
<DeadlineForm
open={showDeadlineForm}
onOpenChange={handleDeadlineFormClose}
onSubmit={handleDeadlineSubmit}
initialDate={deadlineFormDate}
customers={customers}
/>
</div>
)
}
@@ -0,0 +1,98 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Invoice } from '@/types'
import { calculatePeriodSummary, SWEDISH_MONTHS } from '@/lib/calendar/utils'
import { formatCurrency } from '@/lib/utils'
import { TrendingUp, Clock, CheckCircle, AlertTriangle } from 'lucide-react'
interface PaymentSummaryCardProps {
invoices: Invoice[]
year: number
month: number
}
export function PaymentSummaryCard({ invoices, year, month }: PaymentSummaryCardProps) {
const summary = calculatePeriodSummary(invoices)
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-lg">
Sammanfattning {SWEDISH_MONTHS[month]} {year}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 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>
<div>
<p className="text-sm text-muted-foreground">Förväntat</p>
<p className="text-sm font-medium">{summary.pendingCount} fakturor</p>
</div>
</div>
<p className="text-lg font-semibold">{formatCurrency(summary.totalExpected)}</p>
</div>
{/* Overdue */}
{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>
<div>
<p className="text-sm text-muted-foreground">Förfallen</p>
<p className="text-sm font-medium text-destructive">
{summary.overdueCount} fakturor
</p>
</div>
</div>
<p className="text-lg font-semibold text-destructive">
{formatCurrency(summary.totalOverdue)}
</p>
</div>
)}
{/* 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>
<div>
<p className="text-sm text-muted-foreground">Betald</p>
<p className="text-sm font-medium">{summary.paidCount} fakturor</p>
</div>
</div>
<p className="text-lg font-semibold text-success">
{formatCurrency(summary.totalPaid)}
</p>
</div>
{/* Pending */}
{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>
<div>
<p className="text-sm text-muted-foreground">Väntande</p>
<p className="text-sm font-medium">
{summary.pendingCount - summary.overdueCount} fakturor
</p>
</div>
</div>
<p className="text-lg font-semibold">
{formatCurrency(summary.totalExpected - summary.totalOverdue)}
</p>
</div>
)}
</CardContent>
</Card>
)
}
+243
View File
@@ -0,0 +1,243 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { Deadline, DeadlineStatus, TAX_DEADLINE_TYPE_LABELS, DEADLINE_STATUS_LABELS } from '@/types'
import { getReportUrl } from '@/lib/tax/deadline-config'
import { STATUS_COLORS } from '@/lib/calendar/utils'
// Use the labels from types
const STATUS_LABELS = DEADLINE_STATUS_LABELS
import {
FileText,
ChevronRight,
AlertTriangle,
Clock,
Send,
Check,
Loader2,
ExternalLink,
} from 'lucide-react'
interface TaxTodoWidgetProps {
deadlines: Deadline[]
onStatusChange?: (deadlineId: string, newStatus: DeadlineStatus) => void
}
export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps) {
const { toast } = useToast()
const [updatingId, setUpdatingId] = useState<string | null>(null)
// Filter to only tax deadlines needing attention
const taxDeadlines = deadlines.filter(
(d) =>
d.deadline_type === 'tax' &&
!d.is_completed &&
['action_needed', 'overdue', 'in_progress'].includes(d.status)
)
// Sort by urgency: overdue first, then by date
const sortedDeadlines = taxDeadlines.sort((a, b) => {
if (a.status === 'overdue' && b.status !== 'overdue') return -1
if (b.status === 'overdue' && a.status !== 'overdue') return 1
return a.due_date.localeCompare(b.due_date)
})
const overdueCount = taxDeadlines.filter((d) => d.status === 'overdue').length
const actionNeededCount = taxDeadlines.filter((d) => d.status === 'action_needed').length
if (sortedDeadlines.length === 0) {
return null
}
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
const today = new Date()
today.setHours(0, 0, 0, 0)
const diffDays = Math.ceil(
(date.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
)
if (diffDays === 0) return 'Idag'
if (diffDays === 1) return 'Imorgon'
if (diffDays < 0) return `${Math.abs(diffDays)} dagar sedan`
if (diffDays <= 7) return `Om ${diffDays} dagar`
return date.toLocaleDateString('sv-SE', { day: 'numeric', month: 'short' })
}
const handleStatusChange = async (deadlineId: string, newStatus: DeadlineStatus) => {
setUpdatingId(deadlineId)
try {
const response = await fetch(`/api/deadlines/${deadlineId}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus }),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to update status')
}
toast({
title: 'Status uppdaterad',
description: `Markerad som ${STATUS_LABELS[newStatus].toLowerCase()}`,
})
onStatusChange?.(deadlineId, newStatus)
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte uppdatera status',
variant: 'destructive',
})
} finally {
setUpdatingId(null)
}
}
const getReportLink = (deadline: Deadline): string | null => {
if (!deadline.linked_report_type || !deadline.linked_report_period) {
return null
}
return getReportUrl(deadline.linked_report_type, deadline.linked_report_period)
}
return (
<Card className="border-orange-200 bg-orange-50/30">
<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" />
Att göra - Skatt
</CardTitle>
<div className="flex gap-2">
{overdueCount > 0 && (
<Badge variant="destructive">{overdueCount} försenad</Badge>
)}
{actionNeededCount > 0 && (
<Badge variant="warning">{actionNeededCount} snart</Badge>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
{sortedDeadlines.slice(0, 5).map((deadline) => {
const isUpdating = updatingId === deadline.id
const reportLink = getReportLink(deadline)
const isOverdue = deadline.status === 'overdue'
const isActionNeeded = deadline.status === 'action_needed'
return (
<div
key={deadline.id}
className={`p-3 rounded-lg border ${
isOverdue
? 'border-destructive/50 bg-destructive/5'
: isActionNeeded
? 'border-orange-300 bg-orange-50'
: 'border-border bg-background'
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2 min-w-0 flex-1">
{isOverdue ? (
<AlertTriangle className="h-4 w-4 text-destructive flex-shrink-0 mt-0.5" />
) : (
<Clock className="h-4 w-4 text-orange-600 flex-shrink-0 mt-0.5" />
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{deadline.title}</p>
<div className="flex items-center gap-2 mt-1">
<span
className={`text-xs ${
isOverdue ? 'text-destructive font-medium' : 'text-muted-foreground'
}`}
>
{formatDate(deadline.due_date)}
</span>
{deadline.tax_deadline_type && (
<Badge variant="outline" className="text-xs px-1.5 py-0 h-5">
{TAX_DEADLINE_TYPE_LABELS[deadline.tax_deadline_type]}
</Badge>
)}
</div>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{/* Link to report if available */}
{reportLink && (
<Link href={reportLink}>
<Button variant="ghost" size="sm" className="h-7 px-2">
<ExternalLink className="h-3 w-3" />
</Button>
</Link>
)}
{/* Mark as in progress */}
{['action_needed', 'overdue'].includes(deadline.status) && (
<Button
variant="outline"
size="sm"
className="h-7 px-2 text-xs"
disabled={isUpdating}
onClick={() => handleStatusChange(deadline.id, 'in_progress')}
>
{isUpdating ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
'Påbörja'
)}
</Button>
)}
{/* Mark as submitted */}
{deadline.status === 'in_progress' && (
<Button
variant="default"
size="sm"
className="h-7 px-2 text-xs gap-1"
disabled={isUpdating}
onClick={() => handleStatusChange(deadline.id, 'submitted')}
>
{isUpdating ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<>
<Send className="h-3 w-3" />
Inskickad
</>
)}
</Button>
)}
</div>
</div>
</div>
)
})}
{sortedDeadlines.length > 5 && (
<p className="text-xs text-muted-foreground text-center">
+{sortedDeadlines.length - 5} fler uppgifter
</p>
)}
<Link href="/calendar" className="block">
<Button variant="ghost" className="w-full justify-between">
Visa alla skattedeadlines
<ChevronRight className="h-4 w-4" />
</Button>
</Link>
</CardContent>
</Card>
)
}
@@ -0,0 +1,213 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { Deadline, DeadlineStatus } from '@/types'
import {
getUpcomingDeadlines,
PRIORITY_LABELS,
STATUS_LABELS,
STATUS_COLORS,
} from '@/lib/calendar/utils'
import { Calendar, ChevronRight, AlertTriangle, Clock, Check, Send, Loader2 } from 'lucide-react'
interface UpcomingDeadlinesWidgetProps {
deadlines: Deadline[]
maxItems?: number
onStatusChange?: (deadlineId: string, newStatus: DeadlineStatus) => void
}
export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChange }: UpcomingDeadlinesWidgetProps) {
const { toast } = useToast()
const [updatingId, setUpdatingId] = useState<string | null>(null)
// Get upcoming deadlines (next 7 days) + any needing attention
const upcomingDeadlines = getUpcomingDeadlines(deadlines, 7)
const actionNeededDeadlines = deadlines.filter(
(d) => !d.is_completed && d.status === 'action_needed'
)
const overdueDeadlines = deadlines.filter(
(d) => !d.is_completed && d.status === 'overdue'
)
// Combine and sort (overdue first, then action_needed, then upcoming)
const displayDeadlines = [...overdueDeadlines, ...actionNeededDeadlines, ...upcomingDeadlines]
.filter((d, i, arr) => arr.findIndex((x) => x.id === d.id) === i) // Remove duplicates
.slice(0, maxItems)
const overdueCount = overdueDeadlines.length
const actionNeededCount = actionNeededDeadlines.length
if (displayDeadlines.length === 0) {
return null
}
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
const today = new Date()
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
if (date.toDateString() === today.toDateString()) {
return 'Idag'
}
if (date.toDateString() === tomorrow.toDateString()) {
return 'Imorgon'
}
return date.toLocaleDateString('sv-SE', { day: 'numeric', month: 'short' })
}
const handleStatusChange = async (deadlineId: string, newStatus: DeadlineStatus) => {
setUpdatingId(deadlineId)
try {
const response = await fetch(`/api/deadlines/${deadlineId}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus }),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to update status')
}
toast({
title: 'Status uppdaterad',
description: `Deadline markerad som ${STATUS_LABELS[newStatus].toLowerCase()}`,
})
// Notify parent component
onStatusChange?.(deadlineId, newStatus)
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte uppdatera status',
variant: 'destructive',
})
} finally {
setUpdatingId(null)
}
}
const getStatusBadgeVariant = (status: DeadlineStatus): 'default' | 'secondary' | 'destructive' | 'warning' => {
switch (status) {
case 'overdue': return 'destructive'
case 'action_needed': return 'warning'
case 'submitted':
case 'confirmed': return 'default'
default: return 'secondary'
}
}
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Calendar className="h-5 w-5" />
Kommande deadlines
</CardTitle>
<div className="flex gap-2">
{overdueCount > 0 && (
<Badge variant="destructive">{overdueCount} försenad</Badge>
)}
{actionNeededCount > 0 && (
<Badge variant="warning">{actionNeededCount} åtgärd</Badge>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-2">
{displayDeadlines.map((deadline) => {
const isUpdating = updatingId === deadline.id
const statusColors = STATUS_COLORS[deadline.status] || STATUS_COLORS.upcoming
return (
<div
key={deadline.id}
className={`flex items-center justify-between p-2 rounded-lg border ${statusColors.border} ${statusColors.bg}`}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
{deadline.status === 'overdue' ? (
<AlertTriangle className="h-4 w-4 text-destructive flex-shrink-0" />
) : deadline.status === 'action_needed' ? (
<Clock className="h-4 w-4 text-orange-600 flex-shrink-0" />
) : (
<Clock className="h-4 w-4 text-muted-foreground flex-shrink-0" />
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{deadline.title}</p>
<div className="flex items-center gap-2">
<p className="text-xs text-muted-foreground">
{formatDate(deadline.due_date)}
{deadline.due_time && ` kl. ${deadline.due_time.slice(0, 5)}`}
</p>
{deadline.tax_deadline_type && (
<Badge variant="outline" className="text-xs px-1 py-0">
Skatt
</Badge>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{/* Status badge */}
<Badge variant={getStatusBadgeVariant(deadline.status)} className="text-xs">
{STATUS_LABELS[deadline.status]}
</Badge>
{/* Quick action buttons for tax deadlines */}
{deadline.tax_deadline_type && !['submitted', 'confirmed'].includes(deadline.status) && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
disabled={isUpdating}
onClick={() => handleStatusChange(deadline.id, 'submitted')}
title="Markera som inskickad"
>
{isUpdating ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Send className="h-3 w-3" />
)}
</Button>
)}
{deadline.status === 'submitted' && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
disabled={isUpdating}
onClick={() => handleStatusChange(deadline.id, 'confirmed')}
title="Markera som bekräftad"
>
{isUpdating ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Check className="h-3 w-3" />
)}
</Button>
)}
</div>
</div>
)
})}
<Link href="/calendar" className="block">
<Button variant="ghost" className="w-full justify-between mt-2">
Visa kalender
<ChevronRight className="h-4 w-4" />
</Button>
</Link>
</CardContent>
</Card>
)
}
+34
View File
@@ -0,0 +1,34 @@
'use client'
import { CalendarViewMode } from '@/types'
import { VIEW_MODE_LABELS } from '@/lib/calendar/utils'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
interface ViewModeSelectorProps {
viewMode: CalendarViewMode
onViewModeChange: (mode: CalendarViewMode) => void
}
export function ViewModeSelector({ viewMode, onViewModeChange }: ViewModeSelectorProps) {
const modes: CalendarViewMode[] = ['month', 'week', 'day']
return (
<div className="inline-flex rounded-md border">
{modes.map((mode) => (
<Button
key={mode}
variant="ghost"
size="sm"
onClick={() => onViewModeChange(mode)}
className={cn(
'rounded-none border-r last:border-r-0 px-3',
viewMode === mode && 'bg-muted'
)}
>
{VIEW_MODE_LABELS[mode]}
</Button>
))}
</div>
)
}
+12
View File
@@ -0,0 +1,12 @@
export { CalendarHeader } from './CalendarHeader'
export { CalendarGrid } from './CalendarGrid'
export { CalendarDayCell } from './CalendarDayCell'
export { PaymentCalendar } from './PaymentCalendar'
export { PaymentSummaryCard } from './PaymentSummaryCard'
export { DayDetailModal } from './DayDetailModal'
export { DeadlineCard } from './DeadlineCard'
export { DeadlineFilters } from './DeadlineFilters'
export { DeadlineForm } from './DeadlineForm'
export { DeadlineList } from './DeadlineList'
export { UpcomingDeadlinesWidget } from './UpcomingDeadlinesWidget'
export { TaxTodoWidget } from './TaxTodoWidget'
+478
View File
@@ -0,0 +1,478 @@
'use client'
import { useState, useRef } from 'react'
import { Briefing, BriefingType, BRIEFING_TYPE_LABELS } from '@/types'
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 { Progress } from '@/components/ui/progress'
import { useToast } from '@/components/ui/use-toast'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Upload, FileText, X, Link2, AlignLeft, Loader2, Sparkles } from 'lucide-react'
import { cn } from '@/lib/utils'
interface BriefingFormProps {
campaignId: string
briefing?: Briefing | null
onSuccess: () => void
onCancel: () => void
}
export function BriefingForm({ campaignId, briefing, onSuccess, onCancel }: BriefingFormProps) {
const { toast } = useToast()
const fileInputRef = useRef<HTMLInputElement>(null)
const isEditing = !!briefing
// Form state
const [briefingType, setBriefingType] = useState<BriefingType>(briefing?.briefing_type || 'link')
const [title, setTitle] = useState(briefing?.title || '')
const [linkUrl, setLinkUrl] = useState(briefing?.briefing_type === 'link' ? briefing?.content || '' : '')
const [textContent, setTextContent] = useState(briefing?.text_content || '')
const [notes, setNotes] = useState(briefing?.notes || '')
// File upload state
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
// AI summary state
const [aiSummary, setAiSummary] = useState('')
const [isSummarizing, setIsSummarizing] = useState(false)
const handleSummarize = async () => {
if (!textContent.trim()) {
toast({
title: 'Text saknas',
description: 'Ange text att sammanfatta',
variant: 'destructive',
})
return
}
setIsSummarizing(true)
try {
const response = await fetch('/api/briefings/summarize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: textContent }),
})
if (!response.ok) {
throw new Error('Summarization failed')
}
const { data } = await response.json()
setAiSummary(data.summary)
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte sammanfatta texten',
variant: 'destructive',
})
} finally {
setIsSummarizing(false)
}
}
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
// Validate file type (only PDF for briefings)
if (file.type !== 'application/pdf') {
toast({
title: 'Ogiltig filtyp',
description: 'Endast PDF-filer ar tillåtna',
variant: 'destructive',
})
return
}
// Check file size (10MB max)
if (file.size > 10 * 1024 * 1024) {
toast({
title: 'Filen ar for stor',
description: 'Max filstorlek ar 10MB',
variant: 'destructive',
})
return
}
setSelectedFile(file)
// Auto-fill title from filename if empty
if (!title) {
setTitle(file.name.replace(/\.pdf$/i, ''))
}
}
}
const clearFileSelection = () => {
setSelectedFile(null)
if (fileInputRef.current) fileInputRef.current.value = ''
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// Validation
if (!title.trim()) {
toast({
title: 'Titel saknas',
description: 'Ange en titel for briefingen',
variant: 'destructive',
})
return
}
if (briefingType === 'link' && !linkUrl.trim()) {
toast({
title: 'Lank saknas',
description: 'Ange en URL for briefingen',
variant: 'destructive',
})
return
}
if (briefingType === 'text' && !textContent.trim()) {
toast({
title: 'Text saknas',
description: 'Ange textinnehall for briefingen',
variant: 'destructive',
})
return
}
if (briefingType === 'pdf' && !selectedFile && !isEditing) {
toast({
title: 'Fil saknas',
description: 'Valj en PDF-fil att ladda upp',
variant: 'destructive',
})
return
}
setIsSubmitting(true)
setUploadProgress(0)
try {
if (isEditing) {
// Update existing briefing
const updateData: Record<string, unknown> = {
title: title.trim(),
notes: notes.trim() || null,
}
if (briefingType === 'link') {
updateData.content = linkUrl.trim()
} else if (briefingType === 'text') {
updateData.text_content = textContent.trim()
}
const response = await fetch(`/api/briefings/${briefing.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updateData),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Update failed')
}
toast({
title: 'Briefing uppdaterad',
description: title,
})
} else if (briefingType === 'pdf' && selectedFile) {
// Upload PDF file
const formData = new FormData()
formData.append('file', selectedFile)
formData.append('title', title.trim())
if (notes.trim()) formData.append('notes', notes.trim())
// Simulate progress
const progressInterval = setInterval(() => {
setUploadProgress(prev => Math.min(prev + 10, 90))
}, 200)
const response = await fetch(`/api/campaigns/${campaignId}/briefings/upload`, {
method: 'POST',
body: formData,
})
clearInterval(progressInterval)
setUploadProgress(100)
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Upload failed')
}
toast({
title: 'Briefing uppladdad',
description: title,
})
} else {
// Create link or text briefing
const createData = {
briefing_type: briefingType,
title: title.trim(),
content: briefingType === 'link' ? linkUrl.trim() : null,
text_content: briefingType === 'text' ? textContent.trim() : null,
notes: notes.trim() || null,
}
const response = await fetch(`/api/campaigns/${campaignId}/briefings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(createData),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Create failed')
}
toast({
title: 'Briefing tillagd',
description: title,
})
}
onSuccess()
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Något gick fel',
variant: 'destructive',
})
} finally {
setIsSubmitting(false)
setUploadProgress(0)
}
}
const getTypeIcon = (type: BriefingType) => {
switch (type) {
case 'pdf':
return <FileText className="h-4 w-4" />
case 'link':
return <Link2 className="h-4 w-4" />
case 'text':
return <AlignLeft className="h-4 w-4" />
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Type selector - only show when creating new */}
{!isEditing && (
<div>
<Label>Typ av briefing</Label>
<div className="grid grid-cols-3 gap-2 mt-1.5">
{(['link', 'text', 'pdf'] as BriefingType[]).map(type => (
<button
key={type}
type="button"
onClick={() => setBriefingType(type)}
className={cn(
'flex items-center justify-center gap-2 p-3 border rounded-lg transition-colors',
briefingType === type
? 'border-primary bg-primary/10 text-primary'
: 'border-muted hover:border-primary/50 hover:bg-muted/50'
)}
>
{getTypeIcon(type)}
<span className="text-sm font-medium">{BRIEFING_TYPE_LABELS[type]}</span>
</button>
))}
</div>
</div>
)}
{/* Title */}
<div>
<Label htmlFor="title">Titel *</Label>
<Input
id="title"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="T.ex. Kampanjbrief Q1 2025"
disabled={isSubmitting}
/>
</div>
{/* Type-specific content */}
{briefingType === 'link' && (
<div>
<Label htmlFor="link">URL *</Label>
<Input
id="link"
type="url"
value={linkUrl}
onChange={e => setLinkUrl(e.target.value)}
placeholder="https://docs.google.com/... eller https://www.canva.com/..."
disabled={isSubmitting}
/>
<p className="text-xs text-muted-foreground mt-1">
Lankar till Google Docs, Canva, Dropbox, etc.
</p>
</div>
)}
{briefingType === 'text' && (
<div className="space-y-3">
<div>
<Label htmlFor="text">Textinnehall *</Label>
<Textarea
id="text"
value={textContent}
onChange={e => setTextContent(e.target.value)}
placeholder="Klistra in text fran e-post eller annat..."
rows={8}
disabled={isSubmitting}
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleSummarize}
disabled={isSummarizing || !textContent.trim()}
>
{isSummarizing ? (
<>
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
Sammanfattar...
</>
) : (
<>
<Sparkles className="h-4 w-4 mr-1" />
Sammanfatta med AI
</>
)}
</Button>
{aiSummary && (
<div className="p-4 border rounded-lg bg-muted/30">
<div className="flex items-center justify-between mb-2">
<Label className="text-sm font-medium">AI-sammanfattning</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setAiSummary('')}
>
<X className="h-3 w-3" />
</Button>
</div>
<div className="text-sm whitespace-pre-wrap text-muted-foreground">
{aiSummary}
</div>
</div>
)}
</div>
)}
{briefingType === 'pdf' && !isEditing && (
<div>
<Label>PDF-fil *</Label>
<input
ref={fileInputRef}
type="file"
accept=".pdf"
onChange={handleFileSelect}
className="hidden"
/>
{selectedFile ? (
<div className="flex items-center gap-3 p-3 border rounded-lg bg-muted/50 mt-1.5">
<FileText className="h-8 w-8 text-red-500" />
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{selectedFile.name}</p>
<p className="text-sm text-muted-foreground">
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={clearFileSelection}
disabled={isSubmitting}
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="w-full p-6 border-2 border-dashed rounded-lg hover:border-primary hover:bg-muted/50 transition-colors mt-1.5"
>
<div className="flex flex-col items-center gap-2">
<Upload className="h-8 w-8 text-muted-foreground" />
<p className="font-medium">Klicka for att valja PDF-fil</p>
<p className="text-sm text-muted-foreground">Max 10MB</p>
</div>
</button>
)}
</div>
)}
{/* Notes */}
<div>
<Label htmlFor="notes">Anteckningar (valfritt)</Label>
<Textarea
id="notes"
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder="Egna anteckningar om briefingen..."
rows={2}
disabled={isSubmitting}
/>
</div>
{/* Upload progress */}
{isSubmitting && briefingType === 'pdf' && uploadProgress > 0 && (
<div className="space-y-2">
<Progress value={uploadProgress} />
<p className="text-sm text-muted-foreground text-center">
Laddar upp... {uploadProgress}%
</p>
</div>
)}
{/* Actions */}
<div className="flex gap-2 justify-end">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
>
Avbryt
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{isEditing ? 'Sparar...' : 'Laddar upp...'}
</>
) : isEditing ? (
'Spara andringar'
) : (
'Lagg till briefing'
)}
</Button>
</div>
</form>
)
}
+359
View File
@@ -0,0 +1,359 @@
'use client'
import { useState, useRef } from 'react'
import { Briefing, BriefingType, BRIEFING_TYPE_LABELS } from '@/types'
import { BriefingForm } from './BriefingForm'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import {
FileText,
Link2,
AlignLeft,
Download,
Trash2,
Edit2,
Plus,
ChevronUp,
ExternalLink,
ChevronDown,
Loader2,
} from 'lucide-react'
import { cn } from '@/lib/utils'
interface BriefingListProps {
campaignId: string
briefings: Briefing[]
onUpdate: () => void
}
export function BriefingList({ campaignId, briefings, onUpdate }: BriefingListProps) {
const { toast } = useToast()
const [showForm, setShowForm] = useState(false)
const [editingBriefing, setEditingBriefing] = useState<Briefing | null>(null)
const [downloadingId, setDownloadingId] = useState<string | null>(null)
const [expandedTextId, setExpandedTextId] = useState<string | null>(null)
const handleDownload = async (briefing: Briefing) => {
if (briefing.briefing_type !== 'pdf') return
setDownloadingId(briefing.id)
try {
const response = await fetch(`/api/briefings/${briefing.id}/download`)
if (!response.ok) {
throw new Error('Download failed')
}
const data = await response.json()
window.open(data.url, '_blank')
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte ladda ner filen',
variant: 'destructive',
})
} finally {
setDownloadingId(null)
}
}
const handleDelete = async (briefing: Briefing) => {
if (!confirm(`Ta bort "${briefing.title}"?`)) return
try {
const response = await fetch(`/api/briefings/${briefing.id}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Delete failed')
}
toast({
title: 'Briefing borttagen',
description: briefing.title,
})
onUpdate()
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte ta bort briefingen',
variant: 'destructive',
})
}
}
const handleOpenLink = (briefing: Briefing) => {
if (briefing.briefing_type !== 'link' || !briefing.content) return
window.open(briefing.content, '_blank', 'noopener,noreferrer')
}
const handleEdit = (briefing: Briefing) => {
setEditingBriefing(briefing)
setShowForm(true)
}
const handleFormClose = () => {
setShowForm(false)
setEditingBriefing(null)
}
const handleFormSuccess = () => {
handleFormClose()
onUpdate()
}
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('sv-SE', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
const formatFileSize = (bytes: number | null) => {
if (!bytes) return ''
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
const getTypeIcon = (type: BriefingType) => {
switch (type) {
case 'pdf':
return <FileText className="h-5 w-5 text-red-500" />
case 'link':
return <Link2 className="h-5 w-5 text-blue-500" />
case 'text':
return <AlignLeft className="h-5 w-5 text-green-500" />
}
}
const getTypeBadgeColor = (type: BriefingType) => {
switch (type) {
case 'pdf':
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
case 'link':
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
case 'text':
return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
}
}
const truncateUrl = (url: string, maxLength: number = 40) => {
if (url.length <= maxLength) return url
return url.substring(0, maxLength) + '...'
}
// Sort by created date (newest first)
const sortedBriefings = [...briefings].sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium">
Briefing
<span className="text-muted-foreground ml-2">
({briefings.length})
</span>
</h3>
<Button
size="sm"
variant={showForm ? 'secondary' : 'default'}
onClick={() => {
if (showForm) {
handleFormClose()
} else {
setShowForm(true)
}
}}
>
{showForm ? (
<>
<ChevronUp className="h-4 w-4 mr-1" />
Stang
</>
) : (
<>
<Plus className="h-4 w-4 mr-1" />
Lagg till
</>
)}
</Button>
</div>
{/* Form section */}
{showForm && (
<div className="p-4 border rounded-lg bg-muted/30">
<BriefingForm
campaignId={campaignId}
briefing={editingBriefing}
onSuccess={handleFormSuccess}
onCancel={handleFormClose}
/>
</div>
)}
{/* Briefing list */}
{sortedBriefings.length > 0 ? (
<div className="space-y-2">
{sortedBriefings.map(briefing => (
<div
key={briefing.id}
className="p-3 border rounded-lg hover:bg-muted/50 transition-colors"
>
<div className="flex items-start gap-3">
<div className="mt-0.5">
{getTypeIcon(briefing.briefing_type)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium">{briefing.title}</p>
<Badge
variant="secondary"
className={cn('text-xs', getTypeBadgeColor(briefing.briefing_type))}
>
{BRIEFING_TYPE_LABELS[briefing.briefing_type]}
</Badge>
</div>
{/* Type-specific content display */}
{briefing.briefing_type === 'pdf' && briefing.filename && (
<p className="text-sm text-muted-foreground mt-1">
{briefing.filename}
{briefing.file_size && (
<span className="ml-2">({formatFileSize(briefing.file_size)})</span>
)}
</p>
)}
{briefing.briefing_type === 'link' && briefing.content && (
<button
onClick={() => handleOpenLink(briefing)}
className="text-sm text-blue-600 hover:underline flex items-center gap-1 mt-1"
>
{truncateUrl(briefing.content)}
<ExternalLink className="h-3 w-3" />
</button>
)}
{briefing.briefing_type === 'text' && briefing.text_content && (
<div className="mt-2">
<div
className={cn(
'text-sm text-muted-foreground bg-muted/50 rounded p-2 whitespace-pre-wrap',
expandedTextId !== briefing.id && 'max-h-20 overflow-hidden'
)}
>
{briefing.text_content}
</div>
{briefing.text_content.length > 200 && (
<button
onClick={() =>
setExpandedTextId(
expandedTextId === briefing.id ? null : briefing.id
)
}
className="text-xs text-primary hover:underline mt-1 flex items-center gap-1"
>
{expandedTextId === briefing.id ? (
<>
<ChevronUp className="h-3 w-3" />
Visa mindre
</>
) : (
<>
<ChevronDown className="h-3 w-3" />
Visa mer
</>
)}
</button>
)}
</div>
)}
{briefing.notes && (
<p className="text-sm text-muted-foreground italic mt-1">
{briefing.notes}
</p>
)}
<p className="text-xs text-muted-foreground mt-1">
Tillagd: {formatDate(briefing.created_at)}
</p>
</div>
<div className="flex gap-1 shrink-0">
{briefing.briefing_type === 'pdf' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleDownload(briefing)}
disabled={downloadingId === briefing.id}
title="Ladda ner"
>
{downloadingId === briefing.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
</Button>
)}
{briefing.briefing_type === 'link' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenLink(briefing)}
title="Oppna lank"
>
<ExternalLink className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(briefing)}
title="Redigera"
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(briefing)}
className="text-destructive hover:text-destructive"
title="Ta bort"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
))}
</div>
) : !showForm ? (
<div className="text-center py-8 text-muted-foreground">
<FileText className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>Ingen briefing tillagd</p>
<p className="text-sm mt-1">
Lagg till PDF-dokument, lankar eller text fran uppdragsgivaren
</p>
<Button
variant="outline"
size="sm"
className="mt-3"
onClick={() => setShowForm(true)}
>
<Plus className="h-4 w-4 mr-1" />
Lagg till briefing
</Button>
</div>
) : null}
</div>
)
}
+175
View File
@@ -0,0 +1,175 @@
'use client'
import { Campaign, CAMPAIGN_TYPE_LABELS } from '@/types'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { CampaignStatusBadge } from './CampaignStatusBadge'
import { cn } from '@/lib/utils'
import {
Building2,
Calendar,
FileText,
Package,
ChevronRight,
Banknote,
Clock,
AlertCircle,
} from 'lucide-react'
import Link from 'next/link'
interface CampaignCardProps {
campaign: Campaign
onClick?: () => void
}
export function CampaignCard({ campaign, onClick }: CampaignCardProps) {
const formatCurrency = (amount: number | null) => {
if (!amount) return '-'
return new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency: campaign.currency || 'SEK',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount)
}
const formatDate = (date: string | null) => {
if (!date) return '-'
return new Date(date).toLocaleDateString('sv-SE', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
// Deliverable progress
const totalDeliverables = campaign.deliverables?.length || 0
const completedDeliverables = campaign.deliverables?.filter(d =>
['approved', 'published'].includes(d.status)
).length || 0
const pendingDeliverables = totalDeliverables - completedDeliverables
const progressPercent = totalDeliverables > 0 ? Math.round((completedDeliverables / totalDeliverables) * 100) : 0
// Find next due date
const nextDueDate = campaign.deliverables
?.filter(d => d.due_date && !['approved', 'published'].includes(d.status))
.sort((a, b) => new Date(a.due_date!).getTime() - new Date(b.due_date!).getTime())[0]?.due_date
// Check if next deadline is soon (within 3 days)
const isDeadlineSoon = nextDueDate && (() => {
const daysUntil = Math.round((new Date(nextDueDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
return daysUntil <= 3 && daysUntil >= 0
})()
// Check for active exclusivities
const hasActiveExclusivity = campaign.exclusivities?.some(e => {
const today = new Date().toISOString().split('T')[0]
return e.start_date <= today && e.end_date >= today
})
// Customer initial for avatar
const customerInitial = campaign.customer?.name?.charAt(0)?.toUpperCase() || '?'
return (
<Link href={`/campaigns/${campaign.id}`}>
<Card
className={cn(
'hover-lift cursor-pointer',
campaign.status === 'cancelled' && 'opacity-60'
)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-3 flex-1 min-w-0">
{/* Customer avatar */}
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-sm font-medium text-primary">
{customerInitial}
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-lg truncate">{campaign.name || campaign.customer?.name || 'Namnlöst samarbete'}</CardTitle>
{campaign.customer && (
<p className="text-sm text-muted-foreground mt-0.5 truncate">
{campaign.customer.name}
</p>
)}
</div>
</div>
<div className="flex flex-col items-end gap-1">
<CampaignStatusBadge status={campaign.status} />
<Badge variant="secondary" className="text-xs">
{CAMPAIGN_TYPE_LABELS[campaign.campaign_type]}
</Badge>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
{/* Deliverable progress bar */}
{totalDeliverables > 0 && (
<div className="mb-4">
<div className="flex items-center justify-between text-xs mb-1.5">
<span className="text-muted-foreground">Innehåll</span>
<span className="font-medium">{completedDeliverables}/{totalDeliverables} klara</span>
</div>
<Progress value={progressPercent} className="h-1.5" />
</div>
)}
<div className="grid grid-cols-2 gap-3 text-sm">
{/* Value */}
<div className="flex items-center gap-2 text-muted-foreground">
<Banknote className="h-4 w-4" />
<span className="font-medium text-foreground tabular-nums">
{formatCurrency(campaign.total_value)}
</span>
</div>
{/* Date range */}
<div className="flex items-center gap-2 text-muted-foreground">
<Calendar className="h-4 w-4" />
<span>
{campaign.start_date ? formatDate(campaign.start_date) : '-'}
</span>
</div>
{/* Next deadline - more prominent if soon */}
{nextDueDate && (
<div className={cn(
"flex items-center gap-2",
isDeadlineSoon ? "text-warning-foreground" : "text-muted-foreground"
)}>
{isDeadlineSoon ? (
<AlertCircle className="h-4 w-4" />
) : (
<Clock className="h-4 w-4" />
)}
<span className={isDeadlineSoon ? 'font-medium' : ''}>
{formatDate(nextDueDate)}
</span>
</div>
)}
{/* Contracts */}
{campaign.contracts && campaign.contracts.length > 0 && (
<div className="flex items-center gap-2 text-muted-foreground">
<FileText className="h-4 w-4" />
<span>{campaign.contracts.length} avtal</span>
</div>
)}
{/* Exclusivity indicator */}
{hasActiveExclusivity && (
<Badge variant="outline" className="text-xs w-fit">
Aktiv exklusivitet
</Badge>
)}
</div>
<div className="flex justify-end mt-4">
<ChevronRight className="h-5 w-5 text-muted-foreground" />
</div>
</CardContent>
</Card>
</Link>
)
}
+514
View File
@@ -0,0 +1,514 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import {
Campaign,
CAMPAIGN_STATUS_LABELS,
CAMPAIGN_TYPE_LABELS,
BILLING_FREQUENCY_LABELS,
CampaignStatus
} from '@/types'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
import { CampaignStatusBadge } from './CampaignStatusBadge'
import { DeliverableList } from './DeliverableList'
import { ExclusivityList } from './ExclusivityList'
import { ContractList } from './ContractList'
import { BriefingList } from './BriefingList'
import { CampaignInvoiceSummary } from './CampaignInvoiceSummary'
import { cn } from '@/lib/utils'
import {
Building2,
Calendar,
Banknote,
Edit2,
Trash2,
ArrowLeft,
ChevronRight,
Clock,
FileText,
Package,
Shield,
Receipt,
BookOpen,
Check,
Circle,
} from 'lucide-react'
import Link from 'next/link'
interface CampaignDetailProps {
campaign: Campaign
onUpdate: () => void
onEdit: () => void
}
const STATUS_TRANSITIONS: Record<CampaignStatus, CampaignStatus[]> = {
negotiation: ['contracted', 'cancelled'],
contracted: ['active', 'cancelled'],
active: ['delivered', 'cancelled'],
delivered: ['invoiced', 'active'],
invoiced: ['completed', 'delivered'],
completed: ['invoiced'],
cancelled: ['negotiation'],
}
const LIFECYCLE_STEPS: { status: CampaignStatus; label: string }[] = [
{ status: 'negotiation', label: 'Förhandling' },
{ status: 'contracted', label: 'Avtalat' },
{ status: 'active', label: 'Aktivt' },
{ status: 'delivered', label: 'Levererat' },
{ status: 'invoiced', label: 'Fakturerat' },
{ status: 'completed', label: 'Klart' },
]
function getStepState(stepStatus: CampaignStatus, currentStatus: CampaignStatus): 'completed' | 'current' | 'upcoming' {
const stepIndex = LIFECYCLE_STEPS.findIndex(s => s.status === stepStatus)
const currentIndex = LIFECYCLE_STEPS.findIndex(s => s.status === currentStatus)
if (currentStatus === 'cancelled') return 'upcoming'
if (stepIndex < currentIndex) return 'completed'
if (stepIndex === currentIndex) return 'current'
return 'upcoming'
}
export function CampaignDetail({ campaign, onUpdate, onEdit }: CampaignDetailProps) {
const router = useRouter()
const { toast } = useToast()
const [isUpdating, setIsUpdating] = useState(false)
const formatCurrency = (amount: number | null) => {
if (!amount) return '-'
return new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency: campaign.currency || 'SEK',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount)
}
const formatDate = (date: string | null) => {
if (!date) return '-'
return new Date(date).toLocaleDateString('sv-SE', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
const handleStatusChange = async (newStatus: CampaignStatus) => {
setIsUpdating(true)
try {
const response = await fetch(`/api/campaigns/${campaign.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus }),
})
if (!response.ok) {
throw new Error('Failed to update status')
}
toast({
title: 'Status uppdaterad',
description: CAMPAIGN_STATUS_LABELS[newStatus],
})
onUpdate()
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte uppdatera status',
variant: 'destructive',
})
} finally {
setIsUpdating(false)
}
}
const handleDelete = async () => {
if (!confirm(`Ta bort samarbetet "${campaign.name}"? Detta kan inte ångras.`)) return
try {
const response = await fetch(`/api/campaigns/${campaign.id}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Failed to delete')
}
toast({
title: 'Samarbete borttaget',
description: campaign.name,
})
router.push('/campaigns')
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte ta bort samarbetet',
variant: 'destructive',
})
}
}
const availableTransitions = STATUS_TRANSITIONS[campaign.status] || []
// Count items
const deliverableCount = campaign.deliverables?.length || 0
const exclusivityCount = campaign.exclusivities?.length || 0
const contractCount = campaign.contracts?.length || 0
const invoiceCount = campaign.invoices?.length || 0
const briefingCount = campaign.briefings?.length || 0
// Deliverable progress
const completedDeliverables = campaign.deliverables?.filter(d =>
['approved', 'published'].includes(d.status)
).length || 0
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<Link
href="/campaigns"
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 mb-2"
>
<ArrowLeft className="h-4 w-4" />
Tillbaka till samarbeten
</Link>
<h1 className="text-3xl font-bold tracking-tight">{campaign.name}</h1>
{(campaign.customer || campaign.brand_name) && (
<p className="text-muted-foreground flex items-center gap-1 mt-1">
{campaign.brand_name && (
<span className="flex items-center gap-1">
{campaign.brand_name}
</span>
)}
{campaign.brand_name && campaign.customer && (
<ChevronRight className="h-4 w-4" />
)}
{campaign.customer && (
<span className="flex items-center gap-1">
<Building2 className="h-4 w-4" />
{campaign.customer.name}
</span>
)}
</p>
)}
</div>
<div className="flex items-center gap-2">
{/* Prominent "Create invoice" button when status is delivered */}
{campaign.status === 'delivered' && (
<Button
onClick={() => router.push(`/invoices/new?campaign_id=${campaign.id}&customer_id=${campaign.customer_id}`)}
className="bg-accent hover:bg-accent/90 text-accent-foreground"
>
<Receipt className="h-4 w-4 mr-1" />
Skapa faktura
</Button>
)}
<Button variant="outline" size="sm" onClick={onEdit}>
<Edit2 className="h-4 w-4 mr-1" />
Redigera
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDelete}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4 mr-1" />
Ta bort
</Button>
</div>
</div>
{/* Status and quick info */}
<div className="flex flex-wrap items-center gap-3">
<CampaignStatusBadge status={campaign.status} />
<Badge variant="secondary">{CAMPAIGN_TYPE_LABELS[campaign.campaign_type]}</Badge>
{availableTransitions.length > 0 && (
<Select
value=""
onValueChange={(v) => handleStatusChange(v as CampaignStatus)}
disabled={isUpdating}
>
<SelectTrigger className="w-[180px] h-8 text-sm">
<SelectValue placeholder="Ändra status..." />
</SelectTrigger>
<SelectContent>
{availableTransitions.map(status => (
<SelectItem key={status} value={status}>
{CAMPAIGN_STATUS_LABELS[status]}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Campaign lifecycle timeline */}
{campaign.status !== 'cancelled' && (
<Card>
<CardContent className="py-4">
<div className="flex items-center justify-between">
{LIFECYCLE_STEPS.map((step, index) => {
const state = getStepState(step.status, campaign.status)
return (
<div key={step.status} className="flex items-center flex-1">
<div className="flex flex-col items-center">
<div className={cn(
"w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium transition-colors",
state === 'completed' && "bg-success text-success-foreground",
state === 'current' && "bg-primary text-primary-foreground ring-4 ring-primary/20",
state === 'upcoming' && "bg-muted text-muted-foreground"
)}>
{state === 'completed' ? (
<Check className="h-4 w-4" />
) : (
<Circle className="h-3 w-3" />
)}
</div>
<span className={cn(
"text-[10px] mt-1.5 text-center leading-tight whitespace-nowrap",
state === 'current' ? "text-foreground font-medium" : "text-muted-foreground"
)}>
{step.label}
</span>
</div>
{index < LIFECYCLE_STEPS.length - 1 && (
<div className={cn(
"flex-1 h-0.5 mx-1",
state === 'completed' ? "bg-success" : "bg-muted"
)} />
)}
</div>
)
})}
</div>
</CardContent>
</Card>
)}
{/* Overview cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card className="hover-lift">
<CardContent className="pt-4">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Banknote className="h-4 w-4" />
<span className="text-sm">Arvode</span>
</div>
<p className="text-2xl font-bold tabular-nums">{formatCurrency(campaign.total_value)}</p>
{campaign.vat_included && (
<p className="text-xs text-muted-foreground">inkl. moms</p>
)}
</CardContent>
</Card>
<Card className="hover-lift">
<CardContent className="pt-4">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Calendar className="h-4 w-4" />
<span className="text-sm">Publiceringsdatum</span>
</div>
<p className="font-medium">
{campaign.publication_date ? formatDate(campaign.publication_date) : 'Ej satt'}
</p>
{campaign.draft_deadline && (
<p className="text-sm text-muted-foreground">
Utkast: {formatDate(campaign.draft_deadline)}
</p>
)}
</CardContent>
</Card>
<Card className="hover-lift">
<CardContent className="pt-4">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Package className="h-4 w-4" />
<span className="text-sm">Innehåll</span>
</div>
<p className="font-medium">
{deliverableCount > 0 ? (
<span>{completedDeliverables}/{deliverableCount} klara</span>
) : (
<span className="text-muted-foreground">Inga</span>
)}
</p>
{deliverableCount > 0 && (
<div className="mt-2 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-success rounded-full transition-all"
style={{ width: `${Math.round((completedDeliverables / deliverableCount) * 100)}%` }}
/>
</div>
)}
</CardContent>
</Card>
<Card className="hover-lift">
<CardContent className="pt-4">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<FileText className="h-4 w-4" />
<span className="text-sm">Avtal</span>
</div>
<p className="font-medium">
{campaign.contract_signed_at
? formatDate(campaign.contract_signed_at)
: 'Ej signerat'}
</p>
{contractCount > 0 && (
<p className="text-sm text-muted-foreground">
{contractCount} dokument
</p>
)}
</CardContent>
</Card>
</div>
{/* Description */}
{campaign.description && (
<Card>
<CardContent className="pt-4">
<p className="text-muted-foreground whitespace-pre-wrap text-balance">
{campaign.description}
</p>
</CardContent>
</Card>
)}
{/* Tabs for different sections */}
<Tabs defaultValue="deliverables" className="space-y-4">
<TabsList>
<TabsTrigger value="deliverables" className="gap-2">
<Package className="h-4 w-4" />
Innehåll
{deliverableCount > 0 && (
<Badge variant="secondary" className="ml-1">{deliverableCount}</Badge>
)}
</TabsTrigger>
<TabsTrigger value="exclusivities" className="gap-2">
<Shield className="h-4 w-4" />
Exklusivitet
{exclusivityCount > 0 && (
<Badge variant="secondary" className="ml-1">{exclusivityCount}</Badge>
)}
</TabsTrigger>
<TabsTrigger value="briefings" className="gap-2">
<BookOpen className="h-4 w-4" />
Briefing
{briefingCount > 0 && (
<Badge variant="secondary" className="ml-1">{briefingCount}</Badge>
)}
</TabsTrigger>
<TabsTrigger value="contracts" className="gap-2">
<FileText className="h-4 w-4" />
Avtal
{contractCount > 0 && (
<Badge variant="secondary" className="ml-1">{contractCount}</Badge>
)}
</TabsTrigger>
<TabsTrigger value="invoices" className="gap-2">
<Receipt className="h-4 w-4" />
Fakturor
{invoiceCount > 0 && (
<Badge variant="secondary" className="ml-1">{invoiceCount}</Badge>
)}
</TabsTrigger>
</TabsList>
<TabsContent value="deliverables">
<Card>
<CardContent className="pt-6">
<DeliverableList
campaignId={campaign.id}
deliverables={campaign.deliverables || []}
onUpdate={onUpdate}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="exclusivities">
<Card>
<CardContent className="pt-6">
<ExclusivityList
campaignId={campaign.id}
exclusivities={campaign.exclusivities || []}
onUpdate={onUpdate}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="briefings">
<Card>
<CardContent className="pt-6">
<BriefingList
campaignId={campaign.id}
briefings={campaign.briefings || []}
onUpdate={onUpdate}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="contracts">
<Card>
<CardContent className="pt-6">
<ContractList
campaignId={campaign.id}
contracts={campaign.contracts || []}
onUpdate={onUpdate}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="invoices">
<Card>
<CardContent className="pt-6">
<CampaignInvoiceSummary
campaignId={campaign.id}
invoices={campaign.invoices || []}
totalValue={campaign.total_value}
currency={campaign.currency}
onCreateInvoice={() => {
router.push(`/invoices/new?campaign_id=${campaign.id}&customer_id=${campaign.customer_id}`)
}}
/>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Notes */}
{campaign.notes && (
<Card>
<CardHeader>
<CardTitle className="text-base">Anteckningar</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground whitespace-pre-wrap">
{campaign.notes}
</p>
</CardContent>
</Card>
)}
</div>
)
}
+392
View File
@@ -0,0 +1,392 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { useToast } from '@/components/ui/use-toast'
import type {
Campaign,
Customer,
CampaignType,
BillingFrequency,
CreateCampaignInput
} from '@/types'
import {
CAMPAIGN_TYPE_LABELS,
BILLING_FREQUENCY_LABELS,
} from '@/types'
interface CampaignFormProps {
open: boolean
onOpenChange: (open: boolean) => void
initialData?: Partial<Campaign>
customers: Customer[]
onSuccess?: (campaign: Campaign) => void
}
const CURRENCIES = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']
export function CampaignForm({
open,
onOpenChange,
initialData,
customers,
onSuccess
}: CampaignFormProps) {
const router = useRouter()
const { toast } = useToast()
const [isLoading, setIsLoading] = useState(false)
const [formData, setFormData] = useState<CreateCampaignInput>({
name: '',
description: '',
customer_id: '',
brand_name: '',
campaign_type: 'influencer',
total_value: undefined,
currency: 'SEK',
vat_included: false,
payment_terms: 30,
billing_frequency: undefined,
publication_date: '',
draft_deadline: '',
notes: '',
})
useEffect(() => {
if (open) {
setFormData({
name: initialData?.name || '',
description: initialData?.description || '',
customer_id: initialData?.customer_id || '',
brand_name: initialData?.brand_name || '',
campaign_type: initialData?.campaign_type || 'influencer',
total_value: initialData?.total_value || undefined,
currency: initialData?.currency || 'SEK',
vat_included: initialData?.vat_included || false,
payment_terms: initialData?.payment_terms || 30,
billing_frequency: initialData?.billing_frequency || undefined,
publication_date: initialData?.publication_date || '',
draft_deadline: initialData?.draft_deadline || '',
notes: initialData?.notes || '',
})
}
}, [open, initialData])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!formData.name) {
toast({ title: 'Namn krävs', variant: 'destructive' })
return
}
setIsLoading(true)
try {
const url = initialData?.id
? `/api/campaigns/${initialData.id}`
: '/api/campaigns'
const method = initialData?.id ? 'PATCH' : 'POST'
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...formData,
customer_id: formData.customer_id || null,
brand_name: formData.brand_name || null,
total_value: formData.total_value || null,
payment_terms: formData.payment_terms || null,
billing_frequency: formData.billing_frequency || null,
publication_date: formData.publication_date || null,
draft_deadline: formData.draft_deadline || null,
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to save campaign')
}
const { data } = await response.json()
toast({
title: initialData?.id ? 'Samarbete uppdaterat' : 'Samarbete skapat',
description: formData.name,
})
onOpenChange(false)
if (onSuccess) {
onSuccess(data)
} else if (!initialData?.id) {
router.push(`/campaigns/${data.id}`)
}
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Något gick fel',
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{initialData?.id ? 'Redigera samarbete' : 'Nytt samarbete'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Info */}
<div className="space-y-4">
<div>
<Label htmlFor="name">Namn på samarbete *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="T.ex. Sommarkampanj 2025"
required
/>
</div>
<div>
<Label htmlFor="description">Beskrivning</Label>
<Textarea
id="description"
value={formData.description || ''}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Kort beskrivning av kampanjen..."
rows={2}
/>
</div>
<div>
<Label htmlFor="brand_name">Varumärke</Label>
<Input
id="brand_name"
value={formData.brand_name || ''}
onChange={(e) => setFormData({ ...formData, brand_name: e.target.value })}
placeholder="Varumärket du skapar innehåll för"
/>
<p className="text-xs text-muted-foreground mt-1">
Varumärket som samarbetet gäller
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="campaign_type">Typ</Label>
<Select
value={formData.campaign_type}
onValueChange={(v) => setFormData({ ...formData, campaign_type: v as CampaignType })}
>
<SelectTrigger>
<SelectValue placeholder="Välj typ" />
</SelectTrigger>
<SelectContent>
{Object.entries(CAMPAIGN_TYPE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="customer_id">Byrå / Uppdragsgivare</Label>
<Select
value={formData.customer_id || ''}
onValueChange={(v) => setFormData({
...formData,
customer_id: v,
})}
>
<SelectTrigger>
<SelectValue placeholder="Välj byrå/uppdragsgivare" />
</SelectTrigger>
<SelectContent>
{customers.map((customer) => (
<SelectItem key={customer.id} value={customer.id}>
{customer.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1">
Den som faktureras (byrå eller direktkund)
</p>
</div>
</div>
</div>
{/* Financial */}
<div className="space-y-4 pt-4 border-t">
<h4 className="font-medium">Ekonomi</h4>
<div className="grid grid-cols-3 gap-4">
<div>
<Label htmlFor="total_value">Arvode</Label>
<Input
id="total_value"
type="number"
value={formData.total_value || ''}
onChange={(e) => setFormData({
...formData,
total_value: e.target.value ? parseFloat(e.target.value) : undefined
})}
placeholder="0"
/>
</div>
<div>
<Label htmlFor="currency">Valuta</Label>
<Select
value={formData.currency}
onValueChange={(v) => setFormData({ ...formData, currency: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CURRENCIES.map((c) => (
<SelectItem key={c} value={c}>{c}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end gap-2">
<div className="flex items-center gap-2">
<Switch
id="vat_included"
checked={formData.vat_included}
onCheckedChange={(v) => setFormData({ ...formData, vat_included: v })}
/>
<Label htmlFor="vat_included" className="font-normal">Inkl. moms</Label>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="payment_terms">Betalningsvillkor (dagar)</Label>
<Input
id="payment_terms"
type="number"
value={formData.payment_terms || ''}
onChange={(e) => setFormData({
...formData,
payment_terms: e.target.value ? parseInt(e.target.value) : undefined
})}
placeholder="30"
/>
</div>
<div>
<Label htmlFor="billing_frequency">Faktureringsmodell</Label>
<Select
value={formData.billing_frequency || ''}
onValueChange={(v) => setFormData({
...formData,
billing_frequency: v as BillingFrequency || undefined
})}
>
<SelectTrigger>
<SelectValue placeholder="Välj..." />
</SelectTrigger>
<SelectContent>
{Object.entries(BILLING_FREQUENCY_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Dates */}
<div className="space-y-4 pt-4 border-t">
<h4 className="font-medium">Datum</h4>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="publication_date">Publiceringsdatum</Label>
<Input
id="publication_date"
type="date"
value={formData.publication_date || ''}
onChange={(e) => setFormData({ ...formData, publication_date: e.target.value })}
/>
<p className="text-xs text-muted-foreground mt-1">
När innehållet ska publiceras
</p>
</div>
<div>
<Label htmlFor="draft_deadline">Utkastdeadline</Label>
<Input
id="draft_deadline"
type="date"
value={formData.draft_deadline || ''}
onChange={(e) => setFormData({ ...formData, draft_deadline: e.target.value })}
/>
<p className="text-xs text-muted-foreground mt-1">
När utkast ska skickas (valfritt)
</p>
</div>
</div>
</div>
{/* Notes */}
<div className="pt-4 border-t">
<Label htmlFor="notes">Anteckningar</Label>
<Textarea
id="notes"
value={formData.notes || ''}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
placeholder="Interna anteckningar..."
rows={3}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Avbryt
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Sparar...' : initialData?.id ? 'Spara' : 'Skapa samarbete'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,195 @@
'use client'
import { Invoice, PAYMENT_STATUS_LABELS } from '@/types'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import {
FileText,
Plus,
ExternalLink,
CheckCircle,
Clock,
AlertTriangle
} from 'lucide-react'
import Link from 'next/link'
import { cn } from '@/lib/utils'
interface CampaignInvoiceSummaryProps {
campaignId: string
invoices: Invoice[]
totalValue: number | null
currency: string
onCreateInvoice?: () => void
}
export function CampaignInvoiceSummary({
campaignId,
invoices,
totalValue,
currency,
onCreateInvoice
}: CampaignInvoiceSummaryProps) {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency: currency || 'SEK',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount)
}
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('sv-SE', {
day: 'numeric',
month: 'short',
})
}
// Calculate totals
const totalInvoiced = invoices.reduce((sum, inv) => sum + inv.total, 0)
const totalPaid = invoices
.filter(inv => inv.status === 'paid')
.reduce((sum, inv) => sum + inv.total, 0)
const invoicedPercentage = totalValue ? (totalInvoiced / totalValue) * 100 : 0
const paidPercentage = totalValue ? (totalPaid / totalValue) * 100 : 0
// Group by status
const paidInvoices = invoices.filter(inv => inv.status === 'paid')
const sentInvoices = invoices.filter(inv => inv.status === 'sent')
const overdueInvoices = invoices.filter(inv => inv.status === 'overdue')
const draftInvoices = invoices.filter(inv => inv.status === 'draft')
const getStatusIcon = (status: string) => {
switch (status) {
case 'paid':
return <CheckCircle className="h-4 w-4 text-success" />
case 'overdue':
return <AlertTriangle className="h-4 w-4 text-destructive" />
default:
return <Clock className="h-4 w-4 text-muted-foreground" />
}
}
const getStatusBadge = (status: string) => {
switch (status) {
case 'paid':
return <Badge variant="outline" className="bg-green-100 text-green-700 border-green-200">Betald</Badge>
case 'sent':
return <Badge variant="outline" className="bg-blue-100 text-blue-700 border-blue-200">Skickad</Badge>
case 'overdue':
return <Badge variant="destructive">Förfallen</Badge>
case 'draft':
return <Badge variant="secondary">Utkast</Badge>
default:
return <Badge variant="outline">{status}</Badge>
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium">
Fakturor
<span className="text-muted-foreground ml-2">
({invoices.length})
</span>
</h3>
{onCreateInvoice && (
<Button size="sm" onClick={onCreateInvoice}>
<Plus className="h-4 w-4 mr-1" />
Skapa faktura
</Button>
)}
</div>
{/* Progress summary */}
{totalValue && totalValue > 0 && (
<Card>
<CardContent className="pt-4">
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Fakturerat</span>
<span className="font-medium">
{formatCurrency(totalInvoiced)} / {formatCurrency(totalValue)}
</span>
</div>
<Progress value={invoicedPercentage} className="h-2" />
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Betalt</span>
<span className="font-medium text-success">
{formatCurrency(totalPaid)}
</span>
</div>
<Progress value={paidPercentage} className="h-2 bg-muted [&>div]:bg-success" />
{totalValue > totalInvoiced && (
<p className="text-sm text-muted-foreground">
Kvar att fakturera: {formatCurrency(totalValue - totalInvoiced)}
</p>
)}
</div>
</CardContent>
</Card>
)}
{/* Invoice list */}
{invoices.length > 0 ? (
<div className="space-y-2">
{invoices
.sort((a, b) => new Date(b.invoice_date).getTime() - new Date(a.invoice_date).getTime())
.map(invoice => (
<Link key={invoice.id} href={`/invoices/${invoice.id}`}>
<div className={cn(
'flex items-center gap-3 p-3 border rounded-lg hover:border-primary/50 transition-colors',
invoice.status === 'overdue' && 'border-destructive/50 bg-destructive/5'
)}>
{getStatusIcon(invoice.status)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium">{invoice.invoice_number}</span>
{getStatusBadge(invoice.status)}
</div>
<p className="text-sm text-muted-foreground">
{formatDate(invoice.invoice_date)}
{invoice.due_date && ` • Förfaller: ${formatDate(invoice.due_date)}`}
</p>
</div>
<div className="text-right">
<p className="font-medium">{formatCurrency(invoice.total)}</p>
{invoice.payment_status && (
<p className="text-xs text-muted-foreground">
{PAYMENT_STATUS_LABELS[invoice.payment_status]}
</p>
)}
</div>
<ExternalLink className="h-4 w-4 text-muted-foreground" />
</div>
</Link>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<FileText className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>Inga fakturor ännu</p>
{onCreateInvoice && (
<Button
variant="outline"
size="sm"
className="mt-2"
onClick={onCreateInvoice}
>
<Plus className="h-4 w-4 mr-1" />
Skapa första fakturan
</Button>
)}
</div>
)}
</div>
)
}
+201
View File
@@ -0,0 +1,201 @@
'use client'
import { useState } from 'react'
import { Campaign, CampaignStatus, CampaignType, CAMPAIGN_STATUS_LABELS, CAMPAIGN_TYPE_LABELS } from '@/types'
import { CampaignCard } from './CampaignCard'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Search, X } from 'lucide-react'
import { EmptyCampaigns } from '@/components/ui/empty-state'
interface CampaignListProps {
campaigns: Campaign[]
loading?: boolean
}
const STATUS_OPTIONS: { value: CampaignStatus | 'all'; label: string }[] = [
{ value: 'all', label: 'Alla statusar' },
{ value: 'negotiation', label: CAMPAIGN_STATUS_LABELS.negotiation },
{ value: 'contracted', label: CAMPAIGN_STATUS_LABELS.contracted },
{ value: 'active', label: CAMPAIGN_STATUS_LABELS.active },
{ value: 'delivered', label: CAMPAIGN_STATUS_LABELS.delivered },
{ value: 'invoiced', label: CAMPAIGN_STATUS_LABELS.invoiced },
{ value: 'completed', label: CAMPAIGN_STATUS_LABELS.completed },
{ value: 'cancelled', label: CAMPAIGN_STATUS_LABELS.cancelled },
]
const TYPE_OPTIONS: { value: CampaignType | 'all'; label: string }[] = [
{ value: 'all', label: 'Alla typer' },
{ value: 'influencer', label: CAMPAIGN_TYPE_LABELS.influencer },
{ value: 'ugc', label: CAMPAIGN_TYPE_LABELS.ugc },
{ value: 'ambassador', label: CAMPAIGN_TYPE_LABELS.ambassador },
]
export function CampaignList({ campaigns, loading }: CampaignListProps) {
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<CampaignStatus | 'all'>('all')
const [typeFilter, setTypeFilter] = useState<CampaignType | 'all'>('all')
// Filter campaigns
const filteredCampaigns = campaigns.filter(campaign => {
// Search filter
if (search) {
const searchLower = search.toLowerCase()
const matchesName = campaign.name.toLowerCase().includes(searchLower)
const matchesCustomer = campaign.customer?.name.toLowerCase().includes(searchLower)
const matchesDescription = campaign.description?.toLowerCase().includes(searchLower)
if (!matchesName && !matchesCustomer && !matchesDescription) {
return false
}
}
// Status filter
if (statusFilter !== 'all' && campaign.status !== statusFilter) {
return false
}
// Type filter
if (typeFilter !== 'all' && campaign.campaign_type !== typeFilter) {
return false
}
return true
})
// Group by status for active view
const activeStatuses: CampaignStatus[] = ['active', 'contracted', 'negotiation', 'delivered', 'invoiced']
const activeCampaigns = filteredCampaigns.filter(c => activeStatuses.includes(c.status))
const completedCampaigns = filteredCampaigns.filter(c => c.status === 'completed')
const cancelledCampaigns = filteredCampaigns.filter(c => c.status === 'cancelled')
const hasFilters = search || statusFilter !== 'all' || typeFilter !== 'all'
const clearFilters = () => {
setSearch('')
setStatusFilter('all')
setTypeFilter('all')
}
if (loading) {
return (
<div className="space-y-4">
{[1, 2, 3].map(i => (
<div key={i} className="h-40 bg-muted animate-pulse rounded-lg" />
))}
</div>
)
}
return (
<div className="space-y-6">
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Sök samarbeten..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as CampaignStatus | 'all')}>
<SelectTrigger className="w-full sm:w-[160px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={typeFilter} onValueChange={(v) => setTypeFilter(v as CampaignType | 'all')}>
<SelectTrigger className="w-full sm:w-[160px]">
<SelectValue placeholder="Typ" />
</SelectTrigger>
<SelectContent>
{TYPE_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{hasFilters && (
<Button variant="ghost" size="icon" onClick={clearFilters}>
<X className="h-4 w-4" />
</Button>
)}
</div>
{/* Results */}
{filteredCampaigns.length === 0 ? (
hasFilters ? (
<div className="text-center py-12">
<p className="text-muted-foreground">
Inga samarbeten matchar filtren
</p>
<Button variant="outline" className="mt-4" onClick={clearFilters}>
Rensa filter
</Button>
</div>
) : (
<EmptyCampaigns />
)
) : (
<div className="space-y-8">
{/* Active campaigns */}
{activeCampaigns.length > 0 && (
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-3">
Aktiva ({activeCampaigns.length})
</h3>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{activeCampaigns.map(campaign => (
<CampaignCard key={campaign.id} campaign={campaign} />
))}
</div>
</div>
)}
{/* Completed campaigns */}
{completedCampaigns.length > 0 && (
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-3">
Avslutade ({completedCampaigns.length})
</h3>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{completedCampaigns.map(campaign => (
<CampaignCard key={campaign.id} campaign={campaign} />
))}
</div>
</div>
)}
{/* Cancelled campaigns */}
{cancelledCampaigns.length > 0 && (
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-3">
Avbrutna ({cancelledCampaigns.length})
</h3>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{cancelledCampaigns.map(campaign => (
<CampaignCard key={campaign.id} campaign={campaign} />
))}
</div>
</div>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,37 @@
'use client'
import { Badge } from '@/components/ui/badge'
import type { CampaignStatus } from '@/types'
import { CAMPAIGN_STATUS_LABELS } from '@/types'
import { cn } from '@/lib/utils'
const STATUS_STYLES: Record<CampaignStatus, { className: string; variant: 'default' | 'secondary' | 'outline' | 'destructive' }> = {
negotiation: { className: 'bg-blue-100 text-blue-700 border-blue-200', variant: 'outline' },
contracted: { className: 'bg-purple-100 text-purple-700 border-purple-200', variant: 'outline' },
active: { className: 'bg-green-100 text-green-700 border-green-200', variant: 'outline' },
delivered: { className: 'bg-yellow-100 text-yellow-700 border-yellow-200', variant: 'outline' },
invoiced: { className: 'bg-orange-100 text-orange-700 border-orange-200', variant: 'outline' },
completed: { className: 'bg-emerald-100 text-emerald-700 border-emerald-200', variant: 'outline' },
cancelled: { className: 'bg-gray-100 text-gray-500 border-gray-200', variant: 'outline' },
}
interface CampaignStatusBadgeProps {
status: CampaignStatus
size?: 'sm' | 'default'
}
export function CampaignStatusBadge({ status, size = 'default' }: CampaignStatusBadgeProps) {
const style = STATUS_STYLES[status]
return (
<Badge
variant={style.variant}
className={cn(
style.className,
size === 'sm' && 'text-xs px-1.5 py-0'
)}
>
{CAMPAIGN_STATUS_LABELS[status]}
</Badge>
)
}
+159
View File
@@ -0,0 +1,159 @@
'use client'
import Link from 'next/link'
import { Campaign, Deliverable, CAMPAIGN_STATUS_LABELS } from '@/types'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { CampaignStatusBadge } from './CampaignStatusBadge'
import {
Megaphone,
ArrowRight,
Clock,
Package,
AlertTriangle,
Plus
} from 'lucide-react'
import { cn } from '@/lib/utils'
interface CampaignsWidgetProps {
campaigns: Campaign[]
className?: string
}
export function CampaignsWidget({ campaigns, className }: CampaignsWidgetProps) {
const today = new Date().toISOString().split('T')[0]
// Get active campaigns
const activeCampaigns = campaigns.filter(c =>
['active', 'contracted', 'negotiation'].includes(c.status)
)
// Find upcoming deliverables (next 7 days)
const next7Days = new Date()
next7Days.setDate(next7Days.getDate() + 7)
const next7DaysStr = next7Days.toISOString().split('T')[0]
const upcomingDeliverables: (Deliverable & { campaignName: string })[] = []
for (const campaign of activeCampaigns) {
for (const deliverable of campaign.deliverables || []) {
if (
deliverable.due_date &&
deliverable.due_date >= today &&
deliverable.due_date <= next7DaysStr &&
!['approved', 'published'].includes(deliverable.status)
) {
upcomingDeliverables.push({
...deliverable,
campaignName: campaign.name
})
}
}
}
// Sort by due date
upcomingDeliverables.sort((a, b) =>
new Date(a.due_date!).getTime() - new Date(b.due_date!).getTime()
)
// Find overdue deliverables
const overdueCount = activeCampaigns.reduce((count, campaign) => {
return count + (campaign.deliverables?.filter(d =>
d.due_date &&
d.due_date < today &&
!['approved', 'published'].includes(d.status)
).length || 0)
}, 0)
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('sv-SE', {
weekday: 'short',
day: 'numeric',
month: 'short',
})
}
if (activeCampaigns.length === 0) {
return null
}
return (
<Card className={className}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<Megaphone className="h-4 w-4" />
Samarbeten
</CardTitle>
<Link href="/campaigns">
<Button variant="ghost" size="sm" className="text-xs">
Visa alla
<ArrowRight className="h-3 w-3 ml-1" />
</Button>
</Link>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Active campaigns summary */}
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Aktiva samarbeten</span>
<span className="font-medium">{activeCampaigns.length}</span>
</div>
{/* Overdue warning */}
{overdueCount > 0 && (
<div className="flex items-center gap-2 p-2 bg-destructive/10 rounded-lg">
<AlertTriangle className="h-4 w-4 text-destructive" />
<span className="text-sm text-destructive">
{overdueCount} försenat innehåll
</span>
</div>
)}
{/* Upcoming deliverables */}
{upcomingDeliverables.length > 0 && (
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-2">
Kommande denna vecka
</p>
<div className="space-y-2">
{upcomingDeliverables.slice(0, 3).map(deliverable => (
<div
key={deliverable.id}
className="flex items-center gap-2 p-2 rounded-lg border bg-card"
>
<Package className="h-4 w-4 text-muted-foreground" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{deliverable.title}
</p>
<p className="text-xs text-muted-foreground">
{deliverable.campaignName}
</p>
</div>
<div className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatDate(deliverable.due_date!)}
</div>
</div>
))}
{upcomingDeliverables.length > 3 && (
<p className="text-xs text-muted-foreground text-center">
+{upcomingDeliverables.length - 3} till
</p>
)}
</div>
</div>
)}
{/* Quick add button */}
<Link href="/campaigns/new" className="block">
<Button variant="outline" size="sm" className="w-full">
<Plus className="h-4 w-4 mr-1" />
Nytt samarbete
</Button>
</Link>
</CardContent>
</Card>
)
}
+326
View File
@@ -0,0 +1,326 @@
'use client'
import { useState } from 'react'
import { Contract, ExtractionStatus } from '@/types'
import { ContractUpload } from './ContractUpload'
import { ExtractionStatusBadge } from '@/components/contracts/ExtractionStatusBadge'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import {
FileText,
Download,
Trash2,
Star,
Calendar,
Plus,
ChevronDown,
ChevronUp,
Sparkles,
Loader2,
} from 'lucide-react'
import { cn } from '@/lib/utils'
interface ContractListProps {
campaignId: string
contracts: Contract[]
onUpdate: () => void
}
export function ContractList({ campaignId, contracts, onUpdate }: ContractListProps) {
const { toast } = useToast()
const [showUpload, setShowUpload] = useState(false)
const [downloadingId, setDownloadingId] = useState<string | null>(null)
const [extractingId, setExtractingId] = useState<string | null>(null)
const handleDownload = async (contract: Contract) => {
setDownloadingId(contract.id)
try {
const response = await fetch(`/api/contracts/${contract.id}/download`)
if (!response.ok) {
throw new Error('Download failed')
}
const data = await response.json()
// Open download in new tab
window.open(data.url, '_blank')
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte ladda ner filen',
variant: 'destructive',
})
} finally {
setDownloadingId(null)
}
}
const handleDelete = async (contract: Contract) => {
if (!confirm(`Ta bort "${contract.filename}"?`)) return
try {
const response = await fetch(`/api/contracts/${contract.id}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Delete failed')
}
toast({
title: 'Avtal borttaget',
description: contract.filename,
})
onUpdate()
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte ta bort avtalet',
variant: 'destructive',
})
}
}
const handleSetPrimary = async (contract: Contract) => {
try {
const response = await fetch(`/api/contracts/${contract.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_primary: true }),
})
if (!response.ok) {
throw new Error('Update failed')
}
toast({
title: 'Huvudavtal uppdaterat',
})
onUpdate()
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte uppdatera',
variant: 'destructive',
})
}
}
const handleExtract = async (contract: Contract) => {
if (contract.mime_type !== 'application/pdf') {
toast({
title: 'Fel',
description: 'Endast PDF-filer kan analyseras',
variant: 'destructive',
})
return
}
setExtractingId(contract.id)
try {
const response = await fetch(`/api/contracts/${contract.id}/extract`, {
method: 'POST',
})
const result = await response.json()
if (!response.ok) {
throw new Error(result.error || 'Extraction failed')
}
toast({
title: 'Analys klar',
description: 'Avtalsinformationen har extraherats',
})
onUpdate()
} catch (error) {
toast({
title: 'Fel vid analys',
description: error instanceof Error ? error.message : 'Kunde inte analysera avtalet',
variant: 'destructive',
})
} finally {
setExtractingId(null)
}
}
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('sv-SE', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
const formatFileSize = (bytes: number | null) => {
if (!bytes) return ''
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
const getFileIcon = (mimeType: string | null) => {
if (mimeType?.startsWith('image/')) return '🖼️'
if (mimeType === 'application/pdf') return '📄'
if (mimeType?.includes('word')) return '📝'
return '📎'
}
// Sort: primary first, then by upload date
const sortedContracts = [...contracts].sort((a, b) => {
if (a.is_primary && !b.is_primary) return -1
if (!a.is_primary && b.is_primary) return 1
return new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime()
})
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium">
Avtal
<span className="text-muted-foreground ml-2">
({contracts.length})
</span>
</h3>
<Button
size="sm"
variant={showUpload ? 'secondary' : 'default'}
onClick={() => setShowUpload(!showUpload)}
>
{showUpload ? (
<>
<ChevronUp className="h-4 w-4 mr-1" />
Stäng
</>
) : (
<>
<Plus className="h-4 w-4 mr-1" />
Ladda upp
</>
)}
</Button>
</div>
{/* Upload section */}
{showUpload && (
<div className="p-4 border rounded-lg bg-muted/30">
<ContractUpload
campaignId={campaignId}
onSuccess={() => {
setShowUpload(false)
onUpdate()
}}
/>
</div>
)}
{/* Contract list */}
{sortedContracts.length > 0 ? (
<div className="space-y-2">
{sortedContracts.map(contract => (
<div
key={contract.id}
className={cn(
'flex items-center gap-3 p-3 border rounded-lg',
contract.is_primary && 'border-primary/50 bg-primary/5'
)}
>
<div className="text-2xl">
{getFileIcon(contract.mime_type)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium truncate">{contract.filename}</p>
{contract.is_primary && (
<Badge variant="default" className="text-xs">
<Star className="h-3 w-3 mr-1" />
Huvudavtal
</Badge>
)}
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span>{formatFileSize(contract.file_size)}</span>
{contract.signing_date && (
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
Signerat: {formatDate(contract.signing_date)}
</span>
)}
<span>Uppladdat: {formatDate(contract.uploaded_at)}</span>
{contract.extraction_status && contract.extraction_status !== 'pending' && (
<ExtractionStatusBadge status={contract.extraction_status as ExtractionStatus} />
)}
</div>
</div>
<div className="flex gap-1">
{contract.mime_type === 'application/pdf' &&
contract.extraction_status !== 'completed' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleExtract(contract)}
disabled={extractingId === contract.id}
title="Analysera med AI"
>
{extractingId === contract.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
</Button>
)}
{!contract.is_primary && (
<Button
variant="ghost"
size="sm"
onClick={() => handleSetPrimary(contract)}
title="Sätt som huvudavtal"
>
<Star className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => handleDownload(contract)}
disabled={downloadingId === contract.id}
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(contract)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
) : !showUpload ? (
<div className="text-center py-8 text-muted-foreground">
<FileText className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>Inga avtal uppladdade</p>
<Button
variant="outline"
size="sm"
className="mt-2"
onClick={() => setShowUpload(true)}
>
<Plus className="h-4 w-4 mr-1" />
Ladda upp avtal
</Button>
</div>
) : null}
</div>
)
}
+214
View File
@@ -0,0 +1,214 @@
'use client'
import { useState, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Progress } from '@/components/ui/progress'
import { useToast } from '@/components/ui/use-toast'
import { Upload, FileText, X } from 'lucide-react'
interface ContractUploadProps {
campaignId: string
onSuccess?: () => void
}
export function ContractUpload({ campaignId, onSuccess }: ContractUploadProps) {
const { toast } = useToast()
const fileInputRef = useRef<HTMLInputElement>(null)
const [isUploading, setIsUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [signingDate, setSigningDate] = useState('')
const [isPrimary, setIsPrimary] = useState(true)
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
// Validate file type
const allowedTypes = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'image/jpeg',
'image/png',
'image/webp'
]
if (!allowedTypes.includes(file.type)) {
toast({
title: 'Ogiltig filtyp',
description: 'Tillåtna format: PDF, DOC, DOCX, JPG, PNG, WEBP',
variant: 'destructive',
})
return
}
// Check file size (10MB max)
if (file.size > 10 * 1024 * 1024) {
toast({
title: 'Filen är för stor',
description: 'Max filstorlek är 10MB',
variant: 'destructive',
})
return
}
setSelectedFile(file)
}
}
const handleUpload = async () => {
if (!selectedFile) return
setIsUploading(true)
setUploadProgress(0)
try {
const formData = new FormData()
formData.append('file', selectedFile)
if (signingDate) formData.append('signing_date', signingDate)
formData.append('is_primary', isPrimary.toString())
// Simulate progress for better UX
const progressInterval = setInterval(() => {
setUploadProgress(prev => Math.min(prev + 10, 90))
}, 200)
const response = await fetch(`/api/campaigns/${campaignId}/contracts`, {
method: 'POST',
body: formData,
})
clearInterval(progressInterval)
setUploadProgress(100)
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Upload failed')
}
toast({
title: 'Avtal uppladdat',
description: selectedFile.name,
})
// Reset form
setSelectedFile(null)
setSigningDate('')
setIsPrimary(true)
if (fileInputRef.current) fileInputRef.current.value = ''
onSuccess?.()
} catch (error) {
toast({
title: 'Uppladdning misslyckades',
description: error instanceof Error ? error.message : 'Något gick fel',
variant: 'destructive',
})
} finally {
setIsUploading(false)
setUploadProgress(0)
}
}
const clearSelection = () => {
setSelectedFile(null)
if (fileInputRef.current) fileInputRef.current.value = ''
}
return (
<div className="space-y-4">
{/* File input */}
<div>
<input
ref={fileInputRef}
type="file"
accept=".pdf,.doc,.docx,.jpg,.jpeg,.png,.webp"
onChange={handleFileSelect}
className="hidden"
/>
{selectedFile ? (
<div className="flex items-center gap-3 p-3 border rounded-lg bg-muted/50">
<FileText className="h-8 w-8 text-primary" />
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{selectedFile.name}</p>
<p className="text-sm text-muted-foreground">
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={clearSelection}
disabled={isUploading}
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<button
onClick={() => fileInputRef.current?.click()}
className="w-full p-8 border-2 border-dashed rounded-lg hover:border-primary hover:bg-muted/50 transition-colors"
>
<div className="flex flex-col items-center gap-2">
<Upload className="h-8 w-8 text-muted-foreground" />
<p className="font-medium">Klicka för att välja fil</p>
<p className="text-sm text-muted-foreground">
PDF, DOC, DOCX, JPG, PNG (max 10MB)
</p>
</div>
</button>
)}
</div>
{/* Options */}
{selectedFile && (
<>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="signing_date">Signeringsdatum</Label>
<Input
id="signing_date"
type="date"
value={signingDate}
onChange={(e) => setSigningDate(e.target.value)}
/>
</div>
<div className="flex items-end gap-2 pb-2">
<Switch
id="is_primary"
checked={isPrimary}
onCheckedChange={setIsPrimary}
/>
<Label htmlFor="is_primary" className="font-normal">
Huvudavtal
</Label>
</div>
</div>
{/* Upload progress */}
{isUploading && (
<div className="space-y-2">
<Progress value={uploadProgress} />
<p className="text-sm text-muted-foreground text-center">
Laddar upp... {uploadProgress}%
</p>
</div>
)}
{/* Upload button */}
<Button
onClick={handleUpload}
disabled={isUploading}
className="w-full"
>
{isUploading ? 'Laddar upp...' : 'Ladda upp avtal'}
</Button>
</>
)}
</div>
)
}
+247
View File
@@ -0,0 +1,247 @@
'use client'
import { Deliverable, DELIVERABLE_STATUS_LABELS, DELIVERABLE_TYPE_LABELS, PLATFORM_LABELS } from '@/types'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import {
CheckCircle,
Circle,
Clock,
Edit2,
Trash2,
ExternalLink,
RotateCcw,
Send
} from 'lucide-react'
const STATUS_STYLES: Record<string, string> = {
pending: 'bg-gray-100 text-gray-700 border-gray-200',
in_progress: 'bg-blue-100 text-blue-700 border-blue-200',
submitted: 'bg-purple-100 text-purple-700 border-purple-200',
revision: 'bg-orange-100 text-orange-700 border-orange-200',
approved: 'bg-green-100 text-green-700 border-green-200',
published: 'bg-emerald-100 text-emerald-700 border-emerald-200',
}
interface DeliverableCardProps {
deliverable: Deliverable
onStatusChange?: (deliverable: Deliverable, newStatus: string) => void
onEdit?: (deliverable: Deliverable) => void
onDelete?: (deliverable: Deliverable) => void
compact?: boolean
}
export function DeliverableCard({
deliverable,
onStatusChange,
onEdit,
onDelete,
compact = false
}: DeliverableCardProps) {
const isCompleted = deliverable.status === 'approved' || deliverable.status === 'published'
const isOverdue = deliverable.due_date && new Date(deliverable.due_date) < new Date() && !isCompleted
const formatDate = (date: string | null) => {
if (!date) return null
return new Date(date).toLocaleDateString('sv-SE', {
day: 'numeric',
month: 'short',
})
}
// Next logical status based on current
const getNextStatus = () => {
switch (deliverable.status) {
case 'pending': return 'in_progress'
case 'in_progress': return 'submitted'
case 'submitted': return 'approved'
case 'revision': return 'submitted'
case 'approved': return 'published'
default: return null
}
}
const nextStatus = getNextStatus()
if (compact) {
return (
<div
className={cn(
'flex items-center gap-2 p-2 rounded-lg border',
isOverdue ? 'border-destructive/50 bg-destructive/5' : 'border-border'
)}
>
<button
onClick={() => nextStatus && onStatusChange?.(deliverable, nextStatus)}
className="flex-shrink-0"
disabled={!onStatusChange || !nextStatus}
>
{isCompleted ? (
<CheckCircle className="h-4 w-4 text-success" />
) : (
<Circle className="h-4 w-4 text-muted-foreground" />
)}
</button>
<div className="flex-1 min-w-0">
<p className={cn(
'text-sm font-medium truncate',
isCompleted && 'line-through text-muted-foreground'
)}>
{deliverable.title}
</p>
</div>
<Badge variant="outline" className="text-xs">
{PLATFORM_LABELS[deliverable.platform]}
</Badge>
</div>
)
}
return (
<div
className={cn(
'p-4 rounded-lg border',
isOverdue ? 'border-destructive/50 bg-destructive/5' : 'border-border'
)}
>
<div className="flex items-start gap-3">
<button
onClick={() => nextStatus && onStatusChange?.(deliverable, nextStatus)}
className="mt-0.5 flex-shrink-0"
disabled={!onStatusChange || !nextStatus}
>
{isCompleted ? (
<CheckCircle className="h-5 w-5 text-success" />
) : isOverdue ? (
<Clock className="h-5 w-5 text-destructive" />
) : (
<Circle className="h-5 w-5 text-muted-foreground" />
)}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div>
<p className={cn(
'font-medium',
isCompleted && 'line-through text-muted-foreground'
)}>
{deliverable.title}
{deliverable.quantity > 1 && (
<span className="text-muted-foreground ml-1">×{deliverable.quantity}</span>
)}
</p>
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground">
<span>{PLATFORM_LABELS[deliverable.platform]}</span>
<span>·</span>
<span>{DELIVERABLE_TYPE_LABELS[deliverable.deliverable_type]}</span>
{deliverable.account_handle && (
<>
<span>·</span>
<span>{deliverable.account_handle}</span>
</>
)}
</div>
{deliverable.due_date && (
<p className={cn(
'text-sm mt-1',
isOverdue && !isCompleted ? 'text-destructive' : 'text-muted-foreground'
)}>
Deadline: {formatDate(deliverable.due_date)}
</p>
)}
</div>
<div className="flex flex-col items-end gap-1">
<Badge
variant="outline"
className={cn('text-xs', STATUS_STYLES[deliverable.status])}
>
{DELIVERABLE_STATUS_LABELS[deliverable.status]}
</Badge>
{isOverdue && !isCompleted && (
<Badge variant="destructive" className="text-xs">
Försenad
</Badge>
)}
</div>
</div>
{deliverable.description && (
<p className="text-sm text-muted-foreground mt-2">
{deliverable.description}
</p>
)}
{/* Timestamps */}
{(deliverable.submitted_at || deliverable.approved_at || deliverable.published_at) && (
<div className="flex flex-wrap gap-3 mt-2 text-xs text-muted-foreground">
{deliverable.submitted_at && (
<span>Inskickat: {formatDate(deliverable.submitted_at)}</span>
)}
{deliverable.approved_at && (
<span>Godkänt: {formatDate(deliverable.approved_at)}</span>
)}
{deliverable.published_at && (
<span>Publicerat: {formatDate(deliverable.published_at)}</span>
)}
</div>
)}
</div>
</div>
{/* Actions */}
{(onStatusChange || onEdit || onDelete) && (
<div className="flex justify-between items-center gap-1 mt-3 pt-3 border-t">
{/* Status progression buttons */}
<div className="flex gap-1">
{onStatusChange && nextStatus && (
<Button
variant="outline"
size="sm"
onClick={() => onStatusChange(deliverable, nextStatus)}
className="text-xs"
>
{nextStatus === 'in_progress' && <Clock className="h-3 w-3 mr-1" />}
{nextStatus === 'submitted' && <Send className="h-3 w-3 mr-1" />}
{nextStatus === 'approved' && <CheckCircle className="h-3 w-3 mr-1" />}
{nextStatus === 'published' && <ExternalLink className="h-3 w-3 mr-1" />}
{DELIVERABLE_STATUS_LABELS[nextStatus as keyof typeof DELIVERABLE_STATUS_LABELS]}
</Button>
)}
{onStatusChange && deliverable.status === 'submitted' && (
<Button
variant="outline"
size="sm"
onClick={() => onStatusChange(deliverable, 'revision')}
className="text-xs text-orange-600"
>
<RotateCcw className="h-3 w-3 mr-1" />
Revision
</Button>
)}
</div>
<div className="flex gap-1">
{onEdit && (
<Button variant="ghost" size="sm" onClick={() => onEdit(deliverable)}>
<Edit2 className="h-4 w-4" />
</Button>
)}
{onDelete && (
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(deliverable)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
)}
</div>
)
}
+251
View File
@@ -0,0 +1,251 @@
'use client'
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
import type { Deliverable, DeliverableType, PlatformType, CreateDeliverableInput } from '@/types'
import { DELIVERABLE_TYPE_LABELS, PLATFORM_LABELS } from '@/types'
interface DeliverableFormProps {
open: boolean
onOpenChange: (open: boolean) => void
campaignId: string
initialData?: Partial<Deliverable>
onSuccess?: (deliverable: Deliverable) => void
}
export function DeliverableForm({
open,
onOpenChange,
campaignId,
initialData,
onSuccess
}: DeliverableFormProps) {
const { toast } = useToast()
const [isLoading, setIsLoading] = useState(false)
const [formData, setFormData] = useState<Omit<CreateDeliverableInput, 'campaign_id'>>({
title: '',
deliverable_type: 'video',
platform: 'instagram',
account_handle: '',
quantity: 1,
description: '',
due_date: '',
notes: '',
})
useEffect(() => {
if (open) {
setFormData({
title: initialData?.title || '',
deliverable_type: initialData?.deliverable_type || 'video',
platform: initialData?.platform || 'instagram',
account_handle: initialData?.account_handle || '',
quantity: initialData?.quantity || 1,
description: initialData?.description || '',
due_date: initialData?.due_date || '',
notes: initialData?.notes || '',
})
}
}, [open, initialData])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!formData.title) {
toast({ title: 'Titel krävs', variant: 'destructive' })
return
}
setIsLoading(true)
try {
const url = initialData?.id
? `/api/deliverables/${initialData.id}`
: `/api/campaigns/${campaignId}/deliverables`
const method = initialData?.id ? 'PATCH' : 'POST'
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...formData,
due_date: formData.due_date || null,
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to save deliverable')
}
const { data } = await response.json()
toast({
title: initialData?.id ? 'Leverabel uppdaterad' : 'Leverabel tillagd',
description: formData.title,
})
onOpenChange(false)
onSuccess?.(data)
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Något gick fel',
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
{initialData?.id ? 'Redigera leverabel' : 'Lägg till leverabel'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="title">Titel *</Label>
<Input
id="title"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="T.ex. Instagram Reel - Produktrecension"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="platform">Plattform *</Label>
<Select
value={formData.platform}
onValueChange={(v) => setFormData({ ...formData, platform: v as PlatformType })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(PLATFORM_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="deliverable_type">Typ *</Label>
<Select
value={formData.deliverable_type}
onValueChange={(v) => setFormData({ ...formData, deliverable_type: v as DeliverableType })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(DELIVERABLE_TYPE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="account_handle">Konto/Kanal</Label>
<Input
id="account_handle"
value={formData.account_handle || ''}
onChange={(e) => setFormData({ ...formData, account_handle: e.target.value })}
placeholder="@användarnamn"
/>
</div>
<div>
<Label htmlFor="quantity">Antal</Label>
<Input
id="quantity"
type="number"
min="1"
value={formData.quantity || 1}
onChange={(e) => setFormData({ ...formData, quantity: parseInt(e.target.value) || 1 })}
/>
</div>
</div>
<div>
<Label htmlFor="due_date">Deadline</Label>
<Input
id="due_date"
type="date"
value={formData.due_date || ''}
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
/>
<p className="text-xs text-muted-foreground mt-1">
En deadline skapas automatiskt i kalendern
</p>
</div>
<div>
<Label htmlFor="description">Beskrivning</Label>
<Textarea
id="description"
value={formData.description || ''}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Specifika instruktioner eller krav..."
rows={3}
/>
</div>
<div>
<Label htmlFor="notes">Interna anteckningar</Label>
<Textarea
id="notes"
value={formData.notes || ''}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
placeholder="Anteckningar..."
rows={2}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Avbryt
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Sparar...' : initialData?.id ? 'Spara' : 'Lägg till'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
+190
View File
@@ -0,0 +1,190 @@
'use client'
import { useState } from 'react'
import { Deliverable, DeliverableStatus, DELIVERABLE_STATUS_LABELS } from '@/types'
import { DeliverableCard } from './DeliverableCard'
import { DeliverableForm } from './DeliverableForm'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { Plus, ChevronDown, ChevronUp } from 'lucide-react'
import { cn } from '@/lib/utils'
interface DeliverableListProps {
campaignId: string
deliverables: Deliverable[]
onUpdate: () => void
}
export function DeliverableList({ campaignId, deliverables, onUpdate }: DeliverableListProps) {
const { toast } = useToast()
const [showCompleted, setShowCompleted] = useState(false)
const [formOpen, setFormOpen] = useState(false)
const [editingDeliverable, setEditingDeliverable] = useState<Deliverable | null>(null)
// Split deliverables by completion status
const activeDeliverables = deliverables.filter(d =>
!['approved', 'published'].includes(d.status)
)
const completedDeliverables = deliverables.filter(d =>
['approved', 'published'].includes(d.status)
)
const handleStatusChange = async (deliverable: Deliverable, newStatus: string) => {
try {
const response = await fetch(`/api/deliverables/${deliverable.id}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus }),
})
if (!response.ok) {
throw new Error('Failed to update status')
}
toast({
title: 'Status uppdaterad',
description: `${deliverable.title}: ${DELIVERABLE_STATUS_LABELS[newStatus as DeliverableStatus]}`,
})
onUpdate()
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte uppdatera status',
variant: 'destructive',
})
}
}
const handleDelete = async (deliverable: Deliverable) => {
if (!confirm(`Ta bort "${deliverable.title}"?`)) return
try {
const response = await fetch(`/api/deliverables/${deliverable.id}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Failed to delete')
}
toast({
title: 'Innehåll borttaget',
description: deliverable.title,
})
onUpdate()
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte ta bort innehåll',
variant: 'destructive',
})
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium">
Innehåll
<span className="text-muted-foreground ml-2">
({activeDeliverables.length} aktiva)
</span>
</h3>
<Button size="sm" onClick={() => setFormOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Lägg till
</Button>
</div>
{/* Active deliverables */}
{activeDeliverables.length > 0 ? (
<div className="space-y-3">
{activeDeliverables
.sort((a, b) => {
// Sort by due date, nulls last
if (!a.due_date && !b.due_date) return 0
if (!a.due_date) return 1
if (!b.due_date) return -1
return new Date(a.due_date).getTime() - new Date(b.due_date).getTime()
})
.map(deliverable => (
<DeliverableCard
key={deliverable.id}
deliverable={deliverable}
onStatusChange={handleStatusChange}
onEdit={(d) => {
setEditingDeliverable(d)
setFormOpen(true)
}}
onDelete={handleDelete}
/>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<p>Inget aktivt innehåll</p>
<Button
variant="outline"
size="sm"
className="mt-2"
onClick={() => setFormOpen(true)}
>
<Plus className="h-4 w-4 mr-1" />
Lägg till innehåll
</Button>
</div>
)}
{/* Completed deliverables */}
{completedDeliverables.length > 0 && (
<div className="pt-4 border-t">
<button
onClick={() => setShowCompleted(!showCompleted)}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
>
{showCompleted ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
Klara ({completedDeliverables.length})
</button>
{showCompleted && (
<div className="space-y-3 mt-3">
{completedDeliverables.map(deliverable => (
<DeliverableCard
key={deliverable.id}
deliverable={deliverable}
onEdit={(d) => {
setEditingDeliverable(d)
setFormOpen(true)
}}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
)}
{/* Form dialog */}
<DeliverableForm
open={formOpen}
onOpenChange={(open) => {
setFormOpen(open)
if (!open) setEditingDeliverable(null)
}}
campaignId={campaignId}
initialData={editingDeliverable || undefined}
onSuccess={() => {
setFormOpen(false)
setEditingDeliverable(null)
onUpdate()
}}
/>
</div>
)
}
+381
View File
@@ -0,0 +1,381 @@
'use client'
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
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 { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import type { Exclusivity, CreateExclusivityInput, ExclusivityConflict } from '@/types'
import { AlertTriangle, X, Plus } from 'lucide-react'
interface ExclusivityFormProps {
open: boolean
onOpenChange: (open: boolean) => void
campaignId: string
initialData?: Partial<Exclusivity>
onSuccess?: (exclusivity: Exclusivity) => void
}
// Common category suggestions
const CATEGORY_SUGGESTIONS = [
'Skönhet', 'Hudvård', 'Smink', 'Mode', 'Kläder', 'Accessoarer',
'Mat', 'Dryck', 'Kaffe', 'Energidryck', 'Snacks',
'Teknik', 'Mobil', 'Gaming', 'Elektronik',
'Träning', 'Gym', 'Kosttillskott', 'Sport',
'Resor', 'Hotell', 'Flyg',
'Bank', 'Finans', 'Försäkring',
'Bil', 'Bilar', 'Transport'
]
export function ExclusivityForm({
open,
onOpenChange,
campaignId,
initialData,
onSuccess
}: ExclusivityFormProps) {
const { toast } = useToast()
const [isLoading, setIsLoading] = useState(false)
const [isCheckingConflicts, setIsCheckingConflicts] = useState(false)
const [conflicts, setConflicts] = useState<ExclusivityConflict[]>([])
const [newCategory, setNewCategory] = useState('')
const [newBrand, setNewBrand] = useState('')
const [formData, setFormData] = useState<Omit<CreateExclusivityInput, 'campaign_id'>>({
categories: [],
excluded_brands: [],
start_date: '',
end_date: '',
notes: '',
})
useEffect(() => {
if (open) {
setFormData({
categories: initialData?.categories || [],
excluded_brands: initialData?.excluded_brands || [],
start_date: initialData?.start_date || '',
end_date: initialData?.end_date || '',
notes: initialData?.notes || '',
})
setConflicts([])
setNewCategory('')
setNewBrand('')
}
}, [open, initialData])
// Check for conflicts when dates or categories change
const checkConflicts = async () => {
if (!formData.categories.length || !formData.start_date || !formData.end_date) {
setConflicts([])
return
}
setIsCheckingConflicts(true)
try {
const params = new URLSearchParams({
categories: formData.categories.join(','),
start_date: formData.start_date,
end_date: formData.end_date,
exclude_campaign_id: campaignId,
})
const response = await fetch(`/api/exclusivities/conflicts?${params}`)
if (response.ok) {
const data = await response.json()
setConflicts(data.conflicts || [])
}
} catch (error) {
console.error('Failed to check conflicts:', error)
} finally {
setIsCheckingConflicts(false)
}
}
useEffect(() => {
const timer = setTimeout(checkConflicts, 500)
return () => clearTimeout(timer)
}, [formData.categories, formData.start_date, formData.end_date])
const addCategory = (category: string) => {
const trimmed = category.trim()
if (trimmed && !formData.categories.includes(trimmed)) {
setFormData({
...formData,
categories: [...formData.categories, trimmed]
})
}
setNewCategory('')
}
const removeCategory = (category: string) => {
setFormData({
...formData,
categories: formData.categories.filter(c => c !== category)
})
}
const addBrand = (brand: string) => {
const trimmed = brand.trim()
if (trimmed && !formData.excluded_brands?.includes(trimmed)) {
setFormData({
...formData,
excluded_brands: [...(formData.excluded_brands || []), trimmed]
})
}
setNewBrand('')
}
const removeBrand = (brand: string) => {
setFormData({
...formData,
excluded_brands: formData.excluded_brands?.filter(b => b !== brand) || []
})
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (formData.categories.length === 0) {
toast({ title: 'Minst en kategori krävs', variant: 'destructive' })
return
}
if (!formData.start_date || !formData.end_date) {
toast({ title: 'Start- och slutdatum krävs', variant: 'destructive' })
return
}
setIsLoading(true)
try {
const url = initialData?.id
? `/api/exclusivities/${initialData.id}`
: `/api/campaigns/${campaignId}/exclusivities`
const method = initialData?.id ? 'PATCH' : 'POST'
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to save exclusivity')
}
const result = await response.json()
// Show warning if there are conflicts
if (result.warning) {
toast({
title: 'Exklusivitet sparad med varning',
description: result.warning,
variant: 'default',
})
} else {
toast({
title: initialData?.id ? 'Exklusivitet uppdaterad' : 'Exklusivitet tillagd',
})
}
onOpenChange(false)
onSuccess?.(result.data)
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Något gick fel',
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{initialData?.id ? 'Redigera exklusivitet' : 'Lägg till exklusivitet'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Categories */}
<div>
<Label>Kategorier *</Label>
<div className="flex flex-wrap gap-2 mt-2 mb-2">
{formData.categories.map(category => (
<Badge key={category} variant="secondary" className="gap-1">
{category}
<button
type="button"
onClick={() => removeCategory(category)}
className="hover:text-destructive"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
<div className="flex gap-2">
<Input
value={newCategory}
onChange={(e) => setNewCategory(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addCategory(newCategory)
}
}}
placeholder="Lägg till kategori..."
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => addCategory(newCategory)}
>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="flex flex-wrap gap-1 mt-2">
{CATEGORY_SUGGESTIONS
.filter(s => !formData.categories.includes(s))
.slice(0, 8)
.map(suggestion => (
<button
key={suggestion}
type="button"
onClick={() => addCategory(suggestion)}
className="text-xs text-muted-foreground hover:text-foreground px-2 py-0.5 rounded border hover:border-primary"
>
+ {suggestion}
</button>
))}
</div>
</div>
{/* Excluded brands */}
<div>
<Label>Specifika varumärken som exkluderas (valfritt)</Label>
<div className="flex flex-wrap gap-2 mt-2 mb-2">
{formData.excluded_brands?.map(brand => (
<Badge key={brand} variant="outline" className="gap-1">
{brand}
<button
type="button"
onClick={() => removeBrand(brand)}
className="hover:text-destructive"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
<div className="flex gap-2">
<Input
value={newBrand}
onChange={(e) => setNewBrand(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addBrand(newBrand)
}
}}
placeholder="Lägg till varumärke..."
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => addBrand(newBrand)}
>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
{/* Date range */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="start_date">Startdatum *</Label>
<Input
id="start_date"
type="date"
value={formData.start_date}
onChange={(e) => setFormData({ ...formData, start_date: e.target.value })}
required
/>
</div>
<div>
<Label htmlFor="end_date">Slutdatum *</Label>
<Input
id="end_date"
type="date"
value={formData.end_date}
onChange={(e) => setFormData({ ...formData, end_date: e.target.value })}
required
/>
</div>
</div>
{/* Conflict warnings */}
{conflicts.length > 0 && (
<div className="p-3 bg-orange-50 border border-orange-200 rounded-lg">
<div className="flex items-start gap-2">
<AlertTriangle className="h-5 w-5 text-orange-600 flex-shrink-0" />
<div>
<p className="font-medium text-orange-800">
{conflicts.length} överlappande exklusivitet(er)
</p>
<ul className="text-sm text-orange-700 mt-1 space-y-1">
{conflicts.map((conflict, i) => (
<li key={i}>
<strong>{conflict.conflictingCampaign?.name || 'Okänd kampanj'}</strong>
: {conflict.overlappingCategories.join(', ')}
{' '}({conflict.overlapStart} - {conflict.overlapEnd})
</li>
))}
</ul>
</div>
</div>
</div>
)}
{/* Notes */}
<div>
<Label htmlFor="notes">Anteckningar</Label>
<Textarea
id="notes"
value={formData.notes || ''}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
placeholder="Detaljer om exklusiviteten..."
rows={2}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Avbryt
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Sparar...' : initialData?.id ? 'Spara' : 'Lägg till'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
+202
View File
@@ -0,0 +1,202 @@
'use client'
import { useState } from 'react'
import { Exclusivity } from '@/types'
import { ExclusivityForm } from './ExclusivityForm'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import { Plus, Trash2, Edit2, Calendar, AlertTriangle } from 'lucide-react'
import { cn } from '@/lib/utils'
interface ExclusivityListProps {
campaignId: string
exclusivities: Exclusivity[]
onUpdate: () => void
}
export function ExclusivityList({ campaignId, exclusivities, onUpdate }: ExclusivityListProps) {
const { toast } = useToast()
const [formOpen, setFormOpen] = useState(false)
const [editingExclusivity, setEditingExclusivity] = useState<Exclusivity | null>(null)
const today = new Date().toISOString().split('T')[0]
const handleDelete = async (exclusivity: Exclusivity) => {
if (!confirm('Ta bort denna exklusivitet?')) return
try {
const response = await fetch(`/api/exclusivities/${exclusivity.id}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Failed to delete')
}
toast({
title: 'Exklusivitet borttagen',
})
onUpdate()
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte ta bort exklusivitet',
variant: 'destructive',
})
}
}
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('sv-SE', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
const getStatus = (exclusivity: Exclusivity) => {
if (exclusivity.end_date < today) return 'expired'
if (exclusivity.start_date > today) return 'upcoming'
return 'active'
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium">
Exklusiviteter
<span className="text-muted-foreground ml-2">
({exclusivities.length})
</span>
</h3>
<Button size="sm" onClick={() => setFormOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Lägg till
</Button>
</div>
{exclusivities.length > 0 ? (
<div className="space-y-3">
{exclusivities
.sort((a, b) => new Date(a.start_date).getTime() - new Date(b.start_date).getTime())
.map(exclusivity => {
const status = getStatus(exclusivity)
return (
<div
key={exclusivity.id}
className={cn(
'p-4 rounded-lg border',
status === 'active' && 'border-orange-200 bg-orange-50',
status === 'upcoming' && 'border-blue-200 bg-blue-50',
status === 'expired' && 'border-gray-200 bg-gray-50 opacity-60'
)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
{status === 'active' && (
<Badge variant="outline" className="text-orange-700 border-orange-300 bg-orange-100">
<AlertTriangle className="h-3 w-3 mr-1" />
Aktiv
</Badge>
)}
{status === 'upcoming' && (
<Badge variant="outline" className="text-blue-700 border-blue-300 bg-blue-100">
Kommande
</Badge>
)}
{status === 'expired' && (
<Badge variant="outline" className="text-gray-500">
Avslutad
</Badge>
)}
</div>
<div className="flex flex-wrap gap-1.5 mb-2">
{exclusivity.categories.map(category => (
<Badge key={category} variant="secondary">
{category}
</Badge>
))}
</div>
{exclusivity.excluded_brands && exclusivity.excluded_brands.length > 0 && (
<div className="text-sm text-muted-foreground mb-2">
Exkluderade varumärken: {exclusivity.excluded_brands.join(', ')}
</div>
)}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar className="h-4 w-4" />
<span>
{formatDate(exclusivity.start_date)} - {formatDate(exclusivity.end_date)}
</span>
</div>
{exclusivity.notes && (
<p className="text-sm text-muted-foreground mt-2">
{exclusivity.notes}
</p>
)}
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingExclusivity(exclusivity)
setFormOpen(true)
}}
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(exclusivity)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
})}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<p>Inga exklusiviteter</p>
<Button
variant="outline"
size="sm"
className="mt-2"
onClick={() => setFormOpen(true)}
>
<Plus className="h-4 w-4 mr-1" />
Lägg till exklusivitet
</Button>
</div>
)}
{/* Form dialog */}
<ExclusivityForm
open={formOpen}
onOpenChange={(open) => {
setFormOpen(open)
if (!open) setEditingExclusivity(null)
}}
campaignId={campaignId}
initialData={editingExclusivity || undefined}
onSuccess={() => {
setFormOpen(false)
setEditingExclusivity(null)
onUpdate()
}}
/>
</div>
)
}
+14
View File
@@ -0,0 +1,14 @@
export { CampaignStatusBadge } from './CampaignStatusBadge'
export { CampaignCard } from './CampaignCard'
export { CampaignList } from './CampaignList'
export { CampaignForm } from './CampaignForm'
export { CampaignDetail } from './CampaignDetail'
export { DeliverableCard } from './DeliverableCard'
export { DeliverableForm } from './DeliverableForm'
export { DeliverableList } from './DeliverableList'
export { ExclusivityForm } from './ExclusivityForm'
export { ExclusivityList } from './ExclusivityList'
export { ContractUpload } from './ContractUpload'
export { ContractList } from './ContractList'
export { CampaignInvoiceSummary } from './CampaignInvoiceSummary'
export { CampaignsWidget } from './CampaignsWidget'
+83
View File
@@ -0,0 +1,83 @@
'use client'
import { useState, useRef, useCallback, KeyboardEvent } from 'react'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Send, Loader2 } from 'lucide-react'
interface ChatInputProps {
onSend: (message: string) => void
disabled?: boolean
isLoading?: boolean
placeholder?: string
}
export function ChatInput({
onSend,
disabled = false,
isLoading = false,
placeholder = 'Skriv ett meddelande...',
}: ChatInputProps) {
const [message, setMessage] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
const handleSend = useCallback(() => {
const trimmed = message.trim()
if (trimmed && !disabled && !isLoading) {
onSend(trimmed)
setMessage('')
// Reset textarea height
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
}
}
}, [message, disabled, isLoading, onSend])
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
// Send on Enter (without Shift)
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
},
[handleSend]
)
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setMessage(e.target.value)
// Auto-resize textarea
const textarea = e.target
textarea.style.height = 'auto'
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
}
const isDisabled = disabled || isLoading || !message.trim()
return (
<div className="flex gap-2 items-end p-4 border-t bg-background">
<Textarea
ref={textareaRef}
value={message}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled || isLoading}
className="min-h-[44px] max-h-[200px] resize-none py-3"
rows={1}
/>
<Button
onClick={handleSend}
disabled={isDisabled}
size="icon"
className="flex-shrink-0 h-[44px] w-[44px]"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
)
}
+116
View File
@@ -0,0 +1,116 @@
'use client'
import { useState } from 'react'
import ReactMarkdown from 'react-markdown'
import { cn } from '@/lib/utils'
import { ChevronDown, ChevronUp, FileText, User, Bot } from 'lucide-react'
import type { ChatMessage as ChatMessageType, SourceReference } from '@/types/chat'
interface ChatMessageProps {
message: ChatMessageType
isStreaming?: boolean
}
function SourcesList({ sources }: { sources: SourceReference[] }) {
const [isExpanded, setIsExpanded] = useState(false)
if (sources.length === 0) return null
return (
<div className="mt-3 pt-3 border-t border-border/40">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<FileText className="h-3 w-3" />
<span>{sources.length} källa{sources.length !== 1 ? 'or' : ''}</span>
{isExpanded ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
</button>
{isExpanded && (
<div className="mt-2 space-y-1.5">
{sources.map((source, index) => (
<div
key={source.id || index}
className="text-xs bg-muted/50 rounded-md px-2.5 py-1.5"
>
<div className="font-medium text-foreground/80">
{source.title}
{source.section_title && (
<span className="font-normal text-muted-foreground">
{' '}
&rsaquo; {source.section_title}
</span>
)}
</div>
<div className="text-muted-foreground mt-0.5">
{source.source_file} ({(source.similarity * 100).toFixed(0)}% relevans)
</div>
</div>
))}
</div>
)}
</div>
)
}
export function ChatMessage({ message, isStreaming }: ChatMessageProps) {
const isUser = message.role === 'user'
return (
<div
className={cn(
'flex gap-3 px-4 py-3',
isUser ? 'flex-row-reverse' : 'flex-row'
)}
>
{/* Avatar */}
<div
className={cn(
'flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center',
isUser
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground'
)}
>
{isUser ? (
<User className="h-4 w-4" />
) : (
<Bot className="h-4 w-4" />
)}
</div>
{/* Message content */}
<div
className={cn(
'flex-1 max-w-[80%] rounded-xl px-4 py-3',
isUser
? 'bg-primary text-primary-foreground ml-auto'
: 'bg-muted/60 text-foreground'
)}
>
<div className={cn(
"text-sm break-words",
!isUser && "prose prose-sm max-w-none prose-headings:text-foreground prose-headings:font-semibold prose-h1:text-base prose-h2:text-sm prose-h3:text-sm prose-p:text-foreground prose-p:my-1.5 prose-headings:my-2 prose-ul:my-1.5 prose-ol:my-1.5 prose-li:my-0.5 prose-li:text-foreground prose-strong:text-foreground prose-table:my-2 prose-table:text-xs prose-th:px-2 prose-th:py-1 prose-td:px-2 prose-td:py-1 prose-th:bg-muted/50 prose-th:border prose-td:border prose-th:border-border/50 prose-td:border-border/50 prose-th:text-foreground prose-td:text-foreground"
)}>
{isUser ? (
<p className="whitespace-pre-wrap m-0">{message.content}</p>
) : (
<ReactMarkdown>{message.content}</ReactMarkdown>
)}
{isStreaming && !isUser && (
<span className="inline-block w-1.5 h-4 bg-current animate-pulse ml-0.5" />
)}
</div>
{!isUser && message.sources && message.sources.length > 0 && (
<SourcesList sources={message.sources} />
)}
</div>
</div>
)
}
+159
View File
@@ -0,0 +1,159 @@
'use client'
import { useEffect, useRef } from 'react'
import { ChatMessage } from './ChatMessage'
import { ChatInput } from './ChatInput'
import { useChatStream } from './useChatStream'
import { useToast } from '@/components/ui/use-toast'
import { Bot, Trash2, RotateCcw } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface ChatPanelProps {
className?: string
}
export function ChatPanel({ className }: ChatPanelProps) {
const { toast } = useToast()
const messagesEndRef = useRef<HTMLDivElement>(null)
const {
messages,
isLoading,
isStreaming,
error,
sendMessage,
clearChat,
} = useChatStream({
onError: (error) => {
toast({
title: 'Fel',
description: error,
variant: 'destructive',
})
},
})
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
}
}, [messages])
const handleClearChat = () => {
clearChat()
toast({
title: 'Chatten rensad',
description: 'En ny konversation har startats.',
})
}
return (
<div className={`flex flex-col h-full ${className || ''}`}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
<Bot className="h-4 w-4 text-primary" />
</div>
<div>
<h3 className="font-medium text-sm">AI-assistent</h3>
<p className="text-xs text-muted-foreground">
Skatt, moms och bokföring
</p>
</div>
</div>
{messages.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleClearChat}
className="text-muted-foreground hover:text-foreground"
>
<RotateCcw className="h-4 w-4 mr-1" />
Ny chatt
</Button>
)}
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto">
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center px-6 py-8">
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<Bot className="h-6 w-6 text-primary" />
</div>
<h4 className="font-medium mb-2">Hur kan jag hjälpa dig?</h4>
<p className="text-sm text-muted-foreground max-w-xs">
Jag kan svara på frågor om skatt, moms, bokföring och andra
ekonomiska frågor för influencers.
</p>
<div className="mt-6 space-y-2 w-full max-w-xs">
<SuggestionButton
onClick={() => sendMessage('Hur fungerar momsen för YouTube-intäkter?')}
disabled={isLoading}
>
Hur fungerar momsen för YouTube-intäkter?
</SuggestionButton>
<SuggestionButton
onClick={() => sendMessage('Vad kan jag dra av som influencer?')}
disabled={isLoading}
>
Vad kan jag dra av som influencer?
</SuggestionButton>
<SuggestionButton
onClick={() => sendMessage('När måste jag momsregistrera mig?')}
disabled={isLoading}
>
När måste jag momsregistrera mig?
</SuggestionButton>
</div>
</div>
) : (
<div className="py-4">
{messages.map((message, index) => (
<ChatMessage
key={message.id}
message={message}
isStreaming={
isStreaming &&
index === messages.length - 1 &&
message.role === 'assistant'
}
/>
))}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* Input */}
<ChatInput
onSend={sendMessage}
disabled={isStreaming}
isLoading={isLoading}
placeholder="Ställ en fråga om skatt, moms eller bokföring..."
/>
</div>
)
}
function SuggestionButton({
children,
onClick,
disabled,
}: {
children: React.ReactNode
onClick: () => void
disabled?: boolean
}) {
return (
<button
onClick={onClick}
disabled={disabled}
className="w-full text-left text-sm px-3 py-2 rounded-lg border border-border/60 hover:bg-muted/50 hover:border-border transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{children}
</button>
)
}
+55
View File
@@ -0,0 +1,55 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { ChatPanel } from './ChatPanel'
import { MessageCircle, X } from 'lucide-react'
import { cn } from '@/lib/utils'
export function ChatWidget() {
const [isOpen, setIsOpen] = useState(false)
return (
<>
{/* Floating chat panel */}
<div
className={cn(
'fixed bottom-36 right-4 md:bottom-20 z-[60] transition-all duration-300 ease-in-out',
isOpen
? 'opacity-100 translate-y-0 pointer-events-auto'
: 'opacity-0 translate-y-4 pointer-events-none'
)}
>
<div className="w-[380px] h-[550px] max-h-[calc(100vh-120px)] bg-background border rounded-xl shadow-xl overflow-hidden flex flex-col">
{/* Close button in corner */}
<button
onClick={() => setIsOpen(false)}
className="absolute top-2 right-2 z-10 p-1.5 rounded-full bg-muted/80 hover:bg-muted transition-colors"
aria-label="Stäng chatt"
>
<X className="h-4 w-4" />
</button>
<ChatPanel />
</div>
</div>
{/* Floating action button */}
<Button
onClick={() => setIsOpen(!isOpen)}
size="icon"
className={cn(
'fixed bottom-20 right-4 md:bottom-4 z-[60] h-14 w-14 rounded-full shadow-lg transition-all duration-300',
isOpen && 'rotate-90'
)}
aria-label={isOpen ? 'Stäng chatt' : 'Öppna chatt'}
>
{isOpen ? (
<X className="h-6 w-6" />
) : (
<MessageCircle className="h-6 w-6" />
)}
</Button>
</>
)
}
+5
View File
@@ -0,0 +1,5 @@
export { ChatWidget } from './ChatWidget'
export { ChatPanel } from './ChatPanel'
export { ChatMessage } from './ChatMessage'
export { ChatInput } from './ChatInput'
export { useChatStream } from './useChatStream'
+215
View File
@@ -0,0 +1,215 @@
'use client'
import { useState, useCallback, useRef } from 'react'
import type { ChatMessage, SourceReference, ChatSession } from '@/types/chat'
interface UseChatStreamOptions {
onError?: (error: string) => void
}
interface UseChatStreamReturn {
messages: ChatMessage[]
isLoading: boolean
isStreaming: boolean
sessionId: string | null
error: string | null
sendMessage: (message: string) => Promise<void>
loadSession: (sessionId: string) => Promise<void>
clearChat: () => void
setSessionId: (id: string | null) => void
}
export function useChatStream(options: UseChatStreamOptions = {}): UseChatStreamReturn {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isStreaming, setIsStreaming] = useState(false)
const [sessionId, setSessionId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const abortControllerRef = useRef<AbortController | null>(null)
const sendMessage = useCallback(async (message: string) => {
if (!message.trim() || isLoading || isStreaming) return
setError(null)
setIsLoading(true)
// Add user message immediately
const userMessage: ChatMessage = {
id: `temp-${Date.now()}`,
session_id: sessionId || '',
user_id: '',
role: 'user',
content: message.trim(),
sources: [],
created_at: new Date().toISOString(),
}
setMessages((prev) => [...prev, userMessage])
// Add placeholder for assistant response
const assistantPlaceholder: ChatMessage = {
id: `temp-assistant-${Date.now()}`,
session_id: sessionId || '',
user_id: '',
role: 'assistant',
content: '',
sources: [],
created_at: new Date().toISOString(),
}
setMessages((prev) => [...prev, assistantPlaceholder])
try {
abortControllerRef.current = new AbortController()
const response = await fetch('/api/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: message.trim(),
session_id: sessionId,
}),
signal: abortControllerRef.current.signal,
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to send message')
}
setIsLoading(false)
setIsStreaming(true)
const reader = response.body?.getReader()
if (!reader) throw new Error('No response body')
const decoder = new TextDecoder()
let accumulatedContent = ''
let sources: SourceReference[] = []
let newSessionId = sessionId
let messageId: string | null = null
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value)
const lines = chunk.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6))
if (data.type === 'session') {
newSessionId = data.session_id
setSessionId(data.session_id)
} else if (data.type === 'content') {
accumulatedContent += data.content
setMessages((prev) => {
const newMessages = [...prev]
const lastMsg = newMessages[newMessages.length - 1]
if (lastMsg.role === 'assistant') {
lastMsg.content = accumulatedContent
}
return newMessages
})
} else if (data.type === 'sources') {
sources = data.sources
setMessages((prev) => {
const newMessages = [...prev]
const lastMsg = newMessages[newMessages.length - 1]
if (lastMsg.role === 'assistant') {
lastMsg.sources = sources
}
return newMessages
})
} else if (data.type === 'done') {
messageId = data.message_id
// Update the message IDs with real values
setMessages((prev) => {
const newMessages = [...prev]
const lastMsg = newMessages[newMessages.length - 1]
if (lastMsg.role === 'assistant' && messageId) {
lastMsg.id = messageId
lastMsg.session_id = newSessionId || ''
}
return newMessages
})
} else if (data.type === 'error') {
throw new Error(data.error)
}
} catch (parseError) {
// Skip invalid JSON lines
}
}
}
}
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
// User cancelled, remove the placeholder
setMessages((prev) => prev.slice(0, -1))
} else {
const errorMessage = err instanceof Error ? err.message : 'An error occurred'
setError(errorMessage)
options.onError?.(errorMessage)
// Update placeholder with error
setMessages((prev) => {
const newMessages = [...prev]
const lastMsg = newMessages[newMessages.length - 1]
if (lastMsg.role === 'assistant') {
lastMsg.content = 'Ett fel uppstod. Försök igen.'
}
return newMessages
})
}
} finally {
setIsLoading(false)
setIsStreaming(false)
abortControllerRef.current = null
}
}, [sessionId, isLoading, isStreaming, options])
const loadSession = useCallback(async (id: string) => {
setIsLoading(true)
setError(null)
try {
const response = await fetch(`/api/chat/sessions/${id}`)
if (!response.ok) {
throw new Error('Failed to load session')
}
const data = await response.json()
setSessionId(id)
setMessages(data.messages || [])
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load session'
setError(errorMessage)
options.onError?.(errorMessage)
} finally {
setIsLoading(false)
}
}, [options])
const clearChat = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
setMessages([])
setSessionId(null)
setError(null)
}, [])
return {
messages,
isLoading,
isStreaming,
sessionId,
error,
sendMessage,
loadSession,
clearChat,
setSessionId,
}
}
@@ -0,0 +1,100 @@
'use client'
import { cn } from '@/lib/utils'
import type { ConfidenceLevel } from '@/types'
import { CheckCircle2, AlertCircle, HelpCircle, XCircle } from 'lucide-react'
interface ConfidenceIndicatorProps {
level: ConfidenceLevel
showLabel?: boolean
size?: 'sm' | 'md'
}
const config: Record<ConfidenceLevel, {
icon: typeof CheckCircle2
color: string
bgColor: string
label: string
}> = {
high: {
icon: CheckCircle2,
color: 'text-green-600',
bgColor: 'bg-green-100',
label: 'Hög säkerhet',
},
medium: {
icon: AlertCircle,
color: 'text-yellow-600',
bgColor: 'bg-yellow-100',
label: 'Medel säkerhet',
},
low: {
icon: HelpCircle,
color: 'text-orange-600',
bgColor: 'bg-orange-100',
label: 'Låg säkerhet',
},
missing: {
icon: XCircle,
color: 'text-gray-400',
bgColor: 'bg-gray-100',
label: 'Saknas',
},
}
export function ConfidenceIndicator({
level,
showLabel = false,
size = 'md',
}: ConfidenceIndicatorProps) {
const { icon: Icon, color, bgColor, label } = config[level]
const iconSize = size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4'
if (showLabel) {
return (
<span
className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
bgColor,
color
)}
>
<Icon className={iconSize} />
{label}
</span>
)
}
return (
<span title={label}>
<Icon className={cn(iconSize, color)} />
</span>
)
}
interface ConfidenceFieldProps {
label: string
confidence: ConfidenceLevel
children: React.ReactNode
className?: string
}
export function ConfidenceField({
label,
confidence,
children,
className,
}: ConfidenceFieldProps) {
return (
<div className={cn('space-y-1', className)}>
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-muted-foreground">
{label}
</label>
<ConfidenceIndicator level={confidence} size="sm" />
</div>
{children}
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,68 @@
'use client'
import { cn } from '@/lib/utils'
import type { ExtractionStatus } from '@/types'
import { Loader2, CheckCircle2, XCircle, Clock, SkipForward } from 'lucide-react'
interface ExtractionStatusBadgeProps {
status: ExtractionStatus
className?: string
}
const statusConfig: Record<ExtractionStatus, {
icon: typeof Loader2
color: string
bgColor: string
label: string
animate?: boolean
}> = {
pending: {
icon: Clock,
color: 'text-gray-600',
bgColor: 'bg-gray-100',
label: 'Väntar',
},
processing: {
icon: Loader2,
color: 'text-blue-600',
bgColor: 'bg-blue-100',
label: 'Analyserar...',
animate: true,
},
completed: {
icon: CheckCircle2,
color: 'text-green-600',
bgColor: 'bg-green-100',
label: 'Klar',
},
failed: {
icon: XCircle,
color: 'text-red-600',
bgColor: 'bg-red-100',
label: 'Misslyckades',
},
skipped: {
icon: SkipForward,
color: 'text-gray-500',
bgColor: 'bg-gray-100',
label: 'Hoppade över',
},
}
export function ExtractionStatusBadge({ status, className }: ExtractionStatusBadgeProps) {
const { icon: Icon, color, bgColor, label, animate } = statusConfig[status]
return (
<span
className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium',
bgColor,
color,
className
)}
>
<Icon className={cn('h-3.5 w-3.5', animate && 'animate-spin')} />
{label}
</span>
)
}
+321
View File
@@ -0,0 +1,321 @@
'use client'
import { useState } from 'react'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
import { Loader2, CheckCircle, XCircle } from 'lucide-react'
import type { CreateCustomerInput, CustomerType } from '@/types'
const schema = z.object({
name: z.string().min(1, 'Namn krävs'),
customer_type: z.enum(['individual', 'swedish_business', 'eu_business', 'non_eu_business']),
email: z.string().email('Ogiltig e-postadress').optional().or(z.literal('')),
phone: z.string().optional(),
address_line1: z.string().optional(),
address_line2: z.string().optional(),
postal_code: z.string().optional(),
city: z.string().optional(),
country: z.string().optional(),
org_number: z.string().optional(),
vat_number: z.string().optional(),
default_payment_terms: z.number().min(1).optional(),
notes: z.string().optional(),
})
type FormData = z.infer<typeof schema>
interface CustomerFormProps {
onSubmit: (data: CreateCustomerInput) => Promise<void>
isLoading: boolean
initialData?: Partial<FormData>
}
export default function CustomerForm({
onSubmit,
isLoading,
initialData,
}: CustomerFormProps) {
const { toast } = useToast()
const [isValidatingVat, setIsValidatingVat] = useState(false)
const [vatValidationResult, setVatValidationResult] = useState<{
valid: boolean
name?: string
} | null>(null)
const {
register,
handleSubmit,
watch,
control,
setValue,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
name: initialData?.name || '',
customer_type: initialData?.customer_type || 'swedish_business',
email: initialData?.email || '',
phone: initialData?.phone || '',
address_line1: initialData?.address_line1 || '',
postal_code: initialData?.postal_code || '',
city: initialData?.city || '',
country: initialData?.country || 'Sweden',
org_number: initialData?.org_number || '',
vat_number: initialData?.vat_number || '',
default_payment_terms: initialData?.default_payment_terms || 30,
notes: initialData?.notes || '',
},
})
const customerType = watch('customer_type')
const vatNumber = watch('vat_number')
const handleValidateVat = async () => {
if (!vatNumber) return
setIsValidatingVat(true)
setVatValidationResult(null)
try {
const response = await fetch('/api/vat/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vat_number: vatNumber }),
})
const result = await response.json()
setVatValidationResult({
valid: result.valid,
name: result.name,
})
if (result.valid && result.name) {
toast({
title: 'VAT-nummer verifierat',
description: `Företag: ${result.name}`,
})
} else if (!result.valid) {
toast({
title: 'Verifiering misslyckades',
description: result.error || 'VAT-numret kunde inte verifieras',
variant: 'destructive',
})
}
} catch {
toast({
title: 'Fel',
description: 'Kunde inte verifiera VAT-nummer',
variant: 'destructive',
})
} finally {
setIsValidatingVat(false)
}
}
const onFormSubmit = (data: FormData) => {
onSubmit({
...data,
email: data.email || undefined,
})
}
return (
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-6">
{/* Customer Type */}
<div className="space-y-2">
<Label>Kundtyp *</Label>
<Controller
name="customer_type"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Välj kundtyp" />
</SelectTrigger>
<SelectContent>
<SelectItem value="individual">Privatperson (Sverige)</SelectItem>
<SelectItem value="swedish_business">Svenskt företag</SelectItem>
<SelectItem value="eu_business">EU-företag</SelectItem>
<SelectItem value="non_eu_business">Företag utanför EU</SelectItem>
</SelectContent>
</Select>
)}
/>
<p className="text-xs text-muted-foreground">
Kundtypen påverkar hur moms hanteras på fakturor
</p>
</div>
{/* Name */}
<div className="space-y-2">
<Label htmlFor="name">Namn *</Label>
<Input
id="name"
placeholder="Företagsnamn eller personnamn"
{...register('name')}
/>
{errors.name && (
<p className="text-sm text-destructive">{errors.name.message}</p>
)}
</div>
{/* Contact */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="email">E-post</Label>
<Input
id="email"
type="email"
placeholder="namn@foretag.se"
{...register('email')}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone">Telefon</Label>
<Input
id="phone"
placeholder="+46 70 123 45 67"
{...register('phone')}
/>
</div>
</div>
{/* Address */}
<div className="space-y-4">
<h3 className="font-medium">Adress</h3>
<div className="space-y-2">
<Label htmlFor="address_line1">Gatuadress</Label>
<Input
id="address_line1"
placeholder="Storgatan 1"
{...register('address_line1')}
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="postal_code">Postnummer</Label>
<Input
id="postal_code"
placeholder="123 45"
{...register('postal_code')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="city">Ort</Label>
<Input
id="city"
placeholder="Stockholm"
{...register('city')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="country">Land</Label>
<Input
id="country"
placeholder="Sweden"
{...register('country')}
/>
</div>
</div>
</div>
{/* Business info */}
{customerType !== 'individual' && (
<div className="space-y-4 pt-4 border-t">
<h3 className="font-medium">Företagsuppgifter</h3>
<div className="space-y-2">
<Label htmlFor="org_number">Organisationsnummer</Label>
<Input
id="org_number"
placeholder="XXXXXX-XXXX"
{...register('org_number')}
/>
</div>
{(customerType === 'eu_business' || customerType === 'swedish_business') && (
<div className="space-y-2">
<Label htmlFor="vat_number">VAT-nummer (momsreg.nr)</Label>
<div className="flex gap-2">
<Input
id="vat_number"
placeholder={customerType === 'eu_business' ? 'DE123456789' : 'SE123456789001'}
{...register('vat_number')}
className="flex-1"
/>
{customerType === 'eu_business' && (
<Button
type="button"
variant="outline"
onClick={handleValidateVat}
disabled={!vatNumber || isValidatingVat}
>
{isValidatingVat ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : vatValidationResult?.valid ? (
<CheckCircle className="h-4 w-4 text-success" />
) : vatValidationResult?.valid === false ? (
<XCircle className="h-4 w-4 text-destructive" />
) : (
'Verifiera'
)}
</Button>
)}
</div>
{customerType === 'eu_business' && (
<p className="text-xs text-muted-foreground">
Verifiera VAT-numret för att kunna fakturera med omvänd skattskyldighet (0% moms)
</p>
)}
</div>
)}
</div>
)}
{/* Payment terms */}
<div className="space-y-2">
<Label htmlFor="payment_terms">Betalningsvillkor (dagar)</Label>
<Input
id="payment_terms"
type="number"
{...register('default_payment_terms', { valueAsNumber: true })}
/>
</div>
{/* Notes */}
<div className="space-y-2">
<Label htmlFor="notes">Anteckningar</Label>
<Textarea
id="notes"
placeholder="Interna anteckningar om kunden..."
{...register('notes')}
/>
</div>
{/* Submit */}
<div className="flex justify-end gap-2">
<Button type="submit" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
'Spara kund'
)}
</Button>
</div>
</form>
)
}
+532
View File
@@ -0,0 +1,532 @@
'use client'
import { useState, useEffect } from 'react'
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 {
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 { CampaignsWidget } from '@/components/campaigns/CampaignsWidget'
import { TikTokStatsWidget } from '@/components/tiktok'
import NewUserChecklist from '@/components/onboarding/NewUserChecklist'
import {
TrendingUp,
TrendingDown,
Receipt,
ArrowLeftRight,
ChevronDown,
Gift,
ArrowRight,
Megaphone,
Camera,
HelpCircle,
Users,
FileText,
} from 'lucide-react'
import type { CompanySettings, EntityType, MileageEntry, SchablonavdragSettings, GiftSummary, Deadline, Campaign, TikTokStatsSummary, ReceiptQueueSummary, OnboardingProgress } from '@/types'
interface DashboardContentProps {
firstName?: string | null
settings: CompanySettings | null
summary: {
ytd: { income: number; expenses: number; net: number }
mtd: { income: number; expenses: number; net: number }
uncategorizedCount: number
uncategorizedIncome: number
uncategorizedExpenses: number
unpaidInvoicesCount: number
unpaidInvoicesTotal: number
unpaidVatTotal: number
overdueInvoicesCount: number
bankBalance: number | null
mileageEntries: MileageEntry[]
giftSummary: GiftSummary | null
deadlines: Deadline[]
campaigns: Campaign[]
receiptQueue: ReceiptQueueSummary | null
}
onboardingProgress?: OnboardingProgress
}
export default function DashboardContent({ firstName, settings, summary, onboardingProgress }: DashboardContentProps) {
const [tiktokStats, setTiktokStats] = useState<TikTokStatsSummary | null>(null)
const [isTiktokSyncing, setIsTiktokSyncing] = useState(false)
const [showAllAlerts, setShowAllAlerts] = useState(false)
useEffect(() => {
fetchTikTokStats()
}, [])
const fetchTikTokStats = async () => {
try {
const response = await fetch('/api/tiktok/stats')
const data = await response.json()
if (data.summary) {
setTiktokStats(data.summary)
}
} catch (error) {
// TikTok not connected or error - that's fine
}
}
const handleTikTokSync = async () => {
setIsTiktokSyncing(true)
try {
const accountsResponse = await fetch('/api/tiktok/accounts')
const accountsData = await accountsResponse.json()
const activeAccount = accountsData.accounts?.find((a: { status: string }) => a.status === 'active')
if (activeAccount) {
await fetch('/api/tiktok/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account_id: activeAccount.id, sync_type: 'full' }),
})
await fetchTikTokStats()
}
} catch (error) {
console.error('TikTok sync failed:', error)
}
setIsTiktokSyncing(false)
}
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const preliminaryTaxMonthly = settings?.preliminary_tax_monthly || 0
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 giftTaxableValue = summary.giftSummary?.taxable_value || 0
const giftDeductibleValue = summary.giftSummary?.deductible_value || 0
const netGiftTaxableIncome = giftTaxableValue - giftDeductibleValue
const totalTaxableIncome = summary.ytd.net + netGiftTaxableIncome
const taxEstimate =
entityType === 'enskild_firma'
? calculateEFTax(totalTaxableIncome, preliminaryTaxPaidYTD, schablonavdragSummary, summary.unpaidVatTotal)
: calculateABTax(totalTaxableIncome, 0, preliminaryTaxPaidYTD, summary.unpaidVatTotal)
const taxWarning = getEnhancedTaxWarningStatus(taxEstimate, preliminaryTaxMonthly, currentMonth)
const formatLargeNumber = (amount: number) => {
return new Intl.NumberFormat('sv-SE', {
style: 'decimal',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount)
}
const isNewUser = onboardingProgress && !onboardingProgress.hasInvoices && !onboardingProgress.hasCustomers
// Build alert items for "Att hantera" section
const alertItems: React.ReactNode[] = []
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>
<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>
<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" />
</div>
</CardContent>
</Card>
</Link>
)
}
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>
<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>
</div>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground group-hover:translate-x-0.5 transition-all" />
</div>
</CardContent>
</Card>
</Link>
)
}
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>
<div>
<p className="font-medium text-sm">Transaktioner</p>
<p className="text-sm 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" />
</div>
</CardContent>
</Card>
</Link>
)
}
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>
<div>
<p className="font-medium text-sm">Kvitton</p>
<p className="text-sm 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" />
</div>
</CardContent>
</Card>
</Link>
)
}
if (summary.giftSummary && summary.giftSummary.total_count > 0) {
alertItems.push(
<Link key="gifts" href="/gifts" className="group">
<Card className="h-full border-l-4 border-l-accent 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-accent/10">
<Gift className="h-4 w-4 text-accent" />
</div>
<div>
<p className="font-medium text-sm">Gåvor & Förmåner</p>
<p className="text-sm text-muted-foreground mt-0.5">
{summary.giftSummary.total_count} produkter
</p>
{summary.giftSummary.taxable_count > 0 && (
<Badge variant="destructive" className="mt-1.5">
{formatCurrency(summary.giftSummary.taxable_value)} skattepliktig
</Badge>
)}
</div>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground group-hover:translate-x-0.5 transition-all" />
</div>
</CardContent>
</Card>
</Link>
)
}
const MAX_VISIBLE_ALERTS = 3
const visibleAlerts = showAllAlerts ? alertItems : alertItems.slice(0, MAX_VISIBLE_ALERTS)
const hasMoreAlerts = alertItems.length > MAX_VISIBLE_ALERTS
// Quick action items
const quickActions = [
{ href: '/invoices/new', icon: Receipt, label: 'Ny faktura', desc: 'Skapa och skicka', accent: true },
{ href: '/receipts/scan', icon: Camera, label: 'Skanna kvitto', desc: 'Fotografera & spara' },
{ href: '/campaigns/new', icon: Megaphone, label: 'Nytt samarbete', desc: 'Spåra innehåll' },
{ href: '/customers', icon: Users, label: 'Ny kund', desc: 'Lägg till kunduppgifter' },
{ href: '/transactions', icon: ArrowLeftRight, label: 'Transaktioner', desc: 'Kategorisera' },
]
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>
{/* TODO section - actionable items */}
{(() => {
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' }[] = []
if (passedDeadlines.length > 0) {
todoItems.push({ label: 'passerade deadlines', href: '/calendar', count: passedDeadlines.length, variant: 'destructive' })
}
if (summary.overdueInvoicesCount > 0) {
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' })
}
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' })
}
if (todoItems.length === 0) return null
return (
<section className="mb-8">
<div className="flex flex-wrap gap-2">
{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"
>
{item.count} {item.label}
</Badge>
</Link>
))}
</div>
</section>
)
})()}
{/* New user checklist */}
{onboardingProgress && (
<section className="mb-8">
<NewUserChecklist
hasCustomers={onboardingProgress.hasCustomers}
hasInvoices={onboardingProgress.hasInvoices}
hasBankConnected={onboardingProgress.hasBankConnected}
hasReceipts={onboardingProgress.hasReceipts}
/>
</section>
)}
{/* Upcoming deadlines - prominent placement */}
{summary.deadlines && summary.deadlines.length > 0 && (
<section className="mb-8">
<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">
{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>
</Link>
)
})}
</div>
</section>
{/* F-skatt warning */}
<section id="fskatt-section" className="mb-8">
<FSkattWarningCard
warningStatus={taxWarning}
onAdjustClick={() => { window.location.href = '/settings' }}
/>
</section>
{/* TikTok stats widget */}
{tiktokStats && (
<section className="mb-8">
<TikTokStatsWidget
stats={tiktokStats}
onSync={handleTikTokSync}
isSyncing={isTiktokSyncing}
/>
</section>
)}
{/* Active campaigns */}
{summary.campaigns && summary.campaigns.length > 0 && (
<section className="mb-8">
<CampaignsWidget campaigns={summary.campaigns} />
</section>
)}
{/* Alerts section - with urgency indicators and limit */}
{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">
{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"
>
Visa alla ({alertItems.length})
<ChevronDown className="h-3 w-3" />
</button>
)}
</section>
)}
{/* 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>
</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" />
<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>
</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" />
<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>
</div>
</CardContent>
</Card>
</div>
</section>
</div>
)
}
+453
View File
@@ -0,0 +1,453 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
LayoutDashboard,
Receipt,
Users,
ArrowLeftRight,
BookOpen,
BarChart3,
Settings,
LogOut,
Calculator,
Gift,
Upload,
Calendar,
Megaphone,
FileUp,
Camera,
Menu,
X,
HelpCircle,
ChevronDown,
ChevronUp,
Wallet,
} from 'lucide-react'
import type { EntityType } from '@/types'
interface DashboardNavProps {
companyName: string
entityType: EntityType
}
interface NavItem {
href: string
label: string
icon: typeof LayoutDashboard
group: string
modes?: EntityType[] // If set, only visible for these entity types. If not set, visible to all.
}
// All nav items for sidebar and mobile drawer
const navItems: NavItem[] = [
{ href: '/', label: 'Översikt', icon: LayoutDashboard, group: 'main' },
{ href: '/calendar', label: 'Kalender', icon: Calendar, group: 'main' },
{ href: '/campaigns', label: 'Samarbeten', icon: Megaphone, group: 'main' },
{ href: '/campaigns/import', label: 'Importera avtal', icon: FileUp, group: 'main' },
{ href: '/analytics', label: 'Analytics', icon: BarChart3, group: 'main' },
{ href: '/shadow-ledger', label: 'Utbetalningar', icon: Wallet, group: 'finans', modes: ['light'] },
{ href: '/invoices', label: 'Fakturor', icon: Receipt, group: 'finans', modes: ['enskild_firma', 'aktiebolag'] },
{ href: '/customers', label: 'Kunder', icon: Users, group: 'finans', modes: ['enskild_firma', 'aktiebolag'] },
{ href: '/transactions', label: 'Transaktioner', icon: ArrowLeftRight, group: 'finans' },
{ href: '/receipts', label: 'Kvitton', icon: Camera, group: 'finans' },
{ href: '/gifts', label: 'Gåvor', icon: Gift, group: 'finans' },
{ href: '/deductions', label: 'Avdrag', icon: Calculator, group: 'finans', modes: ['enskild_firma', 'aktiebolag'] },
{ href: '/bookkeeping', label: 'Bokföring', icon: BookOpen, group: 'finans', modes: ['enskild_firma', 'aktiebolag'] },
{ href: '/import', label: 'Importera', icon: Upload, group: 'övrigt', modes: ['enskild_firma', 'aktiebolag'] },
{ href: '/reports', label: 'Rapporter', icon: BarChart3, group: 'övrigt', modes: ['enskild_firma', 'aktiebolag'] },
{ href: '/help', label: 'Hjälp', icon: HelpCircle, group: 'övrigt' },
{ href: '/settings', label: 'Inställningar', icon: Settings, group: 'övrigt' },
]
const groupLabels: Record<string, string> = {
main: 'Huvudmeny',
finans: 'Finans',
övrigt: 'Övrigt',
}
export default function DashboardNav({ companyName, entityType }: DashboardNavProps) {
const pathname = usePathname()
const router = useRouter()
const supabase = createClient()
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const [isOvrigtExpanded, setIsOvrigtExpanded] = useState(false)
const handleLogout = async () => {
await supabase.auth.signOut()
router.push('/login')
}
const isActive = (href: string) => {
if (href === '/') {
return pathname === '/'
}
return pathname.startsWith(href)
}
const closeMobileMenu = () => setIsMobileMenuOpen(false)
// Filter nav items by entity type
const filteredItems = navItems.filter(item =>
!item.modes || item.modes.includes(entityType)
)
const mainItems = filteredItems.filter(i => i.group === 'main')
const finansItems = filteredItems.filter(i => i.group === 'finans')
const övrigtItems = filteredItems.filter(i => i.group === 'övrigt')
// Mobile nav items - light mode shows Utbetalningar instead of Fakturor
const mobileNavItems = entityType === 'light'
? [
{ href: '/', label: 'Översikt', icon: LayoutDashboard },
{ href: '/campaigns', label: 'Samarbeten', icon: Megaphone },
{ href: '/receipts/scan', label: 'Skanna', icon: Camera, isScan: true },
{ href: '/shadow-ledger', label: 'Utbetalningar', icon: Wallet },
]
: [
{ href: '/', label: 'Översikt', icon: LayoutDashboard },
{ href: '/campaigns', label: 'Samarbeten', icon: Megaphone },
{ href: '/receipts/scan', label: 'Skanna', icon: Camera, isScan: true },
{ href: '/invoices', label: 'Fakturor', icon: Receipt },
]
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">
{companyName}
</h1>
<p className="text-xs text-muted-foreground mt-0.5 tracking-wide uppercase">
{entityType === 'light' ? 'Egenanställd' : '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">
{groupLabels.main}
</p>
<div className="space-y-0.5">
{mainItems.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" />
{/* Finans group */}
<div className="mb-4">
<p className="px-3 mb-1 text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
{groupLabels.finans}
</p>
<div className="space-y-0.5">
{finansItems.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"
>
<span>{groupLabels.övrigt}</span>
{isOvrigtExpanded ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
</button>
{isOvrigtExpanded && (
<div className="space-y-0.5 animate-fade-in">
{övrigtItems.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>
</nav>
</div>
{/* Logout button */}
<div className="flex-shrink-0 p-4 border-t border-border/40">
<Button
variant="ghost"
className="w-full justify-start text-muted-foreground hover:text-foreground text-sm"
onClick={handleLogout}
>
<LogOut className="mr-3 h-4 w-4" />
Logga ut
</Button>
</div>
</div>
</aside>
{/* Mobile bottom navigation */}
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-card/98 backdrop-blur-sm border-t border-border/40" aria-label="Mobilnavigation">
<div className="flex items-center justify-around h-16 px-2">
{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
key={item.href}
href={item.href}
className={cn(
'flex flex-col items-center justify-center flex-1 h-full text-xs transition-colors duration-200',
active
? 'text-primary'
: 'text-muted-foreground'
)}
>
<Icon className={cn(
"h-5 w-5 mb-1",
active && "text-primary"
)} />
<span className={cn(
"truncate",
active && "font-medium"
)}>{item.label}</span>
</Link>
)
})}
{/* Menu button */}
<button
onClick={() => setIsMobileMenuOpen(true)}
aria-label="Öppna meny"
className="flex flex-col items-center justify-center flex-1 h-full text-xs text-muted-foreground transition-colors duration-200"
>
<Menu className="h-5 w-5 mb-1" />
<span>Meny</span>
</button>
</div>
</nav>
{/* Mobile menu drawer */}
{isMobileMenuOpen && (
<>
{/* Backdrop */}
<div
className="md:hidden fixed inset-0 bg-background/80 backdrop-blur-sm z-50"
onClick={closeMobileMenu}
aria-hidden="true"
/>
{/* Drawer */}
<div
className="md:hidden fixed right-0 top-0 bottom-0 w-72 bg-card border-l border-border/40 z-50 overflow-y-auto animate-in slide-in-from-right duration-300"
role="dialog"
aria-label="Navigeringsmeny"
>
<div className="p-4 border-b border-border/40 flex items-center justify-between">
<div>
<p className="font-medium truncate">{companyName}</p>
<p className="text-xs text-muted-foreground">Meny</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={closeMobileMenu}
aria-label="Stäng meny"
>
<X className="h-5 w-5" />
</Button>
</div>
{/* Grouped navigation */}
<div className="p-2">
{/* Main section */}
<div className="mb-4">
<p className="px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Huvudmeny
</p>
{mainItems.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>
{/* Finance section */}
<div className="mb-4">
<p className="px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Finans
</p>
{finansItems.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">
Övrigt
</p>
{övrigtItems.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>
{/* Logout */}
<div className="pt-4 border-t border-border/40">
<Button
variant="ghost"
className="w-full justify-start text-muted-foreground hover:text-foreground"
onClick={() => {
closeMobileMenu()
handleLogout()
}}
>
<LogOut className="mr-3 h-5 w-5" />
Logga ut
</Button>
</div>
</div>
</div>
</>
)}
</>
)
}
+172
View File
@@ -0,0 +1,172 @@
'use client'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { formatCurrency } from '@/lib/utils'
import {
AlertTriangle,
Info,
CheckCircle,
AlertCircle,
TrendingUp,
ChevronDown,
ChevronUp,
} from 'lucide-react'
import { useState } from 'react'
import type { TaxWarningStatus } from '@/types'
interface FSkattWarningCardProps {
warningStatus: TaxWarningStatus
onAdjustClick?: () => void
}
export default function FSkattWarningCard({
warningStatus,
onAdjustClick,
}: FSkattWarningCardProps) {
const [isExpanded, setIsExpanded] = useState(false)
// Don't show if everything is safe
if (warningStatus.level === 'safe') {
return null
}
// Icon and colors based on warning level
const levelConfig = {
safe: {
icon: CheckCircle,
iconColor: 'text-success',
bgColor: 'bg-success/5',
borderColor: 'border-success/50',
badgeVariant: 'outline' as const,
},
info: {
icon: Info,
iconColor: 'text-blue-500',
bgColor: 'bg-blue-50 dark:bg-blue-950/20',
borderColor: 'border-blue-200 dark:border-blue-800',
badgeVariant: 'outline' as const,
},
warning: {
icon: AlertTriangle,
iconColor: 'text-warning',
bgColor: 'bg-warning/5',
borderColor: 'border-warning/50',
badgeVariant: 'warning' as const,
},
danger: {
icon: AlertCircle,
iconColor: 'text-destructive',
bgColor: 'bg-destructive/5',
borderColor: 'border-destructive/50',
badgeVariant: 'destructive' as const,
},
}
const config = levelConfig[warningStatus.level]
const Icon = config.icon
return (
<Card className={`${config.borderColor} ${config.bgColor}`}>
<CardContent className="pt-6">
<div className="space-y-4">
{/* Main warning */}
<div className="flex items-start gap-3">
<Icon className={`h-5 w-5 flex-shrink-0 ${config.iconColor}`} />
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<InfoTooltip
content={
<div className="space-y-2">
<p className="font-medium">Vad betyder detta?</p>
<p>Vi jämför din beräknade skatt med vad du betalat in via F-skatt hittills i år.</p>
<p className="text-xs text-muted-foreground">Om du betalat för lite kan du få restskatt. Justera din månatliga F-skatt för att undvika överraskningar.</p>
</div>
}
side="right"
>
<p className="font-medium">F-skatt varning</p>
</InfoTooltip>
<Badge variant={config.badgeVariant}>
{Math.round(warningStatus.percentageDifference * 100)}% skillnad
</Badge>
</div>
<p className="text-sm text-muted-foreground">{warningStatus.message}</p>
</div>
</div>
{/* Expandable projection section */}
{warningStatus.yearEndProjection && (
<div className="pt-2">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center justify-between w-full text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<span className="flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
Årsprognos
</span>
{isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
{isExpanded && (
<div className="mt-3 p-3 rounded-lg bg-background/50 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Beräknad årsskatt</span>
<span className="font-medium">
{formatCurrency(warningStatus.yearEndProjection.estimatedTotalTax)}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Projicerad inbetalning</span>
<span className="font-medium">
{formatCurrency(warningStatus.yearEndProjection.projectedPreliminaryPayments)}
</span>
</div>
<div className="flex justify-between text-sm pt-2 border-t">
<span className="font-medium">Projicerad skillnad</span>
<span
className={
warningStatus.yearEndProjection.projectedDifference > 0
? 'text-destructive font-medium'
: 'text-success font-medium'
}
>
{warningStatus.yearEndProjection.projectedDifference > 0 ? '+' : ''}
{formatCurrency(warningStatus.yearEndProjection.projectedDifference)}
</span>
</div>
</div>
)}
</div>
)}
{/* Recommendation */}
{warningStatus.recommendation && (
<div className="p-3 rounded-lg bg-background/50 border border-border/50">
<p className="text-sm">
<span className="font-medium">Rekommendation: </span>
{warningStatus.recommendation}
</p>
</div>
)}
{/* Action button */}
{onAdjustClick && (
<div className="pt-2">
<Button variant="outline" size="sm" onClick={onAdjustClick}>
Justera F-skatt
</Button>
</div>
)}
</div>
</CardContent>
</Card>
)
}
+79
View File
@@ -0,0 +1,79 @@
'use client'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { cn, formatCurrency } from '@/lib/utils'
import { Gift, ArrowRight, CheckCircle2 } from 'lucide-react'
interface GiftTaxDebtCardProps {
virtualTaxDebt: number
taxableGiftCount: number
effectiveRate: number
}
export default function GiftTaxDebtCard({
virtualTaxDebt,
taxableGiftCount,
effectiveRate,
}: GiftTaxDebtCardProps) {
const hasDebt = virtualTaxDebt > 0
return (
<Link href="/gifts" className="group block">
<Card className="h-full hover:border-primary/30 transition-colors">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Gift className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-lg">G\u00e5voskatt</CardTitle>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground group-hover:translate-x-0.5 transition-all" />
</div>
</CardHeader>
<CardContent>
{hasDebt ? (
<div className="space-y-3">
<div>
<p className="font-display text-2xl font-medium tabular-nums text-amber-600">
{formatCurrency(virtualTaxDebt)}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
Virtuell skatteskuld
</p>
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<div>
<span className="tabular-nums font-medium text-foreground">
{taxableGiftCount}
</span>{' '}
skattepliktiga g\u00e5vor
</div>
<span className="text-border">{'\u00b7'}</span>
<div>
<span className="tabular-nums font-medium text-foreground">
{(effectiveRate * 100).toFixed(1)}%
</span>{' '}
skattesats
</div>
</div>
</div>
) : (
<div className="flex items-center gap-3 py-2">
<CheckCircle2 className="h-5 w-5 text-emerald-500 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-emerald-700">
Ingen g\u00e5voskatt att betala
</p>
<p className="text-xs text-muted-foreground mt-0.5">
Du har inga skattepliktiga g\u00e5vor registrerade i \u00e5r
</p>
</div>
</div>
)}
</CardContent>
</Card>
</Link>
)
}
+139
View File
@@ -0,0 +1,139 @@
'use client'
import { useMemo } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
import {
AreaChart,
Area,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
} from 'recharts'
interface MonthlyData {
month: string
income: number
expenses: number
}
interface IncomeChartProps {
monthlyData: MonthlyData[]
}
export function IncomeChart({ monthlyData }: IncomeChartProps) {
const { trendPercent, trendDirection } = useMemo(() => {
if (monthlyData.length < 2) return { trendPercent: 0, trendDirection: 'flat' as const }
const current = monthlyData[monthlyData.length - 1]
const previous = monthlyData[monthlyData.length - 2]
if (previous.income === 0) return { trendPercent: 0, trendDirection: 'flat' as const }
const percent = Math.round(((current.income - previous.income) / previous.income) * 100)
return {
trendPercent: Math.abs(percent),
trendDirection: percent > 0 ? 'up' as const : percent < 0 ? 'down' as const : 'flat' as const,
}
}, [monthlyData])
if (monthlyData.length === 0) return null
const formatAmount = (value: number) => {
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`
if (value >= 1000) return `${Math.round(value / 1000)}k`
return String(value)
}
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm text-muted-foreground">Intäkter vs kostnader</span>
{trendDirection !== 'flat' && (
<div className={`flex items-center gap-1 text-xs font-medium ${trendDirection === 'up' ? 'text-success' : 'text-destructive'}`}>
{trendDirection === 'up' ? (
<TrendingUp className="h-3 w-3" />
) : (
<TrendingDown className="h-3 w-3" />
)}
{trendPercent}% vs förra månaden
</div>
)}
{trendDirection === 'flat' && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Minus className="h-3 w-3" />
Oförändrat
</div>
)}
</div>
<div className="h-[180px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={monthlyData} margin={{ top: 5, right: 5, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="incomeGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(145, 20%, 36%)" stopOpacity={0.2} />
<stop offset="95%" stopColor="hsl(145, 20%, 36%)" stopOpacity={0} />
</linearGradient>
<linearGradient id="expenseGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(0, 45%, 55%)" stopOpacity={0.1} />
<stop offset="95%" stopColor="hsl(0, 45%, 55%)" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis
dataKey="month"
tick={{ fontSize: 11, fill: 'hsl(30, 8%, 45%)' }}
tickLine={false}
axisLine={false}
/>
<YAxis
tick={{ fontSize: 11, fill: 'hsl(30, 8%, 45%)' }}
tickLine={false}
axisLine={false}
tickFormatter={formatAmount}
/>
<Tooltip
contentStyle={{
backgroundColor: 'hsl(40, 40%, 99%)',
border: '1px solid hsl(35, 20%, 88%)',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number | undefined, name: string | undefined) => {
if (value === undefined) return ['-', name || '']
return [
new Intl.NumberFormat('sv-SE', { style: 'currency', currency: 'SEK', maximumFractionDigits: 0 }).format(value),
name === 'income' ? 'Intäkter' : 'Kostnader',
]
}}
/>
<Area
type="monotone"
dataKey="income"
stroke="hsl(145, 20%, 36%)"
strokeWidth={2}
fill="url(#incomeGradient)"
/>
<Area
type="monotone"
dataKey="expenses"
stroke="hsl(0, 45%, 55%)"
strokeWidth={1.5}
fill="url(#expenseGradient)"
strokeDasharray="4 4"
/>
</AreaChart>
</ResponsiveContainer>
</div>
<div className="flex items-center justify-center gap-6 mt-2 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<div className="w-3 h-0.5 bg-primary rounded" />
Intäkter
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-0.5 bg-destructive rounded border-dashed" style={{ borderTop: '2px dashed hsl(0, 45%, 55%)' }} />
Kostnader
</div>
</div>
</CardContent>
</Card>
)
}
@@ -0,0 +1,134 @@
'use client'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import SafeToSpendGauge from '@/components/dashboard/SafeToSpendGauge'
import SGIShieldWidget from '@/components/dashboard/SGIShieldWidget'
import GiftTaxDebtCard from '@/components/dashboard/GiftTaxDebtCard'
import RecentPayoutsCard from '@/components/dashboard/RecentPayoutsCard'
import {
Banknote,
Camera,
Megaphone,
Gift,
} from 'lucide-react'
interface RecentEntry {
id: string
date: string
description: string | null
gross_amount: number
net_amount: number
service_fee: number
pension_deduction: number
social_fees: number
income_tax_withheld: number
platform_fee: number
type: string
provider: string | null
}
interface LightDashboardContentProps {
firstName: string | null
bankBalance: number | null
giftTaxDebt: number
taxableGiftCount: number
effectiveRate: number
daysSinceLastPayout: number | null
recentEntries: RecentEntry[]
hobbyReserve?: number
}
export default function LightDashboardContent({
firstName,
bankBalance,
giftTaxDebt,
taxableGiftCount,
effectiveRate,
daysSinceLastPayout,
recentEntries,
hobbyReserve = 0,
}: LightDashboardContentProps) {
const greeting = (() => {
const hour = new Date().getHours()
if (hour < 12) return 'Godmorgon'
if (hour < 18) return 'God eftermiddag'
return 'God kv\u00e4ll'
})()
const quickActions = [
{
href: '/shadow-ledger/new',
icon: Banknote,
label: 'Logga utbetalning',
},
{
href: '/receipts/scan',
icon: Camera,
label: 'Skanna kvitto',
},
{
href: '/campaigns/new',
icon: Megaphone,
label: 'Nytt samarbete',
},
{
href: '/gifts',
icon: Gift,
label: 'Ny g\u00e5va',
},
]
return (
<div className="stagger-enter">
{/* Greeting header */}
<header className="mb-10">
<h1 className="font-display text-4xl md:text-5xl font-medium tracking-tight">
{greeting}{firstName ? `, ${firstName}` : ''}!
</h1>
</header>
{/* SafeToSpendGauge - full width */}
<section className="mb-6">
<SafeToSpendGauge
bankBalance={bankBalance ?? 0}
giftTaxDebt={giftTaxDebt}
hobbyReserve={hobbyReserve}
/>
</section>
{/* SGIShieldWidget - full width */}
<section className="mb-6">
<SGIShieldWidget daysSinceLastPayout={daysSinceLastPayout} />
</section>
{/* GiftTaxDebtCard + RecentPayoutsCard side by side on md+ */}
<section className="grid md:grid-cols-2 gap-6 mb-10">
<GiftTaxDebtCard
virtualTaxDebt={giftTaxDebt}
taxableGiftCount={taxableGiftCount}
effectiveRate={effectiveRate}
/>
<RecentPayoutsCard entries={recentEntries} />
</section>
{/* Quick actions row */}
<section className="mb-12">
<h2 className="font-display text-xl font-medium mb-4">Snabb\u00e5tg\u00e4rder</h2>
<div className="flex flex-wrap gap-3">
{quickActions.map((action) => {
const Icon = action.icon
return (
<Link key={action.href} href={action.href}>
<Button variant="outline" size="default" className="gap-2">
<Icon className="h-4 w-4" />
{action.label}
</Button>
</Link>
)
})}
</div>
</section>
</div>
)
}
@@ -0,0 +1,94 @@
'use client'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { formatCurrency, formatDate } from '@/lib/utils'
import { ArrowRight, Banknote, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface RecentPayoutsCardProps {
entries: Array<{
id: string
date: string
description: string | null
gross_amount: number
net_amount: number
service_fee: number
pension_deduction: number
social_fees: number
income_tax_withheld: number
platform_fee: number
type: string
provider: string | null
}>
}
export default function RecentPayoutsCard({ entries }: RecentPayoutsCardProps) {
const displayEntries = entries.slice(0, 5)
const isEmpty = displayEntries.length === 0
return (
<Card className="h-full">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Banknote className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-lg">Senaste utbetalningar</CardTitle>
</div>
{!isEmpty && (
<Link
href="/shadow-ledger"
className="text-sm text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
>
Visa alla
<ArrowRight className="h-3.5 w-3.5" />
</Link>
)}
</div>
</CardHeader>
<CardContent>
{isEmpty ? (
<div className="text-center py-6">
<p className="text-sm text-muted-foreground mb-4">
Inga utbetalningar registrerade
</p>
<Link href="/shadow-ledger/new">
<Button variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" />
Logga utbetalning
</Button>
</Link>
</div>
) : (
<div className="space-y-3">
{displayEntries.map((entry) => (
<div
key={entry.id}
className="flex items-center justify-between py-2 border-b border-border/50 last:border-0"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">
{entry.description || entry.provider || 'Utbetalning'}
</p>
<p className="text-xs text-muted-foreground">
{formatDate(entry.date)}
</p>
</div>
<div className="text-right ml-4 flex-shrink-0">
<p className="text-sm tabular-nums font-medium">
{formatCurrency(entry.net_amount)}
</p>
{entry.gross_amount !== entry.net_amount && (
<p className="text-xs text-muted-foreground tabular-nums">
brutto {formatCurrency(entry.gross_amount)}
</p>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)
}
+107
View File
@@ -0,0 +1,107 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import { Shield, Info } from 'lucide-react'
interface SGIShieldWidgetProps {
daysSinceLastPayout: number | null
}
export default function SGIShieldWidget({
daysSinceLastPayout,
}: SGIShieldWidgetProps) {
const maxDays = 90
const days = daysSinceLastPayout ?? 0
const progress = daysSinceLastPayout !== null ? Math.min((days / maxDays) * 100, 100) : 0
// Determine status
let level: 'green' | 'yellow' | 'red'
let statusMessage: string | null = null
if (daysSinceLastPayout === null) {
level = 'green'
} else if (days < 30) {
level = 'green'
} else if (days < 75) {
level = 'yellow'
statusMessage = 'Varning: Logga uppdrag snart'
} else {
level = 'red'
statusMessage = 'Kritiskt: SGI riskerar nollst\u00e4llning'
}
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Shield
className={cn(
'h-5 w-5',
level === 'green' && 'text-emerald-500',
level === 'yellow' && 'text-amber-500',
level === 'red' && 'text-red-500'
)}
/>
<CardTitle className="text-lg">SGI-skydd</CardTitle>
</div>
{daysSinceLastPayout !== null && (
<span className="text-sm text-muted-foreground tabular-nums">
{days} dagar sedan senast
</span>
)}
</div>
</CardHeader>
<CardContent>
{daysSinceLastPayout === null ? (
<div className="flex items-start gap-3 py-2">
<Info className="h-4 w-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm text-muted-foreground">
Inga utbetalningar registrerade \u00e4nnu
</p>
<p className="text-xs text-muted-foreground mt-1">
SGI (sjukpenninggrundande inkomst) baseras p\u00e5 dina l\u00f6neutbetalningar.
Logga din f\u00f6rsta utbetalning f\u00f6r att b\u00f6rja sp\u00e5ra.
</p>
</div>
</div>
) : (
<>
{/* Progress bar */}
<div className="w-full h-3 rounded-full overflow-hidden bg-muted mb-2">
<div
className={cn(
'h-full rounded-full transition-all duration-500',
level === 'green' && 'bg-emerald-500',
level === 'yellow' && 'bg-amber-500',
level === 'red' && 'bg-red-500'
)}
style={{ width: `${progress}%` }}
/>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="tabular-nums">{days}/{maxDays}</span>
<span>N\u00e4sta varning: dag 30 {'\u00b7'} Kritisk: dag 75</span>
</div>
{/* Status message */}
{statusMessage && (
<div className="mt-3">
<Badge
variant={level === 'red' ? 'destructive' : 'warning'}
className="text-xs"
>
{statusMessage}
</Badge>
</div>
)}
</>
)}
</CardContent>
</Card>
)
}
+131
View File
@@ -0,0 +1,131 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { cn, formatCurrency } from '@/lib/utils'
import { Wallet } from 'lucide-react'
interface SafeToSpendGaugeProps {
bankBalance: number
giftTaxDebt: number
hobbyReserve: number
}
export default function SafeToSpendGauge({
bankBalance,
giftTaxDebt,
hobbyReserve,
}: SafeToSpendGaugeProps) {
const safeToSpend = Math.max(0, bankBalance - giftTaxDebt - hobbyReserve)
const total = bankBalance || 1 // Avoid division by zero
const safePercent = bankBalance > 0 ? (safeToSpend / total) * 100 : 0
const giftPercent = bankBalance > 0 ? (giftTaxDebt / total) * 100 : 0
const hobbyPercent = bankBalance > 0 ? (hobbyReserve / total) * 100 : 0
// Determine color level
let level: 'green' | 'yellow' | 'red'
if (safePercent > 70) {
level = 'green'
} else if (safePercent > 30) {
level = 'yellow'
} else {
level = 'red'
}
const noBankConnected = !bankBalance || bankBalance === 0
return (
<Card className={cn(noBankConnected && 'opacity-60')}>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Wallet className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-lg">Att spendera</CardTitle>
</div>
</CardHeader>
<CardContent>
{noBankConnected ? (
<div className="text-center py-4">
<p className="text-2xl font-display font-medium text-muted-foreground">
Ingen bank ansluten
</p>
<p className="text-sm text-muted-foreground mt-1">
Koppla din bank f\u00f6r att se ditt disponibla belopp
</p>
</div>
) : (
<>
<div className="text-center mb-6">
<p
className={cn(
'font-display text-4xl font-medium tabular-nums',
level === 'green' && 'text-emerald-600',
level === 'yellow' && 'text-amber-600',
level === 'red' && 'text-red-600'
)}
>
{formatCurrency(safeToSpend)}
</p>
<p className="text-sm text-muted-foreground mt-1">
av {formatCurrency(bankBalance)} p\u00e5 kontot
</p>
</div>
{/* Stacked bar */}
<div className="w-full h-4 rounded-full overflow-hidden flex bg-muted">
{safePercent > 0 && (
<div
className={cn(
'h-full transition-all duration-500',
level === 'green' && 'bg-emerald-500',
level === 'yellow' && 'bg-amber-500',
level === 'red' && 'bg-red-500'
)}
style={{ width: `${safePercent}%` }}
/>
)}
{giftPercent > 0 && (
<div
className="h-full bg-amber-400 transition-all duration-500"
style={{ width: `${giftPercent}%` }}
/>
)}
{hobbyPercent > 0 && (
<div
className="h-full bg-orange-400 transition-all duration-500"
style={{ width: `${hobbyPercent}%` }}
/>
)}
</div>
{/* Legend */}
<div className="flex flex-wrap gap-x-5 gap-y-1 mt-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<span
className={cn(
'inline-block h-2.5 w-2.5 rounded-full',
level === 'green' && 'bg-emerald-500',
level === 'yellow' && 'bg-amber-500',
level === 'red' && 'bg-red-500'
)}
/>
Disponibelt
</div>
{giftTaxDebt > 0 && (
<div className="flex items-center gap-1.5">
<span className="inline-block h-2.5 w-2.5 rounded-full bg-amber-400" />
G\u00e5voskatt ({formatCurrency(giftTaxDebt)})
</div>
)}
{hobbyReserve > 0 && (
<div className="flex items-center gap-1.5">
<span className="inline-block h-2.5 w-2.5 rounded-full bg-orange-400" />
Hobby ({formatCurrency(hobbyReserve)})
</div>
)}
</div>
</>
)}
</CardContent>
</Card>
)
}
+376
View File
@@ -0,0 +1,376 @@
'use client'
import { useState, useMemo } 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 { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
ArrowRight,
Search,
CheckCircle,
AlertCircle,
XCircle,
Filter,
} from 'lucide-react'
import type { AccountMapping } from '@/lib/import/types'
import type { BASAccount } from '@/types'
interface AccountMappingStepProps {
mappings: AccountMapping[]
basAccounts: BASAccount[]
onMappingChange: (sourceAccount: string, targetAccount: string, targetName: string) => void
onContinue: () => void
onBack: () => void
}
type FilterType = 'all' | 'unmapped' | 'low_confidence' | 'manual'
const PAGE_SIZE = 50
export default function AccountMappingStep({
mappings,
basAccounts,
onMappingChange,
onContinue,
onBack,
}: AccountMappingStepProps) {
const [searchTerm, setSearchTerm] = useState('')
// Default to showing unmapped accounts first (most actionable)
const [filter, setFilter] = useState<FilterType>(() => {
const hasUnmapped = mappings.some((m) => !m.targetAccount)
return hasUnmapped ? 'unmapped' : 'all'
})
const [currentPage, setCurrentPage] = useState(1)
// Filter and search mappings
const filteredMappings = useMemo(() => {
let result = mappings
// Apply filter
switch (filter) {
case 'unmapped':
result = result.filter((m) => !m.targetAccount)
break
case 'low_confidence':
result = result.filter((m) => m.targetAccount && m.confidence < 0.7)
break
case 'manual':
result = result.filter((m) => m.isOverride)
break
}
// Apply search
if (searchTerm) {
const term = searchTerm.toLowerCase()
result = result.filter(
(m) =>
m.sourceAccount.includes(term) ||
m.sourceName.toLowerCase().includes(term) ||
m.targetAccount?.includes(term) ||
m.targetName?.toLowerCase().includes(term)
)
}
return result
}, [mappings, filter, searchTerm])
// Pagination
const totalPages = Math.ceil(filteredMappings.length / PAGE_SIZE)
const paginatedMappings = useMemo(() => {
const start = (currentPage - 1) * PAGE_SIZE
return filteredMappings.slice(start, start + PAGE_SIZE)
}, [filteredMappings, currentPage])
// Reset page when filter or search changes
const handleFilterChange = (newFilter: FilterType) => {
setFilter(newFilter)
setCurrentPage(1)
}
const handleSearchChange = (term: string) => {
setSearchTerm(term)
setCurrentPage(1)
}
// Calculate stats
const stats = useMemo(() => {
const unmapped = mappings.filter((m) => !m.targetAccount).length
const lowConfidence = mappings.filter((m) => m.targetAccount && m.confidence < 0.7).length
const manual = mappings.filter((m) => m.isOverride).length
return { unmapped, lowConfidence, manual }
}, [mappings])
const canContinue = stats.unmapped === 0
// Group BAS accounts by class for the dropdown
const accountsByClass = useMemo(() => {
const groups: { [key: string]: BASAccount[] } = {}
for (const account of basAccounts) {
const className = getAccountClassName(account.account_class)
if (!groups[className]) {
groups[className] = []
}
groups[className].push(account)
}
return groups
}, [basAccounts])
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Kontomappning</CardTitle>
<CardDescription>
Granska och justera mappningarna mellan importerade konton och din kontoplan.
Alla konton måste vara mappade innan importen kan genomföras.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Stats */}
<div className="flex gap-4 flex-wrap">
<Badge
variant={filter === 'unmapped' ? 'destructive' : stats.unmapped > 0 ? 'destructive' : 'secondary'}
className="cursor-pointer"
onClick={() => handleFilterChange('unmapped')}
>
<XCircle className="h-3 w-3 mr-1" />
{stats.unmapped} ej mappade
</Badge>
<Badge
variant={filter === 'low_confidence' ? 'default' : stats.lowConfidence > 0 ? 'secondary' : 'outline'}
className="cursor-pointer"
onClick={() => handleFilterChange('low_confidence')}
>
<AlertCircle className="h-3 w-3 mr-1" />
{stats.lowConfidence} osäkra
</Badge>
<Badge
variant={filter === 'manual' ? 'default' : 'outline'}
className="cursor-pointer"
onClick={() => handleFilterChange('manual')}
>
<CheckCircle className="h-3 w-3 mr-1" />
{stats.manual} manuellt satta
</Badge>
<Badge
variant={filter === 'all' ? 'default' : 'outline'}
className="cursor-pointer"
onClick={() => handleFilterChange('all')}
>
Visa alla ({mappings.length})
</Badge>
</div>
{/* Search and filter */}
<div className="flex gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Sök konto..."
value={searchTerm}
onChange={(e) => handleSearchChange(e.target.value)}
className="pl-9"
/>
</div>
<Select value={filter} onValueChange={(v) => handleFilterChange(v as FilterType)}>
<SelectTrigger className="w-48">
<Filter className="h-4 w-4 mr-2" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Visa alla</SelectItem>
<SelectItem value="unmapped">Ej mappade</SelectItem>
<SelectItem value="low_confidence">Osäkra</SelectItem>
<SelectItem value="manual">Manuellt satta</SelectItem>
</SelectContent>
</Select>
</div>
{/* Mapping table */}
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-36">Källkonto</TableHead>
<TableHead>Källnamn</TableHead>
<TableHead className="w-12"></TableHead>
<TableHead className="w-64">Målkonto</TableHead>
<TableHead className="w-24">Konfidens</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedMappings.map((mapping) => (
<TableRow
key={mapping.sourceAccount}
className={!mapping.targetAccount ? 'bg-destructive/5' : ''}
>
<TableCell className="font-mono">{mapping.sourceAccount}</TableCell>
<TableCell className="text-muted-foreground">{mapping.sourceName}</TableCell>
<TableCell>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</TableCell>
<TableCell>
<Select
value={mapping.targetAccount || 'none'}
onValueChange={(value) => {
const account = basAccounts.find((a) => a.account_number === value)
onMappingChange(
mapping.sourceAccount,
value === 'none' ? '' : value,
account?.account_name || ''
)
}}
>
<SelectTrigger className={!mapping.targetAccount ? 'border-destructive' : ''}>
<SelectValue placeholder="Välj konto..." />
</SelectTrigger>
<SelectContent className="max-h-80">
<SelectItem value="none">-- Välj konto --</SelectItem>
{Object.entries(accountsByClass).map(([className, accounts]) => (
<div key={className}>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted">
{className}
</div>
{accounts.map((account) => (
<SelectItem
key={account.account_number}
value={account.account_number}
>
<span className="font-mono mr-2">{account.account_number}</span>
{account.account_name}
</SelectItem>
))}
</div>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
{mapping.targetAccount && (
<ConfidenceBadge
confidence={mapping.confidence}
matchType={mapping.matchType}
isOverride={mapping.isOverride}
/>
)}
</TableCell>
</TableRow>
))}
{paginatedMappings.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
Inga konton matchar filtret
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Visar {((currentPage - 1) * PAGE_SIZE) + 1}-{Math.min(currentPage * PAGE_SIZE, filteredMappings.length)} av {filteredMappings.length}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
>
Föregående
</Button>
<div className="flex items-center gap-1 px-2">
<span className="text-sm">Sida {currentPage} av {totalPages}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
>
Nästa
</Button>
</div>
</div>
)}
</CardContent>
</Card>
{/* Actions */}
<div className="flex justify-between">
<Button variant="outline" onClick={onBack}>
Tillbaka
</Button>
<Button onClick={onContinue} disabled={!canContinue}>
{canContinue ? 'Fortsätt till granskning' : `${stats.unmapped} konton saknar mappning`}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
)
}
function ConfidenceBadge({
confidence,
isOverride,
}: {
confidence: number
matchType: string // Keep for potential future use
isOverride: boolean
}) {
if (isOverride) {
return <Badge variant="default">Manuell</Badge>
}
if (confidence >= 0.9) {
return <Badge variant="default" className="bg-success">Exakt</Badge>
}
if (confidence >= 0.7) {
return <Badge variant="secondary">Trolig</Badge>
}
return <Badge variant="outline">Osäker</Badge>
}
function getAccountClassName(accountClass: number): string {
switch (accountClass) {
case 1:
return '1xxx - Tillgångar'
case 2:
return '2xxx - Eget kapital & Skulder'
case 3:
return '3xxx - Intäkter'
case 4:
return '4xxx - Varuinköp'
case 5:
return '5xxx - Externa kostnader'
case 6:
return '6xxx - Övriga externa kostnader'
case 7:
return '7xxx - Personal'
case 8:
return '8xxx - Finansiella poster'
default:
return `${accountClass}xxx - Övrigt`
}
}
+212
View File
@@ -0,0 +1,212 @@
'use client'
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 {
CheckCircle,
XCircle,
AlertCircle,
FileText,
ExternalLink,
RotateCcw,
} from 'lucide-react'
import type { ImportResult } from '@/lib/import/types'
interface ImportResultStepProps {
result: ImportResult
onNewImport: () => void
}
export default function ImportResultStep({ result, onNewImport }: ImportResultStepProps) {
const hasErrors = result.errors.length > 0
const hasWarnings = result.warnings.length > 0
return (
<div className="space-y-6">
{/* Success/Failure header */}
<Card className={result.success ? 'border-success/50' : 'border-destructive/50'}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{result.success ? (
<>
<CheckCircle className="h-6 w-6 text-success" />
Import genomförd
</>
) : (
<>
<XCircle className="h-6 w-6 text-destructive" />
Import misslyckades
</>
)}
</CardTitle>
<CardDescription>
{result.success
? 'Din bokföring har importerats framgångsrikt.'
: 'Det uppstod fel under importen. Se detaljer nedan.'}
</CardDescription>
</CardHeader>
</Card>
{/* Statistics */}
{result.success && (
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<FileText className="h-4 w-4" />
<span className="text-sm">Verifikationer skapade</span>
</div>
<p className="text-2xl font-bold">{result.journalEntriesCreated}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<span className="text-sm">Räkenskapsår</span>
</div>
<p className="text-2xl font-bold">
{result.fiscalPeriodId ? (
<Badge variant="default" className="bg-success">Skapat</Badge>
) : (
<Badge variant="secondary">Befintligt</Badge>
)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<span className="text-sm">Ingående balanser</span>
</div>
<p className="text-2xl font-bold">
{result.openingBalanceEntryId ? (
<Badge variant="default" className="bg-success">Importerade</Badge>
) : (
<Badge variant="secondary">Inga</Badge>
)}
</p>
</CardContent>
</Card>
</div>
)}
{/* Errors */}
{hasErrors && (
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
<XCircle className="h-5 w-5" />
Fel ({result.errors.length})
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2 max-h-48 overflow-y-auto">
{result.errors.map((error, i) => (
<div key={i} className="text-sm flex gap-2">
<XCircle className="h-4 w-4 text-destructive flex-shrink-0 mt-0.5" />
<span>{error}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Warnings */}
{hasWarnings && (
<Card className="border-warning/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-warning">
<AlertCircle className="h-5 w-5" />
Varningar ({result.warnings.length})
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2 max-h-48 overflow-y-auto">
{result.warnings.map((warning, i) => (
<div key={i} className="text-sm flex gap-2">
<AlertCircle className="h-4 w-4 text-warning flex-shrink-0 mt-0.5" />
<span>{warning}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Next steps */}
{result.success && (
<Card className="bg-muted/50">
<CardHeader>
<CardTitle className="text-base">Nästa steg</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-medium">
1
</div>
<div>
<p className="font-medium">Granska importerade verifikationer</p>
<p className="text-sm text-muted-foreground">
Kontrollera att allt ser korrekt ut i bokföringslistan
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-medium">
2
</div>
<div>
<p className="font-medium">Verifiera balanserna</p>
<p className="text-sm text-muted-foreground">
Jämför huvudboken med din tidigare bokföring
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-medium">
3
</div>
<div>
<p className="font-medium">Fortsätt med ny bokföring</p>
<p className="text-sm text-muted-foreground">
Nu kan du börja lägga till nya transaktioner
</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Actions */}
<div className="flex justify-between">
<Button variant="outline" onClick={onNewImport}>
<RotateCcw className="mr-2 h-4 w-4" />
Ny import
</Button>
<div className="flex gap-2">
{result.success && (
<>
<Button variant="outline" asChild>
<Link href="/bookkeeping">
Visa bokföring
<ExternalLink className="ml-2 h-4 w-4" />
</Link>
</Button>
<Button asChild>
<Link href="/reports">
Visa rapporter
<ExternalLink className="ml-2 h-4 w-4" />
</Link>
</Button>
</>
)}
</div>
</div>
</div>
)
}
+269
View File
@@ -0,0 +1,269 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { formatCurrency } from '@/lib/utils'
import {
CheckCircle,
AlertCircle,
ArrowRight,
Loader2,
Calendar,
FileText,
Database,
} from 'lucide-react'
import type { ImportPreview, AccountMapping } from '@/lib/import/types'
interface ImportReviewStepProps {
preview: ImportPreview
mappings: AccountMapping[]
onExecute: (options: ImportExecuteOptions) => Promise<void>
onBack: () => void
isLoading: boolean
}
export interface ImportExecuteOptions {
createFiscalPeriod: boolean
importOpeningBalances: boolean
importTransactions: boolean
voucherSeries: string
}
export default function ImportReviewStep({
preview,
mappings,
onExecute,
onBack,
isLoading,
}: ImportReviewStepProps) {
const [options, setOptions] = useState<ImportExecuteOptions>({
createFiscalPeriod: true,
importOpeningBalances: true,
importTransactions: true,
voucherSeries: 'B',
})
const handleExecute = () => {
onExecute(options)
}
const updateOption = <K extends keyof ImportExecuteOptions>(
key: K,
value: ImportExecuteOptions[K]
) => {
setOptions((prev) => ({ ...prev, [key]: value }))
}
// Calculate what will be imported
const mappedCount = mappings.filter((m) => m.targetAccount).length
const hasOpeningBalances = preview.openingBalanceTotal > 0
const hasTransactions = preview.voucherCount > 0
return (
<div className="space-y-6">
{/* Summary */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-success" />
Redo att importera
</CardTitle>
<CardDescription>
Granska inställningarna nedan och klicka på &quot;Starta import&quot; för att genomföra importen.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<div className="flex items-center gap-3 p-3 bg-muted rounded-lg">
<FileText className="h-8 w-8 text-muted-foreground" />
<div>
<p className="font-medium">{preview.companyName || 'Okänt företag'}</p>
<p className="text-sm text-muted-foreground">{preview.orgNumber || 'Inget orgnr'}</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 bg-muted rounded-lg">
<Calendar className="h-8 w-8 text-muted-foreground" />
<div>
<p className="font-medium">Räkenskapsår</p>
<p className="text-sm text-muted-foreground">
{preview.fiscalYearStart
? new Date(preview.fiscalYearStart).toLocaleDateString('sv-SE')
: '?'}{' '}
-{' '}
{preview.fiscalYearEnd
? new Date(preview.fiscalYearEnd).toLocaleDateString('sv-SE')
: '?'}
</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 bg-muted rounded-lg">
<Database className="h-8 w-8 text-muted-foreground" />
<div>
<p className="font-medium">{mappedCount} konton mappade</p>
<p className="text-sm text-muted-foreground">
{preview.voucherCount} verifikationer
</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Import options */}
<Card>
<CardHeader>
<CardTitle>Importinställningar</CardTitle>
<CardDescription>Välj vad som ska importeras</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Fiscal period */}
<div className="flex items-start justify-between">
<div className="space-y-0.5">
<Label htmlFor="create-fiscal-period" className="font-medium">
Skapa räkenskapsår
</Label>
<p className="text-sm text-muted-foreground">
Skapar automatiskt räkenskapsåret om det inte redan finns
</p>
</div>
<Switch
id="create-fiscal-period"
checked={options.createFiscalPeriod}
onCheckedChange={(checked) => updateOption('createFiscalPeriod', checked)}
/>
</div>
{/* Opening balances */}
<div className="flex items-start justify-between">
<div className="space-y-0.5">
<Label htmlFor="import-opening-balances" className="font-medium">
Importera ingående balanser
</Label>
<p className="text-sm text-muted-foreground">
{hasOpeningBalances
? `Skapar verifikation för IB på ${formatCurrency(preview.openingBalanceTotal)}`
: 'Inga ingående balanser i filen'}
</p>
</div>
<Switch
id="import-opening-balances"
checked={options.importOpeningBalances}
onCheckedChange={(checked) => updateOption('importOpeningBalances', checked)}
disabled={!hasOpeningBalances}
/>
</div>
{/* Transactions */}
<div className="flex items-start justify-between">
<div className="space-y-0.5">
<Label htmlFor="import-transactions" className="font-medium">
Importera verifikationer
</Label>
<p className="text-sm text-muted-foreground">
{hasTransactions
? `Importerar ${preview.voucherCount} verifikationer med ${preview.transactionLineCount} rader`
: 'Inga verifikationer i filen (SIE1-format?)'}
</p>
</div>
<Switch
id="import-transactions"
checked={options.importTransactions}
onCheckedChange={(checked) => updateOption('importTransactions', checked)}
disabled={!hasTransactions}
/>
</div>
{/* Voucher series */}
{options.importTransactions && hasTransactions && (
<div className="space-y-2">
<Label htmlFor="voucher-series" className="font-medium">
Verifikationsserie för importerade transaktioner
</Label>
<Select
value={options.voucherSeries}
onValueChange={(value) => updateOption('voucherSeries', value)}
>
<SelectTrigger id="voucher-series" className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="A">A - Huvudserie</SelectItem>
<SelectItem value="B">B - Import</SelectItem>
<SelectItem value="C">C - Korrigeringar</SelectItem>
<SelectItem value="I">I - Import (alternativ)</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Använd en separat serie för att enkelt kunna skilja importerade från manuella verifikationer
</p>
</div>
)}
</CardContent>
</Card>
{/* Warnings */}
{!preview.trialBalance.isBalanced && (
<Card className="border-warning/50 bg-warning/5">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-warning">
<AlertCircle className="h-5 w-5" />
Observera
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
De ingående balanserna i filen balanserar inte helt. En justeringspost kommer
att skapas automatiskt mot konto 2099 (Årets resultat).
</p>
</CardContent>
</Card>
)}
{/* What happens next */}
<Card className="bg-muted/50">
<CardHeader>
<CardTitle className="text-base">Vad händer när du importerar?</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground space-y-2">
<p>1. Räkenskapsåret skapas om det inte finns</p>
<p>2. En verifikation för ingående balanser skapas</p>
<p>3. Alla verifikationer importeras med nya verifikationsnummer</p>
<p>4. Kontomappningarna sparas för framtida importer</p>
<p className="pt-2 font-medium">
Importen kan inte ångras automatiskt, men du kan ta bort skapade verifikationer manuellt.
</p>
</CardContent>
</Card>
{/* Actions */}
<div className="flex justify-between">
<Button variant="outline" onClick={onBack} disabled={isLoading}>
Tillbaka
</Button>
<Button onClick={handleExecute} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Importerar...
</>
) : (
<>
Starta import
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</div>
</div>
)
}
+319
View File
@@ -0,0 +1,319 @@
'use client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { formatCurrency } from '@/lib/utils'
import {
Building2,
Calendar,
FileText,
CheckCircle,
AlertCircle,
XCircle,
ArrowRight,
BarChart3,
} from 'lucide-react'
import type { ImportPreview, ParseIssue } from '@/lib/import/types'
interface SIEPreviewStepProps {
preview: ImportPreview
issues: ParseIssue[]
missingAccounts: { number: string; name: string }[]
onCreateAccounts: () => Promise<void>
isCreatingAccounts: boolean
onContinue: () => void
onBack: () => void
}
export default function SIEPreviewStep({
preview,
issues,
missingAccounts,
onCreateAccounts,
isCreatingAccounts,
onContinue,
onBack,
}: SIEPreviewStepProps) {
const errors = issues.filter((i) => i.severity === 'error')
const warnings = issues.filter((i) => i.severity === 'warning')
// Only block on actual parsing errors, not unmapped accounts
// (users need to proceed to mapping step to fix unmapped accounts)
const hasBlockingErrors = errors.length > 0
return (
<div className="space-y-6">
{/* Company info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="h-5 w-5" />
Företagsinformation
</CardTitle>
<CardDescription>Information från SIE-filen</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-sm text-muted-foreground">Företagsnamn</p>
<p className="font-medium">{preview.companyName || 'Ej angivet'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Organisationsnummer</p>
<p className="font-medium">{preview.orgNumber || 'Ej angivet'}</p>
</div>
</div>
</CardContent>
</Card>
{/* Fiscal year */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Räkenskapsår
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
<div>
<p className="text-sm text-muted-foreground">Start</p>
<p className="font-medium">
{preview.fiscalYearStart
? new Date(preview.fiscalYearStart).toLocaleDateString('sv-SE')
: 'Okänt'}
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
<div>
<p className="text-sm text-muted-foreground">Slut</p>
<p className="font-medium">
{preview.fiscalYearEnd
? new Date(preview.fiscalYearEnd).toLocaleDateString('sv-SE')
: 'Okänt'}
</p>
</div>
</div>
</CardContent>
</Card>
{/* Statistics */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<FileText className="h-4 w-4" />
<span className="text-sm">Konton</span>
</div>
<p className="text-2xl font-bold">{preview.accountCount}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<BarChart3 className="h-4 w-4" />
<span className="text-sm">Verifikationer</span>
</div>
<p className="text-2xl font-bold">{preview.voucherCount}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<span className="text-sm">Transaktionsrader</span>
</div>
<p className="text-2xl font-bold">{preview.transactionLineCount}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<span className="text-sm">IB Summa</span>
</div>
<p className="text-2xl font-bold">{formatCurrency(preview.openingBalanceTotal)}</p>
</CardContent>
</Card>
</div>
{/* Trial balance check */}
<Card className={preview.trialBalance.isBalanced ? 'border-success/50' : 'border-warning/50'}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{preview.trialBalance.isBalanced ? (
<CheckCircle className="h-5 w-5 text-success" />
) : (
<AlertCircle className="h-5 w-5 text-warning" />
)}
Balansräkning (IB)
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-3">
<div>
<p className="text-sm text-muted-foreground">Total debet</p>
<p className="font-medium">{formatCurrency(preview.trialBalance.totalDebit)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Total kredit</p>
<p className="font-medium">{formatCurrency(preview.trialBalance.totalCredit)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Status</p>
{preview.trialBalance.isBalanced ? (
<Badge variant="default" className="bg-success">Balanserar</Badge>
) : (
<Badge variant="secondary">
Diff: {formatCurrency(preview.trialBalance.totalDebit - preview.trialBalance.totalCredit)}
</Badge>
)}
</div>
</div>
</CardContent>
</Card>
{/* Mapping status */}
<Card
className={
preview.mappingStatus.unmapped > 0
? 'border-destructive/50'
: preview.mappingStatus.lowConfidence > 0
? 'border-warning/50'
: 'border-success/50'
}
>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{preview.mappingStatus.unmapped > 0 ? (
<XCircle className="h-5 w-5 text-destructive" />
) : preview.mappingStatus.lowConfidence > 0 ? (
<AlertCircle className="h-5 w-5 text-warning" />
) : (
<CheckCircle className="h-5 w-5 text-success" />
)}
Kontomappning
</CardTitle>
<CardDescription>
Hur väl kunde kontona i filen matchas mot din kontoplan
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-4">
<div>
<p className="text-sm text-muted-foreground">Totalt</p>
<p className="font-medium">{preview.mappingStatus.total}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Mappade</p>
<p className="font-medium text-success">{preview.mappingStatus.mapped}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Ej mappade</p>
<p className={`font-medium ${preview.mappingStatus.unmapped > 0 ? 'text-destructive' : ''}`}>
{preview.mappingStatus.unmapped}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Osäkra</p>
<p className={`font-medium ${preview.mappingStatus.lowConfidence > 0 ? 'text-warning' : ''}`}>
{preview.mappingStatus.lowConfidence}
</p>
</div>
</div>
</CardContent>
</Card>
{/* Create missing accounts */}
{missingAccounts.length > 0 && (
<Card className="border-primary/50">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5 text-primary" />
Skapa saknade konton
</CardTitle>
<CardDescription>
{missingAccounts.length} konton från SIE-filen finns inte i din kontoplan.
Du kan skapa dem automatiskt för att behålla samma kontostruktur som i ditt gamla system.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="max-h-32 overflow-y-auto text-sm space-y-1">
{missingAccounts.slice(0, 10).map((acc) => (
<div key={acc.number} className="flex gap-2 text-muted-foreground">
<span className="font-mono">{acc.number}</span>
<span>{acc.name}</span>
</div>
))}
{missingAccounts.length > 10 && (
<div className="text-muted-foreground">
... och {missingAccounts.length - 10} till
</div>
)}
</div>
<Button
onClick={onCreateAccounts}
disabled={isCreatingAccounts}
className="w-full"
>
{isCreatingAccounts ? (
<>Skapar konton...</>
) : (
<>Skapa {missingAccounts.length} konton</>
)}
</Button>
</div>
</CardContent>
</Card>
)}
{/* Issues */}
{(errors.length > 0 || warnings.length > 0) && (
<Card className={errors.length > 0 ? 'border-destructive/50' : 'border-warning/50'}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{errors.length > 0 ? (
<XCircle className="h-5 w-5 text-destructive" />
) : (
<AlertCircle className="h-5 w-5 text-warning" />
)}
{errors.length > 0 ? 'Fel' : 'Varningar'}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2 max-h-48 overflow-y-auto">
{errors.map((issue, i) => (
<div key={`error-${i}`} className="text-sm flex gap-2 text-destructive">
<XCircle className="h-4 w-4 flex-shrink-0 mt-0.5" />
<span>Rad {issue.line}: {issue.message}</span>
</div>
))}
{warnings.map((issue, i) => (
<div key={`warning-${i}`} className="text-sm flex gap-2 text-warning">
<AlertCircle className="h-4 w-4 flex-shrink-0 mt-0.5" />
<span>Rad {issue.line}: {issue.message}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Actions */}
<div className="flex justify-between">
<Button variant="outline" onClick={onBack}>
Tillbaka
</Button>
<Button onClick={onContinue} disabled={hasBlockingErrors}>
{preview.mappingStatus.lowConfidence > 0 || preview.mappingStatus.unmapped > 0
? 'Granska mappningar'
: 'Fortsätt'}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
)
}
+175
View File
@@ -0,0 +1,175 @@
'use client'
import { useState, useCallback } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
import { Upload, FileText, AlertCircle, CheckCircle } from 'lucide-react'
interface SIEUploadStepProps {
onFileSelect: (file: File) => void
isLoading: boolean
error: string | null
}
export default function SIEUploadStep({ onFileSelect, isLoading, error }: SIEUploadStepProps) {
const [isDragging, setIsDragging] = useState(false)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(true)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
}, [])
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const files = e.dataTransfer.files
if (files.length > 0) {
const file = files[0]
if (file.name.toLowerCase().endsWith('.sie') || file.name.toLowerCase().endsWith('.se')) {
setSelectedFile(file)
onFileSelect(file)
}
}
}, [onFileSelect])
const handleFileInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (files && files.length > 0) {
setSelectedFile(files[0])
onFileSelect(files[0])
}
}, [onFileSelect])
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" />
Ladda upp SIE-fil
</CardTitle>
<CardDescription>
Exportera en SIE4-fil från ditt nuvarande bokföringssystem (Fortnox, Visma, etc.)
och ladda upp den här för att importera din bokföring.
</CardDescription>
</CardHeader>
<CardContent>
{/* Drop zone */}
<div
className={`
relative border-2 border-dashed rounded-lg p-8 text-center transition-colors
${isDragging ? 'border-primary bg-primary/5' : 'border-muted-foreground/25'}
${error ? 'border-destructive bg-destructive/5' : ''}
${isLoading ? 'pointer-events-none opacity-50' : 'cursor-pointer hover:border-primary/50'}
`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => document.getElementById('file-input')?.click()}
>
<input
id="file-input"
type="file"
accept=".sie,.se"
className="hidden"
onChange={handleFileInput}
disabled={isLoading}
/>
{isLoading ? (
<div className="space-y-4">
<FileText className="mx-auto h-12 w-12 text-muted-foreground animate-pulse" />
<p className="text-muted-foreground">Analyserar fil...</p>
<Progress value={33} className="w-48 mx-auto" />
</div>
) : selectedFile ? (
<div className="space-y-4">
<CheckCircle className="mx-auto h-12 w-12 text-success" />
<div>
<p className="font-medium">{selectedFile.name}</p>
<p className="text-sm text-muted-foreground">
{(selectedFile.size / 1024).toFixed(1)} KB
</p>
</div>
</div>
) : (
<div className="space-y-4">
<Upload className="mx-auto h-12 w-12 text-muted-foreground" />
<div>
<p className="font-medium">Dra och släpp SIE-fil här</p>
<p className="text-sm text-muted-foreground">eller klicka för att välja fil</p>
</div>
</div>
)}
</div>
{/* Error display */}
{error && (
<div className="mt-4 p-4 bg-destructive/10 border border-destructive/20 rounded-lg flex gap-3">
<AlertCircle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-destructive">Kunde inte läsa filen</p>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
</div>
)}
</CardContent>
</Card>
{/* Info cards */}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Vad är SIE?</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
<p>
SIE (Standard Import Export) är det svenska standardformatet för att överföra
bokföringsdata mellan system. Det används av alla större bokföringsprogram i Sverige.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Vilken SIE-typ?</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
<ul className="space-y-1">
<li><strong>SIE4</strong> - Full historik med alla verifikationer (rekommenderas)</li>
<li><strong>SIE1</strong> - Endast årssaldon (enklare import)</li>
</ul>
</CardContent>
</Card>
</div>
{/* Export instructions */}
<Card className="bg-muted/50">
<CardHeader>
<CardTitle className="text-base">Så exporterar du från...</CardTitle>
</CardHeader>
<CardContent className="text-sm space-y-3">
<div>
<p className="font-medium">Fortnox</p>
<p className="text-muted-foreground">Inställningar → Import/Export → Exportera SIE</p>
</div>
<div>
<p className="font-medium">Visma eEkonomi</p>
<p className="text-muted-foreground">Rapporter → Övrigt → Exportera till SIE</p>
</div>
<div>
<p className="font-medium">Bokio</p>
<p className="text-muted-foreground">Inställningar → Bokföring → Exportera SIE-fil</p>
</div>
</CardContent>
</Card>
</div>
)
}
+258
View File
@@ -0,0 +1,258 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import {
X,
ChevronRight,
ChevronLeft,
Sparkles,
PiggyBank,
AlertTriangle,
ListTodo,
Zap,
} from 'lucide-react'
import { cn } from '@/lib/utils'
interface TourStep {
id: string
title: string
description: string
icon: React.ComponentType<{ className?: string }>
highlight?: string // CSS selector or element ID to highlight
position?: 'top' | 'bottom' | 'center'
}
const TOUR_COMPLETED_KEY = 'influencer_dashboard_tour_completed'
const tourSteps: TourStep[] = [
{
id: 'welcome',
title: 'Välkommen till din översikt!',
description:
'Här ser du hela din ekonomiska situation på ett ställe. Låt oss gå igenom de viktigaste delarna.',
icon: Sparkles,
position: 'center',
},
{
id: 'disponibelt',
title: 'Ditt att spendera',
description:
'Detta är vad du faktiskt kan använda efter att vi räknat bort uppskattad skatt och moms. Tänk på det som "säkra pengar".',
icon: PiggyBank,
highlight: 'disponibelt-section',
position: 'bottom',
},
{
id: 'fskatt',
title: 'F-skatt varning',
description:
'Vi jämför din faktiska skatt med vad du betalat in via F-skatt. Om det skiljer sig mycket får du en varning så du kan justera.',
icon: AlertTriangle,
highlight: 'fskatt-section',
position: 'top',
},
{
id: 'att-hantera',
title: 'Att hantera',
description:
'Här samlas saker som behöver din uppmärksamhet: okategoriserade transaktioner, obetalda fakturor, och liknande.',
icon: ListTodo,
highlight: 'alerts-section',
position: 'top',
},
{
id: 'snabbatgarder',
title: 'Snabbåtgärder',
description:
'Skapa fakturor, logga körningar eller skanna kvitton med ett klick. De vanligaste uppgifterna finns alltid nära till hands.',
icon: Zap,
highlight: 'quick-actions',
position: 'top',
},
]
interface DashboardTourProps {
onComplete?: () => void
forceShow?: boolean
}
export default function DashboardTour({ onComplete, forceShow = false }: DashboardTourProps) {
const [isVisible, setIsVisible] = useState(false)
const [currentStep, setCurrentStep] = useState(0)
const [isAnimating, setIsAnimating] = useState(false)
useEffect(() => {
// Check if tour has been completed
const completed = localStorage.getItem(TOUR_COMPLETED_KEY)
if (forceShow || completed !== 'true') {
// Small delay to let the page render first
const timer = setTimeout(() => setIsVisible(true), 500)
return () => clearTimeout(timer)
}
}, [forceShow])
const completeTour = useCallback(() => {
setIsAnimating(true)
setTimeout(() => {
localStorage.setItem(TOUR_COMPLETED_KEY, 'true')
setIsVisible(false)
onComplete?.()
}, 300)
}, [onComplete])
const nextStep = () => {
if (currentStep < tourSteps.length - 1) {
setCurrentStep((prev) => prev + 1)
} else {
completeTour()
}
}
const prevStep = () => {
if (currentStep > 0) {
setCurrentStep((prev) => prev - 1)
}
}
const skipTour = () => {
completeTour()
}
if (!isVisible) {
return null
}
const step = tourSteps[currentStep]
const Icon = step.icon
const isLastStep = currentStep === tourSteps.length - 1
const isFirstStep = currentStep === 0
return (
<>
{/* Backdrop overlay */}
<div
className={cn(
'fixed inset-0 bg-background/80 backdrop-blur-sm z-50 transition-opacity duration-300',
isAnimating ? 'opacity-0' : 'opacity-100'
)}
onClick={skipTour}
/>
{/* Tour card */}
<div
className={cn(
'fixed z-50 transition-all duration-300',
step.position === 'center' && 'inset-0 flex items-center justify-center p-4',
step.position === 'top' && 'top-4 left-1/2 -translate-x-1/2 w-full max-w-md px-4',
step.position === 'bottom' && 'bottom-20 md:bottom-4 left-1/2 -translate-x-1/2 w-full max-w-md px-4',
isAnimating && 'opacity-0 scale-95'
)}
>
<Card className="shadow-2xl border-primary/20">
<CardContent className="pt-6">
<div className="space-y-4">
{/* Header with icon and close */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary/10">
<Icon className="h-5 w-5 text-primary" />
</div>
<div>
<h3 className="font-medium">{step.title}</h3>
<p className="text-xs text-muted-foreground">
Steg {currentStep + 1} av {tourSteps.length}
</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 -mr-2 -mt-2"
onClick={skipTour}
>
<X className="h-4 w-4" />
<span className="sr-only">Stäng guide</span>
</Button>
</div>
{/* Description */}
<p className="text-sm text-muted-foreground leading-relaxed">
{step.description}
</p>
{/* Progress dots */}
<div className="flex items-center justify-center gap-1.5 py-2">
{tourSteps.map((_, idx) => (
<button
key={idx}
onClick={() => setCurrentStep(idx)}
className={cn(
'w-2 h-2 rounded-full transition-all',
idx === currentStep
? 'bg-primary w-4'
: idx < currentStep
? 'bg-primary/40'
: 'bg-muted-foreground/20'
)}
/>
))}
</div>
{/* Navigation buttons */}
<div className="flex items-center justify-between pt-2">
<Button
variant="ghost"
size="sm"
onClick={prevStep}
disabled={isFirstStep}
className={cn(isFirstStep && 'invisible')}
>
<ChevronLeft className="h-4 w-4 mr-1" />
Tillbaka
</Button>
<Button
variant="ghost"
size="sm"
onClick={skipTour}
className="text-muted-foreground"
>
Hoppa över
</Button>
<Button size="sm" onClick={nextStep}>
{isLastStep ? (
'Kom igång'
) : (
<>
Nästa
<ChevronRight className="h-4 w-4 ml-1" />
</>
)}
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
</>
)
}
/**
* Hook för att starta om touren
*/
export function useDashboardTour() {
const resetTour = () => {
localStorage.removeItem(TOUR_COMPLETED_KEY)
window.location.reload()
}
const isTourCompleted = () => {
return localStorage.getItem(TOUR_COMPLETED_KEY) === 'true'
}
return { resetTour, isTourCompleted }
}
+217
View File
@@ -0,0 +1,217 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import {
Check,
Circle,
Users,
Receipt,
Building2,
Camera,
Sparkles,
X,
ChevronRight,
} from 'lucide-react'
import { cn } from '@/lib/utils'
interface ChecklistItem {
id: string
label: string
description: string
href: string
icon: React.ComponentType<{ className?: string }>
completed: boolean
}
interface NewUserChecklistProps {
hasCustomers: boolean
hasInvoices: boolean
hasBankConnected: boolean
hasReceipts: boolean
onDismiss?: () => void
className?: string
}
const CHECKLIST_DISMISSED_KEY = 'influencer_checklist_dismissed'
export default function NewUserChecklist({
hasCustomers,
hasInvoices,
hasBankConnected,
hasReceipts,
onDismiss,
className,
}: NewUserChecklistProps) {
const [isDismissed, setIsDismissed] = useState(true) // Start hidden until we check localStorage
const [isAnimatingOut, setIsAnimatingOut] = useState(false)
useEffect(() => {
// Check if checklist has been dismissed
const dismissed = localStorage.getItem(CHECKLIST_DISMISSED_KEY)
setIsDismissed(dismissed === 'true')
}, [])
const items: ChecklistItem[] = [
{
id: 'account',
label: 'Skapa konto',
description: 'Du har ett konto!',
href: '#',
icon: Check,
completed: true, // Always completed if they're seeing this
},
{
id: 'customer',
label: 'Lägg till din första kund',
description: 'Spara kunduppgifter för enkel fakturering',
href: '/customers/new',
icon: Users,
completed: hasCustomers,
},
{
id: 'invoice',
label: 'Skicka din första faktura',
description: 'Skapa en professionell faktura på 60 sekunder',
href: '/invoices/new',
icon: Receipt,
completed: hasInvoices,
},
{
id: 'bank',
label: 'Koppla bank',
description: 'Se transaktioner automatiskt (valfritt)',
href: '/import',
icon: Building2,
completed: hasBankConnected,
},
{
id: 'receipt',
label: 'Skanna ditt första kvitto',
description: 'Fotografera för automatisk bokföring',
href: '/receipts/scan',
icon: Camera,
completed: hasReceipts,
},
]
const completedCount = items.filter((item) => item.completed).length
const progress = (completedCount / items.length) * 100
const allCompleted = completedCount === items.length
const handleDismiss = () => {
setIsAnimatingOut(true)
setTimeout(() => {
localStorage.setItem(CHECKLIST_DISMISSED_KEY, 'true')
setIsDismissed(true)
onDismiss?.()
}, 300)
}
// Don't show if dismissed or all completed
if (isDismissed) {
return null
}
// Auto-dismiss after all completed with celebration
if (allCompleted && !isAnimatingOut) {
return (
<Card className={cn('border-success/50 bg-success/5', className)}>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="p-3 rounded-full bg-success/10">
<Sparkles className="h-6 w-6 text-success" />
</div>
<div className="flex-1">
<p className="font-medium text-success">Snyggt jobbat!</p>
<p className="text-sm text-muted-foreground">
Du har slutfört alla steg. Nu är du redo att köra!
</p>
</div>
<Button variant="ghost" size="sm" onClick={handleDismiss}>
<X className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)
}
return (
<Card
className={cn(
'transition-all duration-300',
isAnimatingOut && 'opacity-0 scale-95',
className
)}
>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary/10">
<Sparkles className="h-4 w-4 text-primary" />
</div>
<div>
<CardTitle className="text-base">Kom igång</CardTitle>
<p className="text-sm text-muted-foreground">
{completedCount} av {items.length} steg klara
</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground"
onClick={handleDismiss}
>
<X className="h-4 w-4" />
<span className="sr-only">Stäng checklista</span>
</Button>
</div>
<Progress value={progress} className="h-1.5 mt-3" />
</CardHeader>
<CardContent className="pt-0">
<ul className="space-y-1">
{items.map((item) => {
const Icon = item.icon
return (
<li key={item.id}>
{item.completed ? (
<div className="flex items-center gap-3 p-2 rounded-lg">
<div className="p-1 rounded-full bg-success/10">
<Check className="h-3.5 w-3.5 text-success" />
</div>
<span className="text-sm text-muted-foreground line-through">
{item.label}
</span>
</div>
) : (
<Link
href={item.href}
className="group flex items-center gap-3 p-2 rounded-lg hover:bg-secondary/50 transition-colors"
>
<div className="p-1 rounded-full border border-border">
<Circle className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium group-hover:text-primary transition-colors">
{item.label}
</p>
<p className="text-xs text-muted-foreground truncate">
{item.description}
</p>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground group-hover:text-primary group-hover:translate-x-0.5 transition-all" />
</Link>
)}
</li>
)
})}
</ul>
</CardContent>
</Card>
)
}
+137
View File
@@ -0,0 +1,137 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Loader2, ArrowRight, Building2, User, Users, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { EntityType } from '@/types'
interface Step1Props {
initialData: { entity_type?: EntityType }
onNext: (data: { entity_type: EntityType }) => void
isSaving: boolean
}
const entityOptions: {
value: EntityType | string
label: string
description: string
icon: typeof Building2
disabled?: boolean
}[] = [
{
value: 'enskild_firma',
label: 'Enskild firma',
description: 'Du driver verksamhet i eget namn med F-skattsedel',
icon: User,
},
{
value: 'aktiebolag',
label: 'Aktiebolag',
description: 'Du har ett registrerat AB med organisationsnummer',
icon: Building2,
},
{
value: 'light',
label: 'Egenanställd',
description: 'Du arbetar via egenanställningsföretag som Frilans Finans, Cool Company eller Gigapay',
icon: Users,
},
]
export default function Step1EntityType({ initialData, onNext, isSaving }: Step1Props) {
const [selected, setSelected] = useState<EntityType | undefined>(initialData.entity_type)
const handleNext = () => {
if (selected) {
onNext({ entity_type: selected })
}
}
return (
<div className="space-y-6">
<div className="text-center">
<h1 className="text-3xl font-bold tracking-tight">Välkommen!</h1>
<p className="text-muted-foreground mt-2">
Låt oss börja med att ställa in din verksamhet. Vilken företagsform har du?
</p>
</div>
<div className="grid gap-3 max-w-lg mx-auto">
{entityOptions.map((option) => {
const Icon = option.icon
const isSelected = selected === option.value
return (
<button
key={option.value}
type="button"
disabled={option.disabled}
onClick={() => !option.disabled && setSelected(option.value as EntityType)}
className="text-left w-full"
>
<Card
className={cn(
'relative p-4 transition-all',
option.disabled
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer hover:border-primary/50',
isSelected && 'border-primary ring-2 ring-primary/20'
)}
>
<div className="flex items-start gap-4">
<div className={cn(
'p-2.5 rounded-lg',
isSelected ? 'bg-primary/10' : 'bg-muted/50'
)}>
<Icon className={cn(
'h-5 w-5',
isSelected ? 'text-primary' : 'text-muted-foreground'
)} />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{option.label}</span>
{option.disabled && (
<Badge variant="secondary" className="text-xs">Kommer snart</Badge>
)}
</div>
<p className="text-sm text-muted-foreground mt-0.5">
{option.description}
</p>
</div>
{isSelected && (
<div className="flex-shrink-0 p-1 rounded-full bg-primary text-primary-foreground">
<Check className="h-3.5 w-3.5" />
</div>
)}
</div>
</Card>
</button>
)
})}
</div>
<div className="flex justify-end">
<Button
onClick={handleNext}
disabled={!selected || isSaving}
size="lg"
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
<>
Fortsätt
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</div>
</div>
)
}
@@ -0,0 +1,171 @@
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
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 { Loader2, ArrowRight, ArrowLeft } from 'lucide-react'
import type { EntityType } from '@/types'
const schema = z.object({
company_name: z.string().min(1, 'Företagsnamn krävs'),
org_number: z.string().min(1, 'Organisationsnummer krävs'),
address_line1: z.string().optional(),
postal_code: z.string().optional(),
city: z.string().optional(),
})
type FormData = z.infer<typeof schema>
interface Step2Props {
initialData: Partial<FormData>
entityType?: EntityType
onNext: (data: FormData) => void
onBack: () => void
isSaving: boolean
}
export default function Step2CompanyDetails({
initialData,
entityType,
onNext,
onBack,
isSaving,
}: Step2Props) {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
company_name: initialData.company_name || '',
org_number: initialData.org_number || '',
address_line1: initialData.address_line1 || '',
postal_code: initialData.postal_code || '',
city: initialData.city || '',
},
})
const isAB = entityType === 'aktiebolag'
return (
<div className="space-y-6">
<div className="text-center">
<h1 className="text-3xl font-bold tracking-tight">Företagsuppgifter</h1>
<p className="text-muted-foreground mt-2">
Fyll i uppgifter om din verksamhet. Dessa används på fakturor.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Grunduppgifter</CardTitle>
<CardDescription>
{isAB
? 'Ange bolagets registrerade namn och organisationsnummer.'
: 'Ange namn på din verksamhet.'}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onNext)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="company_name">
{isAB ? 'Företagsnamn' : 'Verksamhetsnamn (Eller ditt namn vid EF)'} *
</Label>
<Input
id="company_name"
placeholder={isAB ? 'AB Företaget' : 'Alices Influencer-verksamhet'}
{...register('company_name')}
/>
{errors.company_name && (
<p className="text-sm text-destructive">{errors.company_name.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="org_number">
Organisationsnummer *
</Label>
<Input
id="org_number"
placeholder={isAB ? 'XXXXXX-XXXX' : 'ÅÅMMDD-XXXX (ditt personnummer vid EF)'}
{...register('org_number')}
/>
{errors.org_number && (
<p className="text-sm text-destructive">{errors.org_number.message}</p>
)}
<p className="text-xs text-muted-foreground">
{isAB
? 'Obligatoriskt för aktiebolag'
: 'Vid enskild firma är orgnummer samma som ditt personnummer'}
</p>
</div>
<div className="pt-4 border-t">
<h3 className="font-medium mb-4">Adress (för fakturor)</h3>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="address_line1">Gatuadress</Label>
<Input
id="address_line1"
placeholder="Storgatan 1"
{...register('address_line1')}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="postal_code">Postnummer</Label>
<Input
id="postal_code"
placeholder="123 45"
{...register('postal_code')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="city">Ort</Label>
<Input
id="city"
placeholder="Stockholm"
{...register('city')}
/>
</div>
</div>
</div>
</div>
<div className="flex justify-between pt-4">
<Button
type="button"
variant="ghost"
onClick={onBack}
disabled={isSaving}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
<>
Fortsätt
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,100 @@
'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 { Loader2, ArrowRight, ArrowLeft } from 'lucide-react'
interface Step2LightProps {
initialData: { company_name?: string }
onNext: (data: { company_name: string }) => void
onBack: () => void
isSaving: boolean
}
export default function Step2LightPersonalInfo({
initialData,
onNext,
onBack,
isSaving,
}: Step2LightProps) {
const [name, setName] = useState(initialData.company_name || '')
const [error, setError] = useState<string | null>(null)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const trimmed = name.trim()
if (!trimmed) {
setError('Namn krävs')
return
}
setError(null)
onNext({ company_name: trimmed })
}
return (
<div className="space-y-6">
<div className="text-center">
<h1 className="text-3xl font-bold tracking-tight">Dina uppgifter</h1>
<p className="text-muted-foreground mt-2">
Ange ditt namn som det visas i appen
</p>
</div>
<Card className="max-w-lg mx-auto">
<CardHeader>
<CardTitle>Personuppgifter</CardTitle>
<CardDescription>
Ditt namn används i navigeringen och för att identifiera dig.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="company_name">Fullständigt namn *</Label>
<Input
id="company_name"
placeholder="Anna Andersson"
value={name}
onChange={(e) => {
setName(e.target.value)
if (error) setError(null)
}}
/>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
<div className="flex justify-between pt-4">
<Button
type="button"
variant="ghost"
onClick={onBack}
disabled={isSaving}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
<>
Fortsätt
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,526 @@
'use client'
import { useState, useEffect, useCallback } 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 { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Loader2, ArrowRight, ArrowLeft, Search, Check } from 'lucide-react'
import { createClient } from '@/lib/supabase/client'
import type { MunicipalityRate, UmbrellaProviderDefault } from '@/types'
interface Step3LightProps {
initialData: {
municipality_code?: string
municipal_tax_rate?: number
church_tax?: boolean
church_tax_rate?: number
church_parish_code?: string
umbrella_provider?: string
umbrella_fee_percent?: number
umbrella_pension_percent?: number
umbrella_fee_custom?: boolean
}
onNext: (data: Record<string, unknown>) => void
onBack: () => void
isSaving: boolean
}
export default function Step3LightTaxProfile({
initialData,
onNext,
onBack,
isSaving,
}: Step3LightProps) {
const supabase = createClient()
// Municipality state
const [municipalitySearch, setMunicipalitySearch] = useState('')
const [municipalityResults, setMunicipalityResults] = useState<MunicipalityRate[]>([])
const [selectedMunicipality, setSelectedMunicipality] = useState<MunicipalityRate | null>(null)
const [showMunicipalityDropdown, setShowMunicipalityDropdown] = useState(false)
const [municipalityLoading, setMunicipalityLoading] = useState(false)
// Church tax state
const [churchTax, setChurchTax] = useState(initialData.church_tax ?? false)
const [parishes, setParishes] = useState<MunicipalityRate[]>([])
const [selectedParish, setSelectedParish] = useState<MunicipalityRate | null>(null)
const [parishLoading, setParishLoading] = useState(false)
// Umbrella provider state
const [umbrellaProviders, setUmbrellaProviders] = useState<UmbrellaProviderDefault[]>([])
const [selectedProvider, setSelectedProvider] = useState(initialData.umbrella_provider || '')
const [feePercent, setFeePercent] = useState<number | null>(initialData.umbrella_fee_percent ?? null)
const [pensionPercent, setPensionPercent] = useState<number | null>(initialData.umbrella_pension_percent ?? null)
const [feeCustom, setFeeCustom] = useState(initialData.umbrella_fee_custom ?? false)
const [showCustomFees, setShowCustomFees] = useState(initialData.umbrella_fee_custom ?? false)
// Form state
const [municipalityCode, setMunicipalityCode] = useState(initialData.municipality_code || '')
const [municipalTaxRate, setMunicipalTaxRate] = useState<number | null>(initialData.municipal_tax_rate ?? null)
const [churchTaxRate, setChurchTaxRate] = useState<number | null>(initialData.church_tax_rate ?? null)
const [churchParishCode, setChurchParishCode] = useState(initialData.church_parish_code || '')
// Debounced municipality search
const searchMunicipalities = useCallback(async (query: string) => {
if (query.length < 2) {
setMunicipalityResults([])
return
}
setMunicipalityLoading(true)
try {
const { data, error } = await supabase
.from('municipality_tax_rates')
.select('*')
.ilike('municipality_name', `%${query}%`)
.is('parish_code', null)
.order('municipality_name')
.limit(10)
if (!error && data) {
setMunicipalityResults(data)
}
} catch {
// silently fail
} finally {
setMunicipalityLoading(false)
}
}, [supabase])
// Debounce the municipality search
useEffect(() => {
const timer = setTimeout(() => {
searchMunicipalities(municipalitySearch)
}, 300)
return () => clearTimeout(timer)
}, [municipalitySearch, searchMunicipalities])
// Load initial municipality if we have a code
useEffect(() => {
if (initialData.municipality_code && !selectedMunicipality) {
const loadMunicipality = async () => {
const { data } = await supabase
.from('municipality_tax_rates')
.select('*')
.eq('municipality_code', initialData.municipality_code!)
.is('parish_code', null)
.limit(1)
.single()
if (data) {
setSelectedMunicipality(data)
setMunicipalitySearch(data.municipality_name)
}
}
loadMunicipality()
}
}, [initialData.municipality_code, selectedMunicipality, supabase])
// Load initial parish if we have a parish code
useEffect(() => {
if (initialData.church_parish_code && !selectedParish && initialData.municipality_code) {
const loadParish = async () => {
const { data } = await supabase
.from('municipality_tax_rates')
.select('*')
.eq('parish_code', initialData.church_parish_code!)
.eq('municipality_code', initialData.municipality_code!)
.limit(1)
.single()
if (data) {
setSelectedParish(data)
}
}
loadParish()
}
}, [initialData.church_parish_code, initialData.municipality_code, selectedParish, supabase])
// Fetch parishes when municipality is selected and church tax is on
useEffect(() => {
if (churchTax && municipalityCode) {
const fetchParishes = async () => {
setParishLoading(true)
try {
const { data, error } = await supabase
.from('municipality_tax_rates')
.select('*')
.eq('municipality_code', municipalityCode)
.not('parish_code', 'is', null)
.order('parish_name')
if (!error && data) {
setParishes(data)
}
} catch {
// silently fail
} finally {
setParishLoading(false)
}
}
fetchParishes()
} else {
setParishes([])
}
}, [churchTax, municipalityCode, supabase])
// Fetch umbrella providers on mount
useEffect(() => {
const fetchProviders = async () => {
const { data, error } = await supabase
.from('umbrella_provider_defaults')
.select('*')
.order('display_name')
if (!error && data) {
setUmbrellaProviders(data)
}
}
fetchProviders()
}, [supabase])
// Handle municipality selection
const handleSelectMunicipality = (municipality: MunicipalityRate) => {
setSelectedMunicipality(municipality)
setMunicipalitySearch(municipality.municipality_name)
setMunicipalityCode(municipality.municipality_code)
setMunicipalTaxRate(municipality.total_rate / 100)
setShowMunicipalityDropdown(false)
// Reset parish selection when municipality changes
setSelectedParish(null)
setChurchTaxRate(null)
setChurchParishCode('')
}
// Handle parish selection
const handleSelectParish = (parishCode: string) => {
const parish = parishes.find((p) => p.parish_code === parishCode)
if (parish) {
setSelectedParish(parish)
setChurchTaxRate(parish.church_rate ? parish.church_rate / 100 : null)
setChurchParishCode(parish.parish_code || '')
}
}
// Handle provider selection
const handleSelectProvider = (providerName: string) => {
setSelectedProvider(providerName)
if (providerName === 'Annan') {
setFeePercent(null)
setPensionPercent(null)
setShowCustomFees(true)
setFeeCustom(true)
return
}
const provider = umbrellaProviders.find((p) => p.display_name === providerName)
if (provider) {
setFeePercent(provider.default_fee_percent)
setPensionPercent(provider.pension_percent)
if (!showCustomFees) {
setFeeCustom(false)
}
}
}
// Handle form submit
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onNext({
municipality_code: municipalityCode,
municipal_tax_rate: municipalTaxRate,
church_tax: churchTax,
church_tax_rate: churchTax ? churchTaxRate : null,
church_parish_code: churchTax ? churchParishCode : null,
umbrella_provider: selectedProvider || null,
umbrella_fee_percent: feePercent,
umbrella_pension_percent: pensionPercent,
umbrella_fee_custom: feeCustom,
})
}
return (
<div className="space-y-6">
<div className="text-center">
<h1 className="text-3xl font-bold tracking-tight">Skatteprofil</h1>
<p className="text-muted-foreground mt-2">
Ange din kommun och egenanställningsföretag för korrekt skatteberäkning
</p>
</div>
<Card className="max-w-lg mx-auto">
<CardHeader>
<CardTitle>Kommun</CardTitle>
<CardDescription>
Din kommunalskattesats används för att uppskatta skatt på gåvor och hobbyinkomst.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Municipality search */}
<div className="space-y-2">
<Label htmlFor="municipality_search">Sök kommun</Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="municipality_search"
placeholder="Skriv kommunnamn..."
value={municipalitySearch}
onChange={(e) => {
setMunicipalitySearch(e.target.value)
setShowMunicipalityDropdown(true)
if (selectedMunicipality) {
setSelectedMunicipality(null)
setMunicipalityCode('')
setMunicipalTaxRate(null)
}
}}
onFocus={() => {
if (municipalityResults.length > 0) {
setShowMunicipalityDropdown(true)
}
}}
className="pl-9"
/>
{municipalityLoading && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
)}
{/* Municipality dropdown */}
{showMunicipalityDropdown && municipalityResults.length > 0 && (
<div className="absolute z-50 mt-1 w-full rounded-lg border border-border/60 bg-popover shadow-md max-h-60 overflow-auto">
{municipalityResults.map((municipality) => (
<button
key={municipality.id}
type="button"
className="flex w-full items-center justify-between px-4 py-2.5 text-sm hover:bg-secondary transition-colors"
onClick={() => handleSelectMunicipality(municipality)}
>
<span>{municipality.municipality_name}</span>
<span className="text-muted-foreground text-xs">
{municipality.total_rate.toFixed(2)}%
</span>
</button>
))}
</div>
)}
</div>
{/* Selected municipality display */}
{selectedMunicipality && (
<div className="flex items-center gap-2 mt-2">
<Check className="h-4 w-4 text-primary" />
<span className="text-sm">
{selectedMunicipality.municipality_name}
</span>
<Badge variant="secondary" className="text-xs">
{selectedMunicipality.total_rate.toFixed(2)}% kommunalskatt
</Badge>
</div>
)}
</div>
{/* Church membership section */}
<div className="pt-4 border-t space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="church_tax">Medlem i Svenska kyrkan?</Label>
<p className="text-xs text-muted-foreground">
Kyrkoskatt tillkommer utöver kommunalskatten
</p>
</div>
<Switch
id="church_tax"
checked={churchTax}
onCheckedChange={(checked) => {
setChurchTax(checked)
if (!checked) {
setSelectedParish(null)
setChurchTaxRate(null)
setChurchParishCode('')
}
}}
/>
</div>
{churchTax && municipalityCode && (
<div className="space-y-2">
<Label>Församling</Label>
{parishLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Laddar församlingar...
</div>
) : parishes.length > 0 ? (
<Select
value={churchParishCode}
onValueChange={handleSelectParish}
>
<SelectTrigger>
<SelectValue placeholder="Välj församling" />
</SelectTrigger>
<SelectContent>
{parishes.map((parish) => (
<SelectItem
key={parish.parish_code}
value={parish.parish_code!}
>
{parish.parish_name} ({parish.church_rate?.toFixed(2)}%)
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-sm text-muted-foreground">
Inga församlingar hittades för vald kommun.
</p>
)}
{selectedParish && selectedParish.church_rate && (
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-primary" />
<span className="text-sm">
Kyrkoskatt: {selectedParish.church_rate.toFixed(2)}%
</span>
</div>
)}
</div>
)}
{churchTax && !municipalityCode && (
<p className="text-sm text-muted-foreground">
Välj kommun ovan för att se församlingar.
</p>
)}
</div>
{/* Umbrella provider section */}
<div className="pt-4 border-t space-y-4">
<div>
<Label>Egenanställningsföretag</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Välj det företag du arbetar via för att beräkna avgifter korrekt.
</p>
</div>
<Select
value={selectedProvider}
onValueChange={handleSelectProvider}
>
<SelectTrigger>
<SelectValue placeholder="Välj egenanställningsföretag" />
</SelectTrigger>
<SelectContent>
{umbrellaProviders.map((provider) => (
<SelectItem
key={provider.id}
value={provider.display_name}
>
{provider.display_name}
</SelectItem>
))}
<SelectItem value="Annan">Annan</SelectItem>
</SelectContent>
</Select>
{selectedProvider && feePercent !== null && !showCustomFees && (
<div className="flex items-center gap-3 text-sm">
<Badge variant="secondary">
Avgift: {feePercent}%
</Badge>
{pensionPercent !== null && pensionPercent > 0 && (
<Badge variant="secondary">
Pension: {pensionPercent}%
</Badge>
)}
</div>
)}
{selectedProvider && !showCustomFees && selectedProvider !== 'Annan' && (
<button
type="button"
className="text-sm text-primary hover:underline"
onClick={() => {
setShowCustomFees(true)
setFeeCustom(true)
}}
>
Anpassa avgifter
</button>
)}
{showCustomFees && (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="fee_percent">Avgift (%)</Label>
<Input
id="fee_percent"
type="number"
step="0.1"
min="0"
max="100"
placeholder="t.ex. 6"
value={feePercent ?? ''}
onChange={(e) => {
setFeePercent(e.target.value ? parseFloat(e.target.value) : null)
setFeeCustom(true)
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="pension_percent">Pension (%)</Label>
<Input
id="pension_percent"
type="number"
step="0.1"
min="0"
max="100"
placeholder="t.ex. 4.5"
value={pensionPercent ?? ''}
onChange={(e) => {
setPensionPercent(e.target.value ? parseFloat(e.target.value) : null)
setFeeCustom(true)
}}
/>
</div>
</div>
)}
</div>
{/* Navigation buttons */}
<div className="flex justify-between pt-4">
<Button
type="button"
variant="ghost"
onClick={onBack}
disabled={isSaving}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
<>
Fortsätt
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,280 @@
'use client'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
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 { Checkbox } from '@/components/ui/checkbox'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { Loader2, ArrowRight, ArrowLeft } from 'lucide-react'
import type { MomsPeriod } from '@/types'
const schema = z.object({
f_skatt: z.boolean(),
fiscal_year_start_month: z.number().min(1).max(12),
vat_registered: z.boolean(),
vat_number: z.string().optional(),
moms_period: z.enum(['monthly', 'quarterly', 'yearly']).optional(),
})
type FormData = z.infer<typeof schema>
interface Step3Props {
initialData: Partial<FormData>
onNext: (data: FormData) => void
onBack: () => void
isSaving: boolean
}
const months = [
{ value: 1, label: 'Januari' },
{ value: 2, label: 'Februari' },
{ value: 3, label: 'Mars' },
{ value: 4, label: 'April' },
{ value: 5, label: 'Maj' },
{ value: 6, label: 'Juni' },
{ value: 7, label: 'Juli' },
{ value: 8, label: 'Augusti' },
{ value: 9, label: 'September' },
{ value: 10, label: 'Oktober' },
{ value: 11, label: 'November' },
{ value: 12, label: 'December' },
]
export default function Step3TaxRegistration({
initialData,
onNext,
onBack,
isSaving,
}: Step3Props) {
const {
register,
handleSubmit,
watch,
control,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
f_skatt: initialData.f_skatt ?? true,
fiscal_year_start_month: initialData.fiscal_year_start_month ?? 1,
vat_registered: initialData.vat_registered ?? false,
vat_number: initialData.vat_number || '',
moms_period: initialData.moms_period,
},
})
const vatRegistered = watch('vat_registered')
return (
<div className="space-y-6">
<div className="text-center">
<h1 className="text-3xl font-bold tracking-tight">Skatteregistrering</h1>
<p className="text-muted-foreground mt-2">
Ange information om din skatteregistrering.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>F-skatt och räkenskapsår</CardTitle>
<CardDescription>
Dessa uppgifter används för att beräkna din skattesituation.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onNext)} className="space-y-6">
{/* F-skatt */}
<div className="flex items-start space-x-3">
<Controller
name="f_skatt"
control={control}
render={({ field }) => (
<Checkbox
id="f_skatt"
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
/>
<div className="space-y-1">
<InfoTooltip
content={
<div className="space-y-2">
<p className="font-medium">Vad är F-skatt?</p>
<p>F-skatt betyder att du själv ansvarar för att betala skatt och avgifter till Skatteverket varje månad.</p>
<p className="text-xs text-muted-foreground">De flesta som driver företag har F-skatt. Utan F-skatt måste dina kunder göra skatteavdrag på dina fakturor.</p>
</div>
}
side="right"
>
<Label htmlFor="f_skatt" className="cursor-pointer">
Jag har F-skattsedel
</Label>
</InfoTooltip>
<p className="text-sm text-muted-foreground">
F-skatt innebär att du själv ansvarar för att betala in skatt och avgifter.
</p>
</div>
</div>
{/* Fiscal year */}
<div className="space-y-2">
<Label>Räkenskapsår börjar</Label>
<Controller
name="fiscal_year_start_month"
control={control}
render={({ field }) => (
<Select
value={field.value?.toString()}
onValueChange={(value) => field.onChange(parseInt(value))}
>
<SelectTrigger>
<SelectValue placeholder="Välj månad" />
</SelectTrigger>
<SelectContent>
{months.map((month) => (
<SelectItem key={month.value} value={month.value.toString()}>
{month.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<p className="text-sm text-muted-foreground">
De flesta har kalenderår (januari). Brutet räkenskapsår är vanligare för aktiebolag.
</p>
</div>
{/* VAT section */}
<div className="pt-4 border-t space-y-4">
<h3 className="font-medium flex items-center gap-2">
<InfoTooltip
content={
<div className="space-y-2">
<p className="font-medium">Behöver jag momsregistrera mig?</p>
<p>Ja, om din omsättning överstiger 80 000 kr per år. Med moms lägger du på 25% extra på dina fakturor, men får också dra av moms på dina inköp.</p>
<p className="text-xs text-muted-foreground">Som influencer med sponsorintäkter är du troligen momsregistrerad.</p>
</div>
}
side="right"
>
<span>Momsregistrering</span>
</InfoTooltip>
</h3>
<div className="flex items-start space-x-3">
<Controller
name="vat_registered"
control={control}
render={({ field }) => (
<Checkbox
id="vat_registered"
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
/>
<div className="space-y-1">
<Label htmlFor="vat_registered" className="cursor-pointer">
Jag är momsregistrerad
</Label>
<p className="text-sm text-muted-foreground">
Obligatoriskt om din omsättning överstiger 80 000 kr per år.
</p>
</div>
</div>
{vatRegistered && (
<div className="space-y-4 pl-7">
<div className="space-y-2">
<Label htmlFor="vat_number">Momsregistreringsnummer</Label>
<Input
id="vat_number"
placeholder="SE123456789001"
{...register('vat_number')}
/>
<p className="text-xs text-muted-foreground">
Format: SE + organisationsnummer + 01
</p>
</div>
<div className="space-y-2">
<InfoTooltip
content={
<div className="space-y-2">
<p className="font-medium">Hur ofta rapporterar du moms?</p>
<p>Osäker? Börja med kvartal - det är vanligast och du kan ändra senare.</p>
<ul className="text-xs text-muted-foreground space-y-1">
<li>Under 1 miljon/år = Kan välja årsredovisning</li>
<li>1-40 miljoner = Kvartal</li>
<li>Över 40 miljoner = Månad</li>
</ul>
</div>
}
side="right"
>
<Label>Momsredovisningsperiod</Label>
</InfoTooltip>
<Controller
name="moms_period"
control={control}
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange}
>
<SelectTrigger>
<SelectValue placeholder="Välj period" />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Månad</SelectItem>
<SelectItem value="quarterly">Kvartal (rekommenderas)</SelectItem>
<SelectItem value="yearly">År</SelectItem>
</SelectContent>
</Select>
)}
/>
<p className="text-xs text-muted-foreground">
Osäker? Välj kvartal - det passar de flesta och du kan ändra senare.
</p>
</div>
</div>
)}
</div>
<div className="flex justify-between pt-4">
<Button
type="button"
variant="ghost"
onClick={onBack}
disabled={isSaving}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
<>
Fortsätt
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,140 @@
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
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 { Loader2, ArrowRight, ArrowLeft, Calculator, SkipForward } from 'lucide-react'
const schema = z.object({
preliminary_tax_monthly: z.number().min(0).optional().nullable(),
})
type FormData = z.infer<typeof schema>
interface Step4Props {
initialData: Partial<FormData>
onNext: (data: FormData) => void
onBack: () => void
onSkip: () => void
isSaving: boolean
}
export default function Step4PreliminaryTax({
initialData,
onNext,
onBack,
onSkip,
isSaving,
}: Step4Props) {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
preliminary_tax_monthly: initialData.preliminary_tax_monthly ?? undefined,
},
})
const onSubmit = (data: FormData) => {
onNext({
preliminary_tax_monthly: data.preliminary_tax_monthly || null,
})
}
return (
<div className="space-y-6">
<div className="text-center">
<h1 className="text-3xl font-bold tracking-tight">Preliminärskatt</h1>
<p className="text-muted-foreground mt-2">
Ange din månatliga F-skatt för att få bättre överblick över din ekonomi.
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calculator className="h-5 w-5" />
Månatlig F-skatt
</CardTitle>
<CardDescription>
Detta är frivilligt men hjälper oss visa varningar om du betalar för lite skatt.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="preliminary_tax_monthly">
Preliminärskatt per månad (kr)
</Label>
<Input
id="preliminary_tax_monthly"
type="number"
placeholder="0"
{...register('preliminary_tax_monthly', { valueAsNumber: true })}
/>
{errors.preliminary_tax_monthly && (
<p className="text-sm text-destructive">
{errors.preliminary_tax_monthly.message}
</p>
)}
<p className="text-sm text-muted-foreground">
Beloppet hittar du på ditt F-skattsedelsbeslut från Skatteverket.
</p>
</div>
<div className="bg-muted/50 rounded-lg p-4">
<h4 className="font-medium mb-2">Varför är detta användbart?</h4>
<ul className="text-sm text-muted-foreground space-y-1">
<li>• Vi jämför din inbetalda skatt mot beräknad skatt</li>
<li>• Du får varningar om du riskerar restskatt</li>
<li>• Lättare att planera för slutskattebeskedet</li>
</ul>
</div>
<div className="flex justify-between pt-4">
<Button
type="button"
variant="ghost"
onClick={onBack}
disabled={isSaving}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={onSkip}
disabled={isSaving}
>
<SkipForward className="mr-2 h-4 w-4" />
Hoppa över
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
<>
Fortsätt
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</div>
</div>
</form>
</CardContent>
</Card>
</div>
)
}
+198
View File
@@ -0,0 +1,198 @@
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
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 { InfoTooltip } from '@/components/ui/info-tooltip'
import { Loader2, ArrowRight, ArrowLeft, Landmark } from 'lucide-react'
const schema = z.object({
bank_name: z.string().optional(),
clearing_number: z.string().optional(),
account_number: z.string().optional(),
iban: z.string().optional(),
bic: z.string().optional(),
})
type FormData = z.infer<typeof schema>
interface Step5Props {
initialData: Partial<FormData>
onNext: (data: FormData) => void
onBack: () => void
isSaving: boolean
}
export default function Step5BankDetails({
initialData,
onNext,
onBack,
isSaving,
}: Step5Props) {
const {
register,
handleSubmit,
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
bank_name: initialData.bank_name || '',
clearing_number: initialData.clearing_number || '',
account_number: initialData.account_number || '',
iban: initialData.iban || '',
bic: initialData.bic || '',
},
})
return (
<div className="space-y-6">
<div className="text-center">
<h1 className="text-3xl font-bold tracking-tight">Bankuppgifter för fakturor</h1>
<p className="text-muted-foreground mt-2">
Dessa uppgifter visas på dina fakturor så att kunder kan betala dig.
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Landmark className="h-5 w-5" />
Bankkonto
</CardTitle>
<CardDescription>
Ange kontot som kunder ska betala till.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onNext)} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="bank_name">Bank</Label>
<Input
id="bank_name"
placeholder="t.ex. Nordea, SEB, Swedbank"
{...register('bank_name')}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<InfoTooltip
content={
<div className="space-y-2">
<p className="font-medium">Vad är clearingnummer?</p>
<p>De första 4-5 siffrorna i ditt kontonummer som identifierar din bank.</p>
<ul className="text-xs text-muted-foreground space-y-1">
<li>Nordea: 3300</li>
<li>SEB: 5000</li>
<li>Swedbank: 8XXX</li>
<li>Handelsbanken: 6XXX</li>
<li>Avanza: 9550/9551</li>
</ul>
</div>
}
side="top"
>
<Label htmlFor="clearing_number">Clearingnummer</Label>
</InfoTooltip>
<Input
id="clearing_number"
placeholder="XXXX"
{...register('clearing_number')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="account_number">Kontonummer</Label>
<Input
id="account_number"
placeholder="XXX XXX XXX"
{...register('account_number')}
/>
</div>
</div>
<div className="pt-4 border-t">
<h4 className="font-medium mb-4">Internationella betalningar (valfritt)</h4>
<p className="text-sm text-muted-foreground mb-4">
För kunder utanför Sverige som betalar via banköverföring.
</p>
<div className="space-y-4">
<div className="space-y-2">
<InfoTooltip
content={
<div className="space-y-2">
<p className="font-medium">Vad är IBAN?</p>
<p>Internationellt bankkontonummer. Svenska IBAN börjar med SE och har 24 tecken totalt.</p>
<p className="text-xs text-muted-foreground">Din bank kan ge dig ditt IBAN - kolla internetbanken eller kontakta dem.</p>
</div>
}
side="right"
>
<Label htmlFor="iban">IBAN</Label>
</InfoTooltip>
<Input
id="iban"
placeholder="SE00 0000 0000 0000 0000 0000"
{...register('iban')}
/>
</div>
<div className="space-y-2">
<InfoTooltip
content={
<div className="space-y-2">
<p className="font-medium">Vad är BIC/SWIFT?</p>
<p>Bankens internationella id-kod. Används tillsammans med IBAN för utlandsbetalningar.</p>
<ul className="text-xs text-muted-foreground space-y-1">
<li>Nordea: NDEASESS</li>
<li>SEB: ESSESESS</li>
<li>Swedbank: SWEDSESS</li>
<li>Handelsbanken: HANDSESS</li>
</ul>
</div>
}
side="right"
>
<Label htmlFor="bic">BIC/SWIFT</Label>
</InfoTooltip>
<Input
id="bic"
placeholder="XXXXSESS"
{...register('bic')}
/>
</div>
</div>
</div>
<div className="flex justify-between pt-4">
<Button
type="button"
variant="ghost"
onClick={onBack}
disabled={isSaving}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
<>
Fortsätt
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}
+273
View File
@@ -0,0 +1,273 @@
'use client'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
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 { InfoTooltip } from '@/components/ui/info-tooltip'
import { Loader2, ArrowLeft, Landmark, SkipForward, ChevronDown, ChevronUp } from 'lucide-react'
import { BankSelector, type Bank } from '@/components/banking/BankSelector'
const manualBankSchema = z.object({
bank_name: z.string().optional(),
clearing_number: z.string().optional(),
account_number: z.string().optional(),
iban: z.string().optional(),
bic: z.string().optional(),
})
type ManualBankData = z.infer<typeof manualBankSchema>
interface Step6Props {
initialData?: Partial<ManualBankData>
onComplete: (data?: ManualBankData) => void
onBack: () => void
onSkip: () => void
isSaving: boolean
}
export default function Step6ConnectBank({
initialData,
onBack,
onSkip,
onComplete,
isSaving,
}: Step6Props) {
const [isConnecting, setIsConnecting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showManual, setShowManual] = useState(false)
const {
register,
handleSubmit,
} = useForm<ManualBankData>({
resolver: zodResolver(manualBankSchema),
defaultValues: {
bank_name: initialData?.bank_name || '',
clearing_number: initialData?.clearing_number || '',
account_number: initialData?.account_number || '',
iban: initialData?.iban || '',
bic: initialData?.bic || '',
},
})
const handleBankSelect = async (bank: Bank) => {
setIsConnecting(true)
setError(null)
try {
const response = await fetch('/api/banking/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
aspsp_name: bank.name,
aspsp_country: bank.country,
}),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Kunde inte ansluta bank')
}
window.location.href = data.authorization_url
} catch (err) {
setError(err instanceof Error ? err.message : 'Ett fel uppstod')
setIsConnecting(false)
}
}
const onManualSubmit = (data: ManualBankData) => {
onComplete(data)
}
return (
<div className="space-y-6">
<div className="text-center">
<h1 className="text-3xl font-bold tracking-tight">Anslut din bank</h1>
<p className="text-muted-foreground mt-2">
Koppla din bank för att automatiskt importera transaktioner via PSD2.
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Landmark className="h-5 w-5" />
Välj din bank
</CardTitle>
<CardDescription>
Vi använder säker bankintegration (PSD2) för att hämta dina transaktioner.
Vi kan aldrig flytta pengar eller göra ändringar.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<BankSelector
onSelect={handleBankSelect}
isLoading={isConnecting}
/>
{error && (
<div className="p-3 bg-destructive/10 text-destructive rounded-lg text-sm">
{error}
</div>
)}
<div className="bg-muted/50 rounded-lg p-4">
<h4 className="font-medium mb-2">Säker anslutning</h4>
<ul className="text-sm text-muted-foreground space-y-1">
<li>• Krypterad anslutning via PSD2</li>
<li>• Vi kan endast läsa transaktioner</li>
<li>• Du kan koppla bort när som helst</li>
<li>• Samtycke gäller i 90 dagar</li>
</ul>
</div>
{/* Collapsible manual bank details */}
<div className="border-t pt-4">
<button
type="button"
onClick={() => setShowManual(!showManual)}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full"
>
{showManual ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
<span>Ange bankuppgifter manuellt</span>
</button>
{showManual && (
<form onSubmit={handleSubmit(onManualSubmit)} className="space-y-4 mt-4 animate-fade-in">
<p className="text-sm text-muted-foreground">
Dessa uppgifter visas på dina fakturor så att kunder kan betala dig.
</p>
<div className="space-y-2">
<Label htmlFor="bank_name">Bank</Label>
<Input
id="bank_name"
placeholder="t.ex. Nordea, SEB, Swedbank"
{...register('bank_name')}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<InfoTooltip
content={
<div className="space-y-2">
<p className="font-medium">Vad är clearingnummer?</p>
<p>De första 4-5 siffrorna i ditt kontonummer som identifierar din bank.</p>
<ul className="text-xs text-muted-foreground space-y-1">
<li>Nordea: 3300</li>
<li>SEB: 5000</li>
<li>Swedbank: 8XXX</li>
<li>Handelsbanken: 6XXX</li>
<li>Avanza: 9550/9551</li>
</ul>
</div>
}
side="top"
>
<Label htmlFor="clearing_number">Clearingnummer</Label>
</InfoTooltip>
<Input
id="clearing_number"
placeholder="XXXX"
{...register('clearing_number')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="account_number">Kontonummer</Label>
<Input
id="account_number"
placeholder="XXX XXX XXX"
{...register('account_number')}
/>
</div>
</div>
<div className="pt-4 border-t">
<h4 className="font-medium mb-4">Internationella betalningar (valfritt)</h4>
<div className="space-y-4">
<div className="space-y-2">
<InfoTooltip
content={
<div className="space-y-2">
<p className="font-medium">Vad är IBAN?</p>
<p>Internationellt bankkontonummer. Svenska IBAN börjar med SE och har 24 tecken totalt.</p>
</div>
}
side="right"
>
<Label htmlFor="iban">IBAN</Label>
</InfoTooltip>
<Input
id="iban"
placeholder="SE00 0000 0000 0000 0000 0000"
{...register('iban')}
/>
</div>
<div className="space-y-2">
<InfoTooltip
content={
<div className="space-y-2">
<p className="font-medium">Vad är BIC/SWIFT?</p>
<p>Bankens internationella id-kod. Används tillsammans med IBAN för utlandsbetalningar.</p>
</div>
}
side="right"
>
<Label htmlFor="bic">BIC/SWIFT</Label>
</InfoTooltip>
<Input
id="bic"
placeholder="XXXXSESS"
{...register('bic')}
/>
</div>
</div>
</div>
<Button type="submit" className="w-full" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
'Spara bankuppgifter och slutför'
)}
</Button>
</form>
)}
</div>
<div className="flex justify-between pt-4">
<Button
type="button"
variant="ghost"
onClick={onBack}
disabled={isConnecting}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<Button
type="button"
variant="outline"
onClick={onSkip}
disabled={isConnecting}
>
<SkipForward className="mr-2 h-4 w-4" />
Hoppa över
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
+211
View File
@@ -0,0 +1,211 @@
'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
}
+473
View File
@@ -0,0 +1,473 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, 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 { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
import {
X,
Camera,
Check,
Loader2,
Gift,
Building,
User,
HelpCircle,
ArrowRight,
} from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import ReceiptCamera from './ReceiptCamera'
import type { GiftClassification, Gift as GiftType } from '@/types'
interface ProductCaptureProps {
onComplete: (gift: GiftType) => void
onCancel: () => void
}
type Step = 'capture' | 'details' | 'classification' | 'result'
export default function ProductCapture({ onComplete, onCancel }: ProductCaptureProps) {
const [step, setStep] = useState<Step>('capture')
const [isProcessing, setIsProcessing] = useState(false)
const [isEstimating, setIsEstimating] = useState(false)
// Image data
const [imageData, setImageData] = useState<string | null>(null)
const [mimeType, setMimeType] = useState<string>('image/jpeg')
// Product details
const [brandName, setBrandName] = useState('')
const [description, setDescription] = useState('')
const [estimatedValue, setEstimatedValue] = useState<number | null>(null)
const [aiEstimate, setAiEstimate] = useState<{ value: number; confidence: number } | null>(null)
// Classification inputs
const [hasMotprestation, setHasMotprestation] = useState(false)
const [usedInBusiness, setUsedInBusiness] = useState(false)
const [usedPrivately, setUsedPrivately] = useState(false)
const [isSimplePromo, setIsSimplePromo] = useState(false)
// Result
const [classification, setClassification] = useState<GiftClassification | null>(null)
const [createdGift, setCreatedGift] = useState<GiftType | null>(null)
// Handle image capture
const handleCapture = async (base64Data: string, type: string) => {
setImageData(base64Data)
setMimeType(type)
setStep('details')
// Call AI estimate in the background (non-blocking)
setIsEstimating(true)
try {
const byteCharacters = atob(base64Data)
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
const blob = new Blob([byteArray], { type })
const formData = new FormData()
formData.append('image', blob, 'product.jpg')
const response = await fetch('/api/gifts/estimate', {
method: 'POST',
body: formData,
})
if (response.ok) {
const { data } = await response.json()
if (data) {
setAiEstimate({ value: data.estimatedValue, confidence: data.confidence })
// Only pre-fill empty fields
setEstimatedValue((prev) => prev ?? data.estimatedValue)
setDescription((prev) => (prev ? prev : data.description || ''))
setBrandName((prev) => (prev ? prev : data.brand || ''))
}
}
} catch (error) {
// Silently continue — user can still enter values manually
console.error('AI estimation failed:', error)
} finally {
setIsEstimating(false)
}
}
// Handle details submission
const handleDetailsSubmit = () => {
if (!estimatedValue || estimatedValue <= 0) {
alert('Ange ett uppskattat värde')
return
}
if (!description.trim()) {
alert('Ange en beskrivning av produkten')
return
}
setStep('classification')
}
// Handle classification submission
const handleClassificationSubmit = async () => {
if (!imageData || !estimatedValue) return
setIsProcessing(true)
try {
const formData = new FormData()
// Convert base64 to blob
const byteCharacters = atob(imageData)
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
const blob = new Blob([byteArray], { type: mimeType })
formData.append('image', blob, 'product.jpg')
formData.append('estimated_value', estimatedValue.toString())
formData.append('brand_name', brandName || 'Okänt varumärke')
formData.append('description', description)
formData.append('has_motprestation', hasMotprestation.toString())
formData.append('used_in_business', usedInBusiness.toString())
formData.append('used_privately', usedPrivately.toString())
formData.append('is_simple_promo', isSimplePromo.toString())
const response = await fetch('/api/receipts/product', {
method: 'POST',
body: formData,
})
const data = await response.json()
if (response.ok && data.data) {
setClassification(data.data.classification)
setCreatedGift(data.data.gift)
setStep('result')
} else {
alert(data.error || 'Kunde inte registrera produkten')
}
} catch (error) {
console.error('Submit error:', error)
alert('Något gick fel. Försök igen.')
} finally {
setIsProcessing(false)
}
}
// Handle completion
const handleComplete = () => {
if (createdGift) {
onComplete(createdGift)
}
}
// Render camera step
if (step === 'capture') {
return <ReceiptCamera onCapture={handleCapture} onClose={onCancel} />
}
// Render details step
if (step === 'details') {
return (
<div className="fixed inset-0 bg-background z-50 flex flex-col">
<div className="flex items-center justify-between p-4 border-b">
<Button variant="ghost" size="icon" onClick={onCancel}>
<X className="h-5 w-5" />
</Button>
<h1 className="font-semibold">Produktdetaljer</h1>
<div className="w-10" />
</div>
<div className="flex-1 overflow-auto p-4 space-y-4">
{/* Image preview */}
{imageData && (
<div className="relative">
<img
src={`data:${mimeType};base64,${imageData}`}
alt="Product"
className="w-full max-h-48 object-contain rounded-lg bg-muted"
/>
<Button
variant="secondary"
size="sm"
className="absolute bottom-2 right-2"
onClick={() => setStep('capture')}
>
<Camera className="mr-2 h-4 w-4" />
Ta om
</Button>
</div>
)}
{/* AI estimation indicator */}
{isEstimating && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm text-muted-foreground">AI analyserar produkten...</span>
</div>
)}
{/* Product details form */}
<div className="space-y-4">
<div>
<Label htmlFor="brand">Varumärke (valfritt)</Label>
<Input
id="brand"
value={brandName}
onChange={(e) => setBrandName(e.target.value)}
placeholder="T.ex. Apple, Nike, Samsung..."
/>
</div>
<div>
<Label htmlFor="description">Beskrivning *</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Vad är det för produkt?"
rows={2}
/>
</div>
<div>
<Label htmlFor="value">Uppskattat marknadsvärde (SEK) *</Label>
<Input
id="value"
type="number"
min={0}
value={estimatedValue || ''}
onChange={(e) => setEstimatedValue(parseFloat(e.target.value) || null)}
placeholder="0"
/>
{aiEstimate && (
<div className="flex items-center gap-2 mt-1">
<p className="text-xs text-muted-foreground">
AI-uppskattning: {formatCurrency(aiEstimate.value, 'SEK')}
</p>
<Badge variant="outline" className="text-xs">
{Math.round(aiEstimate.confidence * 100)}% säkerhet
</Badge>
{estimatedValue !== aiEstimate.value && (
<Button
variant="link"
size="sm"
className="h-auto p-0 text-xs"
onClick={() => setEstimatedValue(aiEstimate.value)}
>
Använd AI-uppskattning
</Button>
)}
</div>
)}
</div>
</div>
</div>
<div className="p-4 border-t">
<Button className="w-full" onClick={handleDetailsSubmit} disabled={isProcessing}>
Fortsätt
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
)
}
// Render classification step
if (step === 'classification') {
return (
<div className="fixed inset-0 bg-background z-50 flex flex-col">
<div className="flex items-center justify-between p-4 border-b">
<Button variant="ghost" size="icon" onClick={() => setStep('details')}>
<X className="h-5 w-5" />
</Button>
<h1 className="font-semibold">Klassificera förmån</h1>
<div className="w-10" />
</div>
<div className="flex-1 overflow-auto p-4 space-y-4">
{/* Product summary */}
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-3">
<Gift className="h-8 w-8 text-muted-foreground" />
<div>
<p className="font-medium">{description}</p>
<p className="text-lg font-bold">{formatCurrency(estimatedValue || 0, 'SEK')}</p>
</div>
</div>
</CardContent>
</Card>
{/* Classification questions */}
<div className="space-y-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<HelpCircle className="h-4 w-4" />
Frågor för klassificering
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Motprestation */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Krävdes motprestation?</Label>
<p className="text-xs text-muted-foreground">
T.ex. inlägg, recension, omnämnande
</p>
</div>
<Switch
checked={hasMotprestation}
onCheckedChange={setHasMotprestation}
/>
</div>
{/* Used in business */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Används i verksamheten?</Label>
<p className="text-xs text-muted-foreground">
Som rekvisita, utrustning, material
</p>
</div>
<Switch
checked={usedInBusiness}
onCheckedChange={setUsedInBusiness}
/>
</div>
{/* Used privately */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Används privat?</Label>
<p className="text-xs text-muted-foreground">
För eget bruk, hemma, fritid
</p>
</div>
<Switch
checked={usedPrivately}
onCheckedChange={setUsedPrivately}
/>
</div>
{/* Simple promo */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Enkel reklamprodukt?</Label>
<p className="text-xs text-muted-foreground">
Penna, mugg, t-shirt med logga
</p>
</div>
<Switch
checked={isSimplePromo}
onCheckedChange={setIsSimplePromo}
/>
</div>
</CardContent>
</Card>
</div>
</div>
<div className="p-4 border-t">
<Button className="w-full" onClick={handleClassificationSubmit} disabled={isProcessing}>
{isProcessing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Klassificerar...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Klassificera
</>
)}
</Button>
</div>
</div>
)
}
// Render result step
if (step === 'result' && classification) {
return (
<div className="fixed inset-0 bg-background z-50 flex flex-col">
<div className="flex items-center justify-between p-4 border-b">
<div className="w-10" />
<h1 className="font-semibold">Resultat</h1>
<div className="w-10" />
</div>
<div className="flex-1 overflow-auto p-4 space-y-4">
{/* Result card */}
<Card className={classification.taxable ? 'border-orange-500' : 'border-green-500'}>
<CardContent className="pt-6 text-center">
<div
className={`h-16 w-16 rounded-full flex items-center justify-center mx-auto mb-4 ${
classification.taxable ? 'bg-orange-100' : 'bg-green-100'
}`}
>
{classification.taxable ? (
<User className="h-8 w-8 text-orange-600" />
) : (
<Building className="h-8 w-8 text-green-600" />
)}
</div>
<h2 className="text-xl font-bold mb-2">
{classification.taxable ? 'Skattepliktig förmån' : 'Skattefri förmån'}
</h2>
<Badge variant={classification.taxable ? 'destructive' : 'default'} className="mb-4">
{formatCurrency(classification.marketValue, 'SEK')}
</Badge>
<p className="text-sm text-muted-foreground">{classification.reasoning}</p>
{classification.deductibleAsExpense && (
<div className="mt-4 p-3 rounded-lg bg-green-50 dark:bg-green-950/20 text-sm">
<p className="font-medium text-green-700 dark:text-green-300">
Avdragsgill som företagskostnad
</p>
</div>
)}
</CardContent>
</Card>
{/* Booking type info */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Bokföringstyp</CardTitle>
</CardHeader>
<CardContent>
<Badge variant="outline">{classification.bookingType}</Badge>
<p className="text-xs text-muted-foreground mt-2">
{classification.bookingType === 'income' &&
'Bokförs som intäkt (förmånsvärde)'}
{classification.bookingType === 'income_and_expense' &&
'Bokförs som både intäkt och kostnad'}
{classification.bookingType === 'tax_free' &&
'Ingen bokföringsåtgärd krävs'}
</p>
</CardContent>
</Card>
</div>
<div className="p-4 border-t">
<Button className="w-full" onClick={handleComplete}>
<Check className="mr-2 h-4 w-4" />
Klar
</Button>
</div>
</div>
)
}
return null
}
+336
View File
@@ -0,0 +1,336 @@
'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>
)
}
+179
View File
@@ -0,0 +1,179 @@
'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, Package, ArrowRight } from 'lucide-react'
import type { ReceiptQueueSummary } from '@/types'
interface ReceiptDashboardProps {
summary: ReceiptQueueSummary
onScanReceipt: () => void
onRegisterProduct: () => void
onViewReceiptQueue: () => void
onViewTransactionQueue: () => void
}
export default function ReceiptDashboard({
summary,
onScanReceipt,
onRegisterProduct,
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-2 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>
<Button
variant="outline"
className="h-auto py-4 flex-col gap-2"
onClick={onRegisterProduct}
>
<Package className="h-6 w-6" />
<span>Logga produkt</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>
)
}
@@ -0,0 +1,99 @@
'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>
)
}
+418
View File
@@ -0,0 +1,418 @@
'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>
)
}
+229
View File
@@ -0,0 +1,229 @@
'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>
)
}
+6
View File
@@ -0,0 +1,6 @@
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'
export { default as ProductCapture } from './ProductCapture'
@@ -0,0 +1,372 @@
'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 { Badge } from '@/components/ui/badge'
import { Calendar, Copy, RefreshCw, Loader2, ExternalLink, Check } from 'lucide-react'
import type { CalendarFeed } from '@/types'
interface CalendarFeedWithUrls extends CalendarFeed {
webcalUrl: string
httpsUrl: string
}
export function CalendarFeedSettings() {
const { toast } = useToast()
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [isRegenerating, setIsRegenerating] = useState(false)
const [feed, setFeed] = useState<CalendarFeedWithUrls | null>(null)
const [copied, setCopied] = useState(false)
useEffect(() => {
fetchFeed()
}, [])
const fetchFeed = async () => {
setIsLoading(true)
const response = await fetch('/api/calendar/feed')
const { data } = await response.json()
setFeed(data)
setIsLoading(false)
}
const createFeed = async () => {
setIsSaving(true)
try {
const response = await fetch('/api/calendar/feed', {
method: 'POST',
})
if (!response.ok) {
throw new Error('Failed to create feed')
}
const { data } = await response.json()
setFeed(data)
toast({
title: 'Kalenderfeed skapad',
description: 'Du kan nu koppla kalendern till Apple Calendar eller Google Calendar.',
})
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte skapa kalenderfeed.',
variant: 'destructive',
})
} finally {
setIsSaving(false)
}
}
const updateFeed = async (key: keyof CalendarFeed, value: boolean) => {
if (!feed) return
setIsSaving(true)
try {
const response = await fetch('/api/calendar/feed', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [key]: value }),
})
if (!response.ok) {
throw new Error('Failed to update feed')
}
const { data } = await response.json()
setFeed(data)
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte uppdatera inställning.',
variant: 'destructive',
})
} finally {
setIsSaving(false)
}
}
const regenerateToken = async () => {
if (!confirm('Är du säker? Den gamla länken kommer sluta fungera.')) {
return
}
setIsRegenerating(true)
try {
const response = await fetch('/api/calendar/feed', {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Failed to regenerate token')
}
const { data } = await response.json()
setFeed(data)
toast({
title: 'Ny länk skapad',
description: 'Den gamla länken fungerar inte längre.',
})
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte skapa ny länk.',
variant: 'destructive',
})
} finally {
setIsRegenerating(false)
}
}
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
toast({
title: 'Kopierad',
description: 'Länken har kopierats till urklipp.',
})
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte kopiera länken.',
variant: 'destructive',
})
}
}
const openWebcal = () => {
if (feed?.webcalUrl) {
window.location.href = feed.webcalUrl
}
}
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 (!feed) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Kalendersynkronisering
</CardTitle>
<CardDescription>
Synka dina deadlines med Apple Calendar, Google Calendar eller Outlook
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-center space-y-4 py-4">
<p className="text-muted-foreground">
Skapa en kalenderfeed för att se dina deadlines i din vanliga kalenderapp.
</p>
<Button onClick={createFeed} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Skapar...
</>
) : (
<>
<Calendar className="mr-2 h-4 w-4" />
Aktivera kalendersynk
</>
)}
</Button>
</div>
</CardContent>
</Card>
)
}
return (
<div className="space-y-6">
{/* Feed URL */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Kalendersynkronisering
</CardTitle>
<CardDescription>
Prenumerera på din kalender i Apple Calendar, Google Calendar eller Outlook
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Quick add for Apple Calendar */}
<div className="flex gap-2">
<Button onClick={openWebcal} className="flex-1">
<Calendar className="mr-2 h-4 w-4" />
Lägg till i Apple Calendar
</Button>
<Button variant="outline" onClick={() => copyToClipboard(feed.httpsUrl)}>
{copied ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
{/* URL display */}
<div className="space-y-2">
<Label>Kalenderlänk (för Google Calendar m.fl.)</Label>
<div className="flex gap-2">
<Input
value={feed.httpsUrl}
readOnly
className="font-mono text-xs"
/>
</div>
<p className="text-xs text-muted-foreground">
Kopiera denna länk och lägg till som URL-prenumeration i din kalenderapp.
</p>
</div>
{/* Stats */}
{feed.last_accessed_at && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Senast hämtad:</span>
<span>
{new Date(feed.last_accessed_at).toLocaleDateString('sv-SE', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
})}
</span>
<Badge variant="secondary" className="text-xs">
{feed.access_count} gånger
</Badge>
</div>
)}
{/* Regenerate link */}
<div className="pt-2 border-t">
<Button
variant="outline"
size="sm"
onClick={regenerateToken}
disabled={isRegenerating}
>
{isRegenerating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Skapar ny länk...
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Skapa ny länk
</>
)}
</Button>
<p className="text-xs text-muted-foreground mt-1">
Ogiltigförklarar den gamla länken. Använd om någon obehörig fått tag i länken.
</p>
</div>
</CardContent>
</Card>
{/* Content settings */}
<Card>
<CardHeader>
<CardTitle>Innehåll i kalendern</CardTitle>
<CardDescription>
Välj vilka händelser som ska visas i din kalender
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="include-tax">Skattedeadlines</Label>
<p className="text-sm text-muted-foreground">
Moms, F-skatt, deklarationer
</p>
</div>
<Switch
id="include-tax"
checked={feed.include_tax_deadlines}
onCheckedChange={(checked) =>
updateFeed('include_tax_deadlines', checked)
}
disabled={isSaving}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="include-invoices">Fakturor</Label>
<p className="text-sm text-muted-foreground">
Förfallodatum för fakturor
</p>
</div>
<Switch
id="include-invoices"
checked={feed.include_invoices}
onCheckedChange={(checked) =>
updateFeed('include_invoices', checked)
}
disabled={isSaving}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="include-campaigns">Samarbeten</Label>
<p className="text-sm text-muted-foreground">
Leveransdatum och deadlines
</p>
</div>
<Switch
id="include-campaigns"
checked={feed.include_campaigns}
onCheckedChange={(checked) =>
updateFeed('include_campaigns', checked)
}
disabled={isSaving}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="include-exclusivity">Exklusivitetsperioder</Label>
<p className="text-sm text-muted-foreground">
Visar när du har exklusivitetsavtal
</p>
</div>
<Switch
id="include-exclusivity"
checked={feed.include_exclusivity}
onCheckedChange={(checked) =>
updateFeed('include_exclusivity', checked)
}
disabled={isSaving}
/>
</div>
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,366 @@
'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,
campaign_deadlines_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>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="campaign-deadlines">Samarbetsdeadlines</Label>
<p className="text-sm text-muted-foreground">
Leveranser och andra samarbetsdatum
</p>
</div>
<Switch
id="campaign-deadlines"
checked={settings.campaign_deadlines_enabled}
onCheckedChange={(checked) =>
updateSetting('campaign_deadlines_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
}
@@ -0,0 +1,204 @@
'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>
)
}
@@ -0,0 +1,139 @@
'use client'
import { cn } from '@/lib/utils'
import { formatCurrency } from '@/lib/utils'
interface PayoutWaterfallProps {
grossAmount: number
platformFee: number
serviceFee: number
pensionDeduction: number
socialFees: number
incomeTaxWithheld: number
netAmount: number
}
interface Segment {
label: string
amount: number
color: string
isDeduction: boolean
isPension?: boolean
}
export default function PayoutWaterfall({
grossAmount,
platformFee,
serviceFee,
pensionDeduction,
socialFees,
incomeTaxWithheld,
netAmount,
}: PayoutWaterfallProps) {
if (grossAmount <= 0) return null
const segments: Segment[] = [
{
label: 'Brutto',
amount: grossAmount,
color: 'bg-emerald-500',
isDeduction: false,
},
{
label: 'Plattformsavgift',
amount: platformFee,
color: 'bg-orange-400',
isDeduction: true,
},
{
label: 'Serviceavgift',
amount: serviceFee,
color: 'bg-amber-500',
isDeduction: true,
},
{
label: 'Pension',
amount: pensionDeduction,
color: 'bg-violet-500',
isDeduction: true,
isPension: true,
},
{
label: 'Arbetsgivaravgifter',
amount: socialFees,
color: 'bg-rose-400',
isDeduction: true,
},
{
label: 'Skatt',
amount: incomeTaxWithheld,
color: 'bg-red-500',
isDeduction: true,
},
{
label: 'Netto',
amount: netAmount,
color: 'bg-sky-500',
isDeduction: false,
},
].filter((s) => s.amount > 0)
// Total for percentage calculation is the gross amount
const total = grossAmount
return (
<div className="space-y-3">
{/* Stacked bar */}
<div className="flex h-8 w-full overflow-hidden rounded-lg bg-muted">
{segments.map((segment) => {
const widthPercent = Math.max((segment.amount / total) * 100, 2)
return (
<div
key={segment.label}
className={cn(
segment.color,
'relative flex items-center justify-center overflow-hidden transition-all duration-300',
segment.isPension && 'ring-2 ring-violet-300 ring-inset'
)}
style={{ width: `${widthPercent}%` }}
title={`${segment.label}: ${formatCurrency(segment.amount)}`}
>
{widthPercent > 8 && (
<span className="truncate px-1 text-[10px] font-medium text-white">
{formatCurrency(segment.amount)}
</span>
)}
</div>
)
})}
</div>
{/* Legend */}
<div className="flex flex-wrap gap-x-4 gap-y-1.5">
{segments.map((segment) => (
<div key={segment.label} className="flex items-center gap-1.5">
<div
className={cn(
'h-2.5 w-2.5 rounded-sm',
segment.color,
segment.isPension && 'ring-1 ring-violet-300'
)}
/>
<span className="text-xs text-muted-foreground">
{segment.isDeduction ? '\u2212' : ''}
{segment.label}:{' '}
<span className="font-medium text-foreground">
{formatCurrency(segment.amount)}
</span>
{segment.isPension && (
<span className="ml-1 text-[10px] font-medium text-violet-500">
(dold kostnad)
</span>
)}
</span>
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,388 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import PayoutWaterfall from '@/components/shadow-ledger/PayoutWaterfall'
import type { CreateShadowLedgerEntryInput, ShadowLedgerEntryType } from '@/types'
const TYPE_OPTIONS: { value: ShadowLedgerEntryType; label: string }[] = [
{ value: 'payout', label: 'Utbetalning' },
{ value: 'gift', label: 'G\u00e5va' },
{ value: 'expense', label: 'Utgift' },
{ value: 'hobby_income', label: 'Hobbyinkomst' },
{ value: 'hobby_expense', label: 'Hobbyutgift' },
]
interface ShadowLedgerFormProps {
onSubmit: (data: CreateShadowLedgerEntryInput) => Promise<void>
initialData?: Partial<CreateShadowLedgerEntryInput>
isLoading?: boolean
settings?: {
umbrella_provider?: string | null
umbrella_fee_percent?: number | null
umbrella_pension_percent?: number | null
municipal_tax_rate?: number | null
}
}
export default function ShadowLedgerForm({
onSubmit,
initialData,
isLoading,
settings,
}: ShadowLedgerFormProps) {
const [date, setDate] = useState(
initialData?.date || new Date().toISOString().split('T')[0]
)
const [type, setType] = useState<ShadowLedgerEntryType>(
initialData?.type || 'payout'
)
const [provider, setProvider] = useState(
initialData?.provider || settings?.umbrella_provider || ''
)
const [grossAmount, setGrossAmount] = useState(
initialData?.gross_amount?.toString() || ''
)
const [platformFee, setPlatformFee] = useState(
initialData?.platform_fee?.toString() || ''
)
const [serviceFee, setServiceFee] = useState(
initialData?.service_fee?.toString() || ''
)
const [pensionDeduction, setPensionDeduction] = useState(
initialData?.pension_deduction?.toString() || ''
)
const [socialFees, setSocialFees] = useState(
initialData?.social_fees?.toString() || ''
)
const [incomeTaxWithheld, setIncomeTaxWithheld] = useState(
initialData?.income_tax_withheld?.toString() || ''
)
const [netAmount, setNetAmount] = useState(
initialData?.net_amount?.toString() || ''
)
const [description, setDescription] = useState(
initialData?.description || ''
)
const [campaignId, setCampaignId] = useState(
initialData?.campaign_id || ''
)
const [netOverridden, setNetOverridden] = useState(false)
const parseNum = (val: string): number => {
const n = parseFloat(val)
return isNaN(n) ? 0 : n
}
// Auto-compute service_fee and pension_deduction when gross changes and type is payout
const autoCompute = useCallback(() => {
if (type !== 'payout') return
const gross = parseNum(grossAmount)
const platform = parseNum(platformFee)
if (gross <= 0) return
const afterPlatform = gross - platform
// Service fee from umbrella percentage
if (settings?.umbrella_fee_percent != null) {
const computedServiceFee = afterPlatform * (settings.umbrella_fee_percent / 100)
setServiceFee(Math.round(computedServiceFee).toString())
}
// Pension deduction
if (settings?.umbrella_pension_percent != null) {
const computedPension = afterPlatform * (settings.umbrella_pension_percent / 100)
setPensionDeduction(Math.round(computedPension).toString())
}
}, [type, grossAmount, platformFee, settings])
useEffect(() => {
autoCompute()
}, [autoCompute])
// Auto-compute net amount unless overridden
useEffect(() => {
if (netOverridden) return
const gross = parseNum(grossAmount)
const platform = parseNum(platformFee)
const service = parseNum(serviceFee)
const pension = parseNum(pensionDeduction)
const social = parseNum(socialFees)
const tax = parseNum(incomeTaxWithheld)
const computed = gross - platform - service - pension - social - tax
setNetAmount(Math.max(0, Math.round(computed)).toString())
}, [grossAmount, platformFee, serviceFee, pensionDeduction, socialFees, incomeTaxWithheld, netOverridden])
// Update provider from settings when settings change
useEffect(() => {
if (settings?.umbrella_provider && !provider) {
setProvider(settings.umbrella_provider)
}
}, [settings, provider])
const handleNetChange = (val: string) => {
setNetOverridden(true)
setNetAmount(val)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const data: CreateShadowLedgerEntryInput = {
date,
type,
source: 'manual',
provider: provider || undefined,
gross_amount: parseNum(grossAmount),
platform_fee: parseNum(platformFee) || undefined,
service_fee: parseNum(serviceFee) || undefined,
pension_deduction: parseNum(pensionDeduction) || undefined,
social_fees: parseNum(socialFees) || undefined,
income_tax_withheld: parseNum(incomeTaxWithheld) || undefined,
net_amount: parseNum(netAmount),
description: description || undefined,
campaign_id: campaignId || undefined,
}
await onSubmit(data)
}
const isPayout = type === 'payout'
const gross = parseNum(grossAmount)
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Row 1: Date + Type */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="date">Datum</Label>
<Input
id="date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="type">Typ</Label>
<Select value={type} onValueChange={(v) => setType(v as ShadowLedgerEntryType)}>
<SelectTrigger id="type">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Row 2: Provider */}
<div className="space-y-2">
<Label htmlFor="provider">Leverant\u00f6r</Label>
<Input
id="provider"
value={provider}
onChange={(e) => setProvider(e.target.value)}
placeholder="t.ex. Gigapay, Frilans Finans"
/>
</div>
{/* Row 3: Gross + Platform fee */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="gross">Bruttobelopp (SEK)</Label>
<Input
id="gross"
type="number"
min="0"
step="1"
value={grossAmount}
onChange={(e) => setGrossAmount(e.target.value)}
placeholder="0"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="platformFee">Plattformsavgift</Label>
<Input
id="platformFee"
type="number"
min="0"
step="1"
value={platformFee}
onChange={(e) => setPlatformFee(e.target.value)}
placeholder="0"
/>
</div>
</div>
{/* Row 4: Service fee + Pension */}
{isPayout && (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="serviceFee">
Serviceavgift
{settings?.umbrella_fee_percent != null && (
<span className="ml-1 text-xs text-muted-foreground">
({settings.umbrella_fee_percent}%)
</span>
)}
</Label>
<Input
id="serviceFee"
type="number"
min="0"
step="1"
value={serviceFee}
onChange={(e) => setServiceFee(e.target.value)}
placeholder="0"
/>
</div>
<div className="space-y-2">
<Label htmlFor="pension">
Pensionsavs\u00e4ttning
{settings?.umbrella_pension_percent != null && (
<span className="ml-1 text-xs text-muted-foreground">
({settings.umbrella_pension_percent}%)
</span>
)}
</Label>
<Input
id="pension"
type="number"
min="0"
step="1"
value={pensionDeduction}
onChange={(e) => setPensionDeduction(e.target.value)}
placeholder="0"
/>
</div>
</div>
)}
{/* Row 5: Social fees + Tax */}
{isPayout && (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="socialFees">Arbetsgivaravgifter</Label>
<Input
id="socialFees"
type="number"
min="0"
step="1"
value={socialFees}
onChange={(e) => setSocialFees(e.target.value)}
placeholder="0"
/>
</div>
<div className="space-y-2">
<Label htmlFor="tax">Prelimin\u00e4rskatt</Label>
<Input
id="tax"
type="number"
min="0"
step="1"
value={incomeTaxWithheld}
onChange={(e) => setIncomeTaxWithheld(e.target.value)}
placeholder="0"
/>
</div>
</div>
)}
{/* Row 6: Net amount */}
<div className="space-y-2">
<Label htmlFor="net">Nettobelopp (SEK)</Label>
<Input
id="net"
type="number"
min="0"
step="1"
value={netAmount}
onChange={(e) => handleNetChange(e.target.value)}
placeholder="0"
required
/>
{netOverridden && isPayout && (
<button
type="button"
className="text-xs text-primary hover:underline"
onClick={() => setNetOverridden(false)}
>
\u00c5terst\u00e4ll automatisk ber\u00e4kning
</button>
)}
</div>
{/* Row 7: Description */}
<div className="space-y-2">
<Label htmlFor="description">Beskrivning</Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Frivillig beskrivning"
/>
</div>
{/* Row 8: Campaign link */}
<div className="space-y-2">
<Label htmlFor="campaignId">Kampanjkoppling</Label>
<Input
id="campaignId"
value={campaignId}
onChange={(e) => setCampaignId(e.target.value)}
placeholder="Kampanj-ID (valfritt)"
/>
</div>
{/* Waterfall preview */}
{isPayout && gross > 0 && (
<Card className="bg-muted/30">
<CardContent className="pt-4 pb-4">
<p className="mb-3 text-sm font-medium text-muted-foreground">
Brutto-till-netto-f\u00f6rdelning
</p>
<PayoutWaterfall
grossAmount={gross}
platformFee={parseNum(platformFee)}
serviceFee={parseNum(serviceFee)}
pensionDeduction={parseNum(pensionDeduction)}
socialFees={parseNum(socialFees)}
incomeTaxWithheld={parseNum(incomeTaxWithheld)}
netAmount={parseNum(netAmount)}
/>
</CardContent>
</Card>
)}
{/* Submit */}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Sparar...' : 'Spara post'}
</Button>
</form>
)
}
@@ -0,0 +1,199 @@
'use client'
import { useState } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { formatCurrency } from '@/lib/utils'
import { cn } from '@/lib/utils'
import { ChevronDown, ChevronUp, Trash2 } from 'lucide-react'
import PayoutWaterfall from '@/components/shadow-ledger/PayoutWaterfall'
import type { ShadowLedgerEntry, ShadowLedgerEntryType } from '@/types'
interface ShadowLedgerListProps {
entries: ShadowLedgerEntry[]
onDelete?: (id: string) => void
isDeleting?: boolean
}
const TYPE_LABELS: Record<ShadowLedgerEntryType, string> = {
payout: 'Utbetalning',
gift: 'G\u00e5va',
expense: 'Utgift',
hobby_income: 'Hobbyinkomst',
hobby_expense: 'Hobbyutgift',
}
const TYPE_BADGE_VARIANT: Record<
ShadowLedgerEntryType,
'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
> = {
payout: 'default',
gift: 'warning',
expense: 'destructive',
hobby_income: 'success',
hobby_expense: 'secondary',
}
function formatDateShort(dateStr: string): string {
const d = new Date(dateStr)
return d.toLocaleDateString('sv-SE', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
export default function ShadowLedgerList({
entries,
onDelete,
isDeleting,
}: ShadowLedgerListProps) {
const [expandedId, setExpandedId] = useState<string | null>(null)
const toggle = (id: string) => {
setExpandedId((prev) => (prev === id ? null : id))
}
if (entries.length === 0) {
return (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">Inga poster hittades.</p>
</CardContent>
</Card>
)
}
return (
<div className="space-y-3">
{entries.map((entry) => {
const isExpanded = expandedId === entry.id
const isPayout = entry.type === 'payout'
return (
<Card key={entry.id}>
<CardContent className="p-0">
{/* Collapsed row */}
<button
type="button"
className="flex w-full items-center gap-3 p-4 text-left transition-colors hover:bg-muted/30"
onClick={() => toggle(entry.id)}
>
{/* Date */}
<span className="shrink-0 text-sm text-muted-foreground w-24">
{formatDateShort(entry.date)}
</span>
{/* Type badge */}
<Badge
variant={TYPE_BADGE_VARIANT[entry.type]}
className="shrink-0"
>
{TYPE_LABELS[entry.type]}
</Badge>
{/* Description */}
<span className="min-w-0 flex-1 truncate text-sm">
{entry.description || entry.provider || '\u2014'}
</span>
{/* Amounts */}
<span className="shrink-0 text-sm font-medium tabular-nums">
{formatCurrency(entry.gross_amount)}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
&rarr;
</span>
<span
className={cn(
'shrink-0 text-sm font-semibold tabular-nums',
entry.net_amount < entry.gross_amount
? 'text-sky-600'
: 'text-foreground'
)}
>
{formatCurrency(entry.net_amount)}
</span>
{/* Expand icon */}
{isExpanded ? (
<ChevronUp className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
</button>
{/* Expanded content */}
{isExpanded && (
<div className="border-t px-4 pb-4 pt-3 space-y-4">
{/* Waterfall (only for payouts with gross > 0) */}
{isPayout && entry.gross_amount > 0 && (
<PayoutWaterfall
grossAmount={entry.gross_amount}
platformFee={entry.platform_fee}
serviceFee={entry.service_fee}
pensionDeduction={entry.pension_deduction}
socialFees={entry.social_fees}
incomeTaxWithheld={entry.income_tax_withheld}
netAmount={entry.net_amount}
/>
)}
{/* Metadata grid */}
<div className="grid gap-x-6 gap-y-2 text-sm sm:grid-cols-2">
{entry.provider && (
<div>
<span className="text-muted-foreground">Leverant\u00f6r: </span>
<span className="font-medium">{entry.provider}</span>
</div>
)}
{entry.campaign_id && (
<div>
<span className="text-muted-foreground">Kampanj: </span>
<span className="font-medium">{entry.campaign_id}</span>
</div>
)}
{entry.virtual_tax_debt > 0 && (
<div>
<span className="text-muted-foreground">
Virtuell skatteskuld:{' '}
</span>
<span className="font-medium text-destructive">
{formatCurrency(entry.virtual_tax_debt)}
</span>
</div>
)}
<div>
<span className="text-muted-foreground">K\u00e4lla: </span>
<span className="font-medium capitalize">{entry.source}</span>
</div>
</div>
{/* Delete button */}
{onDelete && (
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
disabled={isDeleting}
onClick={(e) => {
e.stopPropagation()
onDelete(entry.id)
}}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Ta bort
</Button>
</div>
)}
</div>
)}
</CardContent>
</Card>
)
})}
</div>
)
}
+225
View File
@@ -0,0 +1,225 @@
'use client'
import { useState } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import { TIKTOK_STATUS_LABELS } from '@/types'
import type { TikTokAccount } from '@/types'
import { formatDate } from '@/lib/utils'
import {
Loader2,
RefreshCw,
Trash2,
ExternalLink,
AlertTriangle,
} from 'lucide-react'
interface TikTokAccountCardProps {
account: TikTokAccount
onDisconnect?: () => void
onSync?: () => void
}
export function TikTokAccountCard({
account,
onDisconnect,
onSync,
}: TikTokAccountCardProps) {
const [isSyncing, setIsSyncing] = useState(false)
const [isDisconnecting, setIsDisconnecting] = useState(false)
const { toast } = useToast()
const handleSync = async () => {
setIsSyncing(true)
try {
const response = await fetch('/api/tiktok/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account_id: account.id, sync_type: 'full' }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Synkronisering misslyckades')
}
toast({
title: 'Synkronisering klar',
description: `${data.videos_synced} videor synkade, ${data.new_videos} nya`,
})
onSync?.()
} catch (error) {
toast({
title: 'Synkronisering misslyckades',
description: error instanceof Error ? error.message : 'Ett fel uppstod',
variant: 'destructive',
})
}
setIsSyncing(false)
}
const handleDisconnect = async () => {
if (!confirm('Vill du koppla bort detta TikTok-konto?')) {
return
}
setIsDisconnecting(true)
try {
const response = await fetch('/api/tiktok/disconnect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account_id: account.id }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Kunde inte koppla bort')
}
toast({
title: 'Konto bortkopplat',
description: 'TikTok-kontot har kopplats bort',
})
onDisconnect?.()
} catch (error) {
toast({
title: 'Kunde inte koppla bort',
description: error instanceof Error ? error.message : 'Ett fel uppstod',
variant: 'destructive',
})
setIsDisconnecting(false)
}
}
const getStatusVariant = (status: TikTokAccount['status']) => {
switch (status) {
case 'active':
return 'default'
case 'expired':
case 'error':
return 'destructive'
case 'revoked':
return 'secondary'
default:
return 'outline'
}
}
const tokenExpiresAt = new Date(account.token_expires_at)
const isExpiringSoon = tokenExpiresAt.getTime() - Date.now() < 24 * 60 * 60 * 1000
return (
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-4">
{/* Avatar */}
{account.avatar_url ? (
<img
src={account.avatar_url}
alt={account.display_name || account.username}
className="h-12 w-12 rounded-full"
/>
) : (
<div className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
<TikTokIcon className="h-6 w-6 text-primary" />
</div>
)}
{/* Account info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium truncate">
{account.display_name || account.username}
</p>
<Badge variant={getStatusVariant(account.status)}>
{TIKTOK_STATUS_LABELS[account.status]}
</Badge>
</div>
<p className="text-sm text-muted-foreground">@{account.username}</p>
<div className="flex items-center gap-4 mt-1 text-xs text-muted-foreground">
{account.last_synced_at && (
<span>Synkad: {formatDate(account.last_synced_at)}</span>
)}
{isExpiringSoon && account.status === 'active' && (
<span className="flex items-center gap-1 text-warning-foreground">
<AlertTriangle className="h-3 w-3" />
Token går ut snart
</span>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
asChild
>
<a
href={`https://www.tiktok.com/@${account.username}`}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-4 w-4" />
</a>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleSync}
disabled={isSyncing || account.status !== 'active'}
>
{isSyncing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleDisconnect}
disabled={isDisconnecting}
>
{isDisconnecting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4 text-destructive" />
)}
</Button>
</div>
</div>
{/* Error message */}
{account.last_error && (
<div className="mt-3 p-2 bg-destructive/10 rounded text-sm text-destructive">
{account.last_error}
</div>
)}
</CardContent>
</Card>
)
}
function TikTokIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z" />
</svg>
)
}
+84
View File
@@ -0,0 +1,84 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Loader2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
interface TikTokConnectButtonProps {
disabled?: boolean
variant?: 'default' | 'outline' | 'ghost'
size?: 'default' | 'sm' | 'lg'
className?: string
}
export function TikTokConnectButton({
disabled = false,
variant = 'default',
size = 'default',
className,
}: TikTokConnectButtonProps) {
const [isConnecting, setIsConnecting] = useState(false)
const { toast } = useToast()
const handleConnect = async () => {
setIsConnecting(true)
try {
const response = await fetch('/api/tiktok/connect', {
method: 'POST',
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to start connection')
}
// Redirect to TikTok authorization
window.location.href = data.authorization_url
} catch (error) {
toast({
title: 'Anslutning misslyckades',
description: error instanceof Error ? error.message : 'Ett fel uppstod',
variant: 'destructive',
})
setIsConnecting(false)
}
}
return (
<Button
onClick={handleConnect}
disabled={disabled || isConnecting}
variant={variant}
size={size}
className={className}
>
{isConnecting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Ansluter...
</>
) : (
<>
<TikTokIcon className="mr-2 h-4 w-4" />
Koppla TikTok
</>
)}
</Button>
)
}
function TikTokIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z" />
</svg>
)
}
+174
View File
@@ -0,0 +1,174 @@
'use client'
import { useState, useEffect } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Area,
AreaChart,
} from 'recharts'
import { cn } from '@/lib/utils'
interface TikTokGrowthChartProps {
accountId: string
className?: string
}
type Period = '7d' | '30d' | '90d' | '1y'
interface ChartData {
date: string
followers: number
change: number | null
}
export function TikTokGrowthChart({ accountId, className }: TikTokGrowthChartProps) {
const [period, setPeriod] = useState<Period>('30d')
const [data, setData] = useState<ChartData[]>([])
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
fetchData()
}, [accountId, period])
const fetchData = async () => {
setIsLoading(true)
try {
const days = period === '7d' ? 7 : period === '30d' ? 30 : period === '90d' ? 90 : 365
const response = await fetch(
`/api/tiktok/stats?account_id=${accountId}&days=${days}`
)
const result = await response.json()
setData(result.history || [])
} catch (error) {
console.error('Failed to fetch growth data:', error)
}
setIsLoading(false)
}
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
if (period === '7d') {
return date.toLocaleDateString('sv-SE', { weekday: 'short' })
}
if (period === '30d') {
return date.toLocaleDateString('sv-SE', { day: 'numeric', month: 'short' })
}
return date.toLocaleDateString('sv-SE', { day: 'numeric', month: 'short' })
}
const formatFollowers = (value: number) => {
if (value >= 1000000) {
return (value / 1000000).toFixed(1) + 'M'
}
if (value >= 1000) {
return (value / 1000).toFixed(1) + 'K'
}
return value.toString()
}
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
return (
<div className="bg-popover border rounded-lg shadow-lg p-3">
<p className="text-sm font-medium">{label}</p>
<p className="text-sm text-muted-foreground">
Följare: {payload[0].value.toLocaleString('sv-SE')}
</p>
{payload[0].payload.change !== null && (
<p className={cn(
'text-sm',
payload[0].payload.change >= 0 ? 'text-success' : 'text-destructive'
)}>
{payload[0].payload.change >= 0 ? '+' : ''}
{payload[0].payload.change.toLocaleString('sv-SE')} från föregående dag
</p>
)}
</div>
)
}
return null
}
const periods: { value: Period; label: string }[] = [
{ value: '7d', label: '7 dagar' },
{ value: '30d', label: '30 dagar' },
{ value: '90d', label: '90 dagar' },
{ value: '1y', label: '1 år' },
]
return (
<Card className={className}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">Följartillväxt</CardTitle>
<div className="flex gap-1">
{periods.map((p) => (
<Button
key={p.value}
variant={period === p.value ? 'default' : 'ghost'}
size="sm"
onClick={() => setPeriod(p.value)}
className="text-xs"
>
{p.label}
</Button>
))}
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="h-64 flex items-center justify-center">
<p className="text-sm text-muted-foreground">Laddar...</p>
</div>
) : data.length === 0 ? (
<div className="h-64 flex items-center justify-center">
<p className="text-sm text-muted-foreground">Ingen data tillgänglig</p>
</div>
) : (
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="followerGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(var(--primary))" stopOpacity={0.3} />
<stop offset="95%" stopColor="hsl(var(--primary))" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis
dataKey="date"
tickFormatter={formatDate}
tick={{ fontSize: 12 }}
className="text-muted-foreground"
/>
<YAxis
tickFormatter={formatFollowers}
tick={{ fontSize: 12 }}
className="text-muted-foreground"
width={50}
/>
<Tooltip content={<CustomTooltip />} />
<Area
type="monotone"
dataKey="followers"
stroke="hsl(var(--primary))"
strokeWidth={2}
fill="url(#followerGradient)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
)
}
+142
View File
@@ -0,0 +1,142 @@
'use client'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import Link from 'next/link'
import type { TikTokCampaignROI } from '@/types'
import { formatCurrency } from '@/lib/utils'
interface TikTokROITableProps {
campaigns: TikTokCampaignROI[]
className?: string
}
export function TikTokROITable({ campaigns, className }: TikTokROITableProps) {
const formatNumber = (num: number) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
}
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return num.toLocaleString('sv-SE')
}
const formatCPV = (cpv: number) => {
if (cpv === 0) return '-'
if (cpv < 0.01) return '< 0.01 kr'
return formatCurrency(cpv)
}
const getROIBadge = (cpv: number) => {
if (cpv === 0) return null
if (cpv < 0.1) return <Badge variant="default">Utmärkt</Badge>
if (cpv < 0.5) return <Badge variant="secondary">Bra</Badge>
if (cpv < 1) return <Badge variant="outline">OK</Badge>
return <Badge variant="destructive">Hög kostnad</Badge>
}
if (campaigns.length === 0) {
return (
<div className="text-center py-8">
<p className="text-muted-foreground">
Inga samarbeten med kopplade TikTok-videor
</p>
</div>
)
}
return (
<div className={className}>
<Table>
<TableHeader>
<TableRow>
<TableHead>Samarbete</TableHead>
<TableHead className="text-right">Videor</TableHead>
<TableHead className="text-right">Visningar</TableHead>
<TableHead className="text-right">Engagemang</TableHead>
<TableHead className="text-right">Kostnad</TableHead>
<TableHead className="text-right">CPV</TableHead>
<TableHead className="text-right">CPE</TableHead>
<TableHead>ROI</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{campaigns.map(campaign => (
<TableRow key={campaign.campaignId}>
<TableCell>
<Link
href={`/campaigns/${campaign.campaignId}`}
className="font-medium hover:underline"
>
{campaign.campaignName}
</Link>
</TableCell>
<TableCell className="text-right">
{campaign.videos.length}
</TableCell>
<TableCell className="text-right">
{formatNumber(campaign.totalViews)}
</TableCell>
<TableCell className="text-right">
{formatNumber(campaign.totalEngagements)}
</TableCell>
<TableCell className="text-right">
{formatCurrency(campaign.totalCost)}
</TableCell>
<TableCell className="text-right">
{formatCPV(campaign.costPerView)}
</TableCell>
<TableCell className="text-right">
{formatCPV(campaign.costPerEngagement)}
</TableCell>
<TableCell>
{getROIBadge(campaign.costPerView)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* Summary */}
<div className="mt-4 p-4 bg-secondary/30 rounded-lg">
<div className="grid grid-cols-4 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Totala visningar</p>
<p className="font-medium">
{formatNumber(campaigns.reduce((sum, c) => sum + c.totalViews, 0))}
</p>
</div>
<div>
<p className="text-muted-foreground">Totalt engagemang</p>
<p className="font-medium">
{formatNumber(campaigns.reduce((sum, c) => sum + c.totalEngagements, 0))}
</p>
</div>
<div>
<p className="text-muted-foreground">Total kostnad</p>
<p className="font-medium">
{formatCurrency(campaigns.reduce((sum, c) => sum + c.totalCost, 0))}
</p>
</div>
<div>
<p className="text-muted-foreground">Genomsnittlig CPV</p>
<p className="font-medium">
{formatCPV(
campaigns.reduce((sum, c) => sum + c.totalCost, 0) /
Math.max(1, campaigns.reduce((sum, c) => sum + c.totalViews, 0))
)}
</p>
</div>
</div>
</div>
</div>
)
}
+162
View File
@@ -0,0 +1,162 @@
'use client'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { formatDate } from '@/lib/utils'
import type { TikTokStatsSummary } from '@/types'
import {
Users,
TrendingUp,
TrendingDown,
Heart,
Video,
ArrowRight,
RefreshCw,
} from 'lucide-react'
import { cn } from '@/lib/utils'
interface TikTokStatsWidgetProps {
stats: TikTokStatsSummary | null
onSync?: () => void
isSyncing?: boolean
className?: string
}
export function TikTokStatsWidget({
stats,
onSync,
isSyncing,
className,
}: TikTokStatsWidgetProps) {
if (!stats) {
return null
}
const formatNumber = (num: number) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
}
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return num.toLocaleString('sv-SE')
}
const formatChange = (change: number) => {
const prefix = change >= 0 ? '+' : ''
return prefix + formatNumber(change)
}
return (
<Card className={className}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<TikTokIcon className="h-4 w-4" />
TikTok
</CardTitle>
<div className="flex items-center gap-2">
{onSync && (
<Button
variant="ghost"
size="sm"
onClick={onSync}
disabled={isSyncing}
>
<RefreshCw className={cn('h-4 w-4', isSyncing && 'animate-spin')} />
</Button>
)}
<Link href="/analytics">
<Button variant="ghost" size="sm" className="text-xs">
Detaljer
<ArrowRight className="h-3 w-3 ml-1" />
</Button>
</Link>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Main follower count */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Följare</span>
</div>
<div className="text-right">
<p className="font-display text-2xl font-medium">
{formatNumber(stats.currentFollowers)}
</p>
</div>
</div>
{/* Growth indicators */}
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center gap-2">
<div className={cn(
'flex items-center gap-1 text-sm',
stats.followerChange7d >= 0 ? 'text-success' : 'text-destructive'
)}>
{stats.followerChange7d >= 0 ? (
<TrendingUp className="h-4 w-4" />
) : (
<TrendingDown className="h-4 w-4" />
)}
{formatChange(stats.followerChange7d)}
</div>
<span className="text-xs text-muted-foreground">7d</span>
</div>
<div className="flex items-center gap-2">
<div className={cn(
'flex items-center gap-1 text-sm',
stats.followerChange30d >= 0 ? 'text-success' : 'text-destructive'
)}>
{stats.followerChange30d >= 0 ? (
<TrendingUp className="h-4 w-4" />
) : (
<TrendingDown className="h-4 w-4" />
)}
{formatChange(stats.followerChange30d)}
</div>
<span className="text-xs text-muted-foreground">30d</span>
</div>
</div>
{/* Secondary stats */}
<div className="flex items-center justify-between pt-2 border-t">
<div className="flex items-center gap-2">
<Heart className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{formatNumber(stats.totalLikes)}</span>
</div>
<div className="flex items-center gap-2">
<Video className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{stats.totalVideos} videor</span>
</div>
<div className="text-sm text-muted-foreground">
{stats.engagementRate.toFixed(1)}% eng.
</div>
</div>
{/* Last synced */}
{stats.lastSynced && (
<p className="text-xs text-muted-foreground text-center">
Senast uppdaterad: {formatDate(stats.lastSynced)}
</p>
)}
</CardContent>
</Card>
)
}
function TikTokIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z" />
</svg>
)
}
+132
View File
@@ -0,0 +1,132 @@
'use client'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import type { TikTokVideo } from '@/types'
import { formatDate } from '@/lib/utils'
import {
Eye,
Heart,
MessageCircle,
Share2,
ExternalLink,
Link as LinkIcon,
} from 'lucide-react'
interface TikTokVideoCardProps {
video: TikTokVideo
onLinkClick?: (video: TikTokVideo) => void
showLinkButton?: boolean
}
export function TikTokVideoCard({
video,
onLinkClick,
showLinkButton = true,
}: TikTokVideoCardProps) {
const formatNumber = (num: number) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
}
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return num.toLocaleString('sv-SE')
}
const engagementRate = video.view_count > 0
? ((video.like_count + video.comment_count + video.share_count) / video.view_count) * 100
: 0
return (
<Card className="overflow-hidden">
<div className="flex">
{/* Thumbnail */}
{video.cover_image_url && (
<div className="relative w-24 h-36 flex-shrink-0">
<img
src={video.cover_image_url}
alt={video.title || 'Video'}
className="w-full h-full object-cover"
/>
{video.duration && (
<div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded">
{Math.floor(video.duration / 60)}:{(video.duration % 60).toString().padStart(2, '0')}
</div>
)}
</div>
)}
<CardContent className="flex-1 p-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="font-medium text-sm line-clamp-2">
{video.title || 'Untitled video'}
</p>
{video.published_at && (
<p className="text-xs text-muted-foreground mt-1">
{formatDate(video.published_at)}
</p>
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{video.share_url && (
<Button variant="ghost" size="sm" asChild>
<a
href={video.share_url}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-4 w-4" />
</a>
</Button>
)}
{showLinkButton && onLinkClick && (
<Button
variant="ghost"
size="sm"
onClick={() => onLinkClick(video)}
>
<LinkIcon className="h-4 w-4" />
</Button>
)}
</div>
</div>
{/* Metrics */}
<div className="flex items-center gap-3 mt-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Eye className="h-3 w-3" />
{formatNumber(video.view_count)}
</div>
<div className="flex items-center gap-1">
<Heart className="h-3 w-3" />
{formatNumber(video.like_count)}
</div>
<div className="flex items-center gap-1">
<MessageCircle className="h-3 w-3" />
{formatNumber(video.comment_count)}
</div>
<div className="flex items-center gap-1">
<Share2 className="h-3 w-3" />
{formatNumber(video.share_count)}
</div>
</div>
{/* Tags */}
<div className="flex items-center gap-2 mt-2">
<Badge variant="secondary" className="text-xs">
{engagementRate.toFixed(1)}% eng.
</Badge>
{video.campaign_id && (
<Badge variant="default" className="text-xs">
Kopplad till samarbete
</Badge>
)}
</div>
</CardContent>
</div>
</Card>
)
}
+117
View File
@@ -0,0 +1,117 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { TikTokVideoCard } from './TikTokVideoCard'
import type { TikTokVideo } from '@/types'
import { Loader2 } from 'lucide-react'
interface TikTokVideoListProps {
accountId?: string
campaignId?: string
unlinkedOnly?: boolean
onLinkClick?: (video: TikTokVideo) => void
showLinkButton?: boolean
limit?: number
}
export function TikTokVideoList({
accountId,
campaignId,
unlinkedOnly = false,
onLinkClick,
showLinkButton = true,
limit = 10,
}: TikTokVideoListProps) {
const [videos, setVideos] = useState<TikTokVideo[]>([])
const [total, setTotal] = useState(0)
const [offset, setOffset] = useState(0)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
fetchVideos()
}, [accountId, campaignId, unlinkedOnly, offset])
const fetchVideos = async () => {
setIsLoading(true)
try {
const params = new URLSearchParams({
limit: limit.toString(),
offset: offset.toString(),
})
if (accountId) params.append('account_id', accountId)
if (campaignId) params.append('campaign_id', campaignId)
if (unlinkedOnly) params.append('unlinked_only', 'true')
const response = await fetch(`/api/tiktok/videos?${params.toString()}`)
const data = await response.json()
if (offset === 0) {
setVideos(data.videos || [])
} else {
setVideos(prev => [...prev, ...(data.videos || [])])
}
setTotal(data.total || 0)
} catch (error) {
console.error('Failed to fetch videos:', error)
}
setIsLoading(false)
}
const loadMore = () => {
setOffset(prev => prev + limit)
}
const hasMore = videos.length < total
if (isLoading && videos.length === 0) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)
}
if (videos.length === 0) {
return (
<div className="text-center py-8">
<p className="text-muted-foreground">Inga videor hittades</p>
</div>
)
}
return (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
{videos.map(video => (
<TikTokVideoCard
key={video.id}
video={video}
onLinkClick={onLinkClick}
showLinkButton={showLinkButton}
/>
))}
</div>
{hasMore && (
<div className="flex justify-center">
<Button
variant="outline"
onClick={loadMore}
disabled={isLoading}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Laddar...
</>
) : (
`Visa fler (${videos.length} av ${total})`
)}
</Button>
</div>
)}
</div>
)
}
+249
View File
@@ -0,0 +1,249 @@
'use client'
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
import type { TikTokVideo, Campaign, Deliverable } from '@/types'
import { Loader2 } from 'lucide-react'
interface VideoLinkModalProps {
video: TikTokVideo | null
isOpen: boolean
onClose: () => void
onSuccess?: () => void
}
export function VideoLinkModal({
video,
isOpen,
onClose,
onSuccess,
}: VideoLinkModalProps) {
const [campaigns, setCampaigns] = useState<Campaign[]>([])
const [deliverables, setDeliverables] = useState<Deliverable[]>([])
const [selectedCampaign, setSelectedCampaign] = useState<string>('')
const [selectedDeliverable, setSelectedDeliverable] = useState<string>('')
const [isLoading, setIsLoading] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const { toast } = useToast()
useEffect(() => {
if (isOpen) {
fetchCampaigns()
}
}, [isOpen])
useEffect(() => {
if (selectedCampaign) {
fetchDeliverables(selectedCampaign)
} else {
setDeliverables([])
setSelectedDeliverable('')
}
}, [selectedCampaign])
const fetchCampaigns = async () => {
setIsLoading(true)
try {
const response = await fetch('/api/campaigns')
const data = await response.json()
setCampaigns(data.campaigns || [])
} catch (error) {
console.error('Failed to fetch campaigns:', error)
}
setIsLoading(false)
}
const fetchDeliverables = async (campaignId: string) => {
try {
const response = await fetch(`/api/campaigns/${campaignId}/deliverables`)
const data = await response.json()
setDeliverables(data.deliverables || [])
} catch (error) {
console.error('Failed to fetch deliverables:', error)
}
}
const handleSave = async () => {
if (!video || !selectedCampaign) return
setIsSaving(true)
try {
const response = await fetch(`/api/tiktok/videos/${video.id}/link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
campaign_id: selectedCampaign,
deliverable_id: selectedDeliverable || undefined,
}),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to link video')
}
toast({
title: 'Video kopplad',
description: 'Videon har kopplats till samarbetet',
})
onSuccess?.()
onClose()
} catch (error) {
toast({
title: 'Kunde inte koppla video',
description: error instanceof Error ? error.message : 'Ett fel uppstod',
variant: 'destructive',
})
}
setIsSaving(false)
}
const handleUnlink = async () => {
if (!video) return
setIsSaving(true)
try {
const response = await fetch(`/api/tiktok/videos/${video.id}/link`, {
method: 'DELETE',
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to unlink video')
}
toast({
title: 'Koppling borttagen',
description: 'Videon har kopplats bort från samarbetet',
})
onSuccess?.()
onClose()
} catch (error) {
toast({
title: 'Kunde inte ta bort koppling',
description: error instanceof Error ? error.message : 'Ett fel uppstod',
variant: 'destructive',
})
}
setIsSaving(false)
}
const handleClose = () => {
setSelectedCampaign('')
setSelectedDeliverable('')
onClose()
}
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Koppla video till samarbete</DialogTitle>
<DialogDescription>
{video?.title || 'Välj ett samarbete att koppla denna video till för ROI-spårning'}
</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="campaign">Samarbete</Label>
<Select
value={selectedCampaign}
onValueChange={setSelectedCampaign}
>
<SelectTrigger id="campaign">
<SelectValue placeholder="Välj samarbete" />
</SelectTrigger>
<SelectContent>
{campaigns.map(campaign => (
<SelectItem key={campaign.id} value={campaign.id}>
{campaign.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{deliverables.length > 0 && (
<div className="space-y-2">
<Label htmlFor="deliverable">Leverabel (valfritt)</Label>
<Select
value={selectedDeliverable}
onValueChange={setSelectedDeliverable}
>
<SelectTrigger id="deliverable">
<SelectValue placeholder="Välj leverabel" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Ingen specifik leverabel</SelectItem>
{deliverables.map(deliverable => (
<SelectItem key={deliverable.id} value={deliverable.id}>
{deliverable.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
)}
<DialogFooter>
{video?.campaign_id && (
<Button
variant="destructive"
onClick={handleUnlink}
disabled={isSaving}
>
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Ta bort koppling'
)}
</Button>
)}
<Button variant="outline" onClick={handleClose}>
Avbryt
</Button>
<Button
onClick={handleSave}
disabled={!selectedCampaign || isSaving}
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
'Koppla'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+8
View File
@@ -0,0 +1,8 @@
export { TikTokConnectButton } from './TikTokConnectButton'
export { TikTokAccountCard } from './TikTokAccountCard'
export { TikTokStatsWidget } from './TikTokStatsWidget'
export { TikTokGrowthChart } from './TikTokGrowthChart'
export { TikTokVideoCard } from './TikTokVideoCard'
export { TikTokVideoList } from './TikTokVideoList'
export { VideoLinkModal } from './VideoLinkModal'
export { TikTokROITable } from './TikTokROITable'
+242
View File
@@ -0,0 +1,242 @@
'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>
)
}

Some files were not shown because too many files have changed in this diff Show More