Initial copy from influencer-biz
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { BankSelector, type Bank } from './BankSelector'
|
||||
export { BankConnectionStatus } from './BankConnectionStatus'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 "{giftToDelete?.description}" 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
{' '}
|
||||
› {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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { ChatWidget } from './ChatWidget'
|
||||
export { ChatPanel } from './ChatPanel'
|
||||
export { ChatMessage } from './ChatMessage'
|
||||
export { ChatInput } from './ChatInput'
|
||||
export { useChatStream } from './useChatStream'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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å "Starta import" 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
→
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
Reference in New Issue
Block a user