Refactor to generic ERP base

Remove influencer-specific features (campaigns, TikTok, gifts, shadow ledger,
contracts, briefings) and consolidate into a clean ERP foundation with core
bookkeeping, invoicing, receipts, tax reporting, and calendar functionality.
Reorganize database migrations into a clean numbered sequence.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-02-13 15:39:13 +01:00
co-authored by Claude Opus 4.6
parent a25d10a528
commit 0aecf5b41c
184 changed files with 1418 additions and 24763 deletions
-290
View File
@@ -1,290 +0,0 @@
'use client'
import { useState, useMemo } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
import { classifyGift, TAX_FREE_PROMO_THRESHOLD, getBookingTypeDisplayText } from '@/lib/benefits/gift-classifier'
import { formatCurrency } from '@/lib/utils'
import { Gift, CheckCircle, XCircle, AlertCircle, HelpCircle, AlertTriangle } from 'lucide-react'
import type { CreateGiftInput, GiftInput, GiftClassification } from '@/types'
interface GiftFormProps {
onSubmit: (data: CreateGiftInput) => Promise<void>
initialData?: Partial<CreateGiftInput & { returned?: boolean }>
isLoading?: boolean
isLightMode?: boolean
}
export default function GiftForm({ onSubmit, initialData, isLoading, isLightMode }: GiftFormProps) {
const [formData, setFormData] = useState<CreateGiftInput>({
date: initialData?.date || new Date().toISOString().split('T')[0],
brand_name: initialData?.brand_name || '',
description: initialData?.description || '',
estimated_value: initialData?.estimated_value || 0,
has_motprestation: initialData?.has_motprestation || false,
used_in_business: initialData?.used_in_business || false,
used_privately: initialData?.used_privately || false,
is_simple_promo: initialData?.is_simple_promo || false,
})
const [returned, setReturned] = useState(initialData?.returned || false)
const [valueOverridden, setValueOverridden] = useState(false)
// Compute classification from form data (derived state, no need for useEffect)
const classification = useMemo<GiftClassification | null>(() => {
if (formData.estimated_value > 0) {
const input: GiftInput = {
estimatedValue: formData.estimated_value,
hasMotprestation: formData.has_motprestation,
usedInBusiness: formData.used_in_business,
usedPrivately: formData.used_privately,
isSimplePromoItem: formData.is_simple_promo || false,
}
return classifyGift(input)
}
return null
}, [formData.estimated_value, formData.has_motprestation, formData.used_in_business, formData.used_privately, formData.is_simple_promo])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
await onSubmit({ ...formData, returned } as CreateGiftInput & { returned?: boolean })
}
const updateField = <K extends keyof CreateGiftInput>(field: K, value: CreateGiftInput[K]) => {
setFormData((prev) => ({ ...prev, [field]: value }))
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Gift className="h-5 w-5" />
Produktinformation
</CardTitle>
<CardDescription>Ange information om gåvan eller produkten du fått</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="date">Datum</Label>
<Input
id="date"
type="date"
value={formData.date}
onChange={(e) => updateField('date', e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="brand_name">Varumärke/Företag</Label>
<Input
id="brand_name"
placeholder="t.ex. Daniel Wellington"
value={formData.brand_name}
onChange={(e) => updateField('brand_name', e.target.value)}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">Beskrivning</Label>
<Textarea
id="description"
placeholder="t.ex. Klocka, modell Classic Petite"
value={formData.description}
onChange={(e) => updateField('description', e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="estimated_value">Uppskattat marknadsvärde (SEK)</Label>
<Input
id="estimated_value"
type="number"
min="0"
step="1"
placeholder="0"
value={formData.estimated_value || ''}
onChange={(e) => updateField('estimated_value', parseFloat(e.target.value) || 0)}
required
/>
<p className="text-xs text-muted-foreground">
Skattefritt om enklare reklamgåva under {formatCurrency(TAX_FREE_PROMO_THRESHOLD)}
</p>
</div>
<div className="flex items-center space-x-2">
<Switch
id="is_simple_promo"
checked={formData.is_simple_promo || false}
onCheckedChange={(checked) => updateField('is_simple_promo', checked)}
/>
<Label htmlFor="is_simple_promo" className="flex items-center gap-2">
Enklare reklamgåva
<HelpCircle className="h-4 w-4 text-muted-foreground" />
</Label>
</div>
<p className="text-xs text-muted-foreground ml-12">
T.ex. penna, mugg, t-shirt med företagslogo - inte exklusiva produkter
</p>
{/* Returned toggle */}
<div className="flex items-center space-x-2">
<Switch
id="returned"
checked={returned}
onCheckedChange={setReturned}
/>
<Label htmlFor="returned">Returnerad</Label>
</div>
{returned && (
<p className="text-xs text-success ml-12">
Returnerade gåvor räknas inte som skattepliktig inkomst.
</p>
)}
{/* Value override warning */}
{valueOverridden && (
<div className="flex items-start gap-2 p-3 bg-warning/10 border border-warning/30 rounded-lg">
<AlertTriangle className="h-4 w-4 text-warning mt-0.5 flex-shrink-0" />
<p className="text-xs text-warning">
Skatteverket kräver marknadsvärde vid mottagning (inkl. moms). Lägre värdering ökar revisionsrisken.
</p>
</div>
)}
</CardContent>
</Card>
{/* Decision Tree Questions */}
<Card>
<CardHeader>
<CardTitle>Skatteklassificering</CardTitle>
<CardDescription>Svara på frågorna nedan för korrekt skattehantering</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Question 1: Motprestation */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="has_motprestation" className="font-medium">
Fanns krav på att du skulle posta om denna produkt?
</Label>
<Switch
id="has_motprestation"
checked={formData.has_motprestation}
onCheckedChange={(checked) => updateField('has_motprestation', checked)}
/>
</div>
<p className="text-sm text-muted-foreground">
T.ex. sponsrat inlägg, samarbete, eller annat krav på att nämna/visa produkten
</p>
</div>
{/* Question 2: Business use - hidden for light mode */}
{!isLightMode && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="used_in_business" className="font-medium">
Använder du produkten i din content-produktion?
</Label>
<Switch
id="used_in_business"
checked={formData.used_in_business}
onCheckedChange={(checked) => updateField('used_in_business', checked)}
/>
</div>
<p className="text-sm text-muted-foreground">T.ex. som rekvisita, utrustning, eller i bakgrunden</p>
</div>
)}
{/* Question 3: Private use */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="used_privately" className="font-medium">
Använder du produkten privat?
</Label>
<Switch
id="used_privately"
checked={formData.used_privately}
onCheckedChange={(checked) => updateField('used_privately', checked)}
/>
</div>
<p className="text-sm text-muted-foreground">T.ex. bär klockan dagligen, använder sminket privat</p>
</div>
</CardContent>
</Card>
{/* Classification Preview */}
{classification && (
<Card
className={
classification.taxable
? classification.deductibleAsExpense
? 'border-warning/50 bg-warning/5'
: 'border-destructive/50 bg-destructive/5'
: 'border-success/50 bg-success/5'
}
>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{classification.taxable ? (
classification.deductibleAsExpense ? (
<AlertCircle className="h-5 w-5 text-warning" />
) : (
<XCircle className="h-5 w-5 text-destructive" />
)
) : (
<CheckCircle className="h-5 w-5 text-success" />
)}
Klassificering
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
<Badge variant={classification.taxable ? 'destructive' : 'default'}>
{classification.taxable ? 'Skattepliktig' : 'Skattefri'}
</Badge>
{classification.deductibleAsExpense && <Badge variant="outline">Avdragsgill</Badge>}
<Badge variant="secondary">{getBookingTypeDisplayText(classification.bookingType)}</Badge>
</div>
<div className="text-sm">
<p className="font-medium">Förklaring:</p>
<p className="text-muted-foreground">{classification.reasoning}</p>
</div>
{classification.taxable && (
<div className="text-sm border-t pt-4">
<p className="font-medium">Skatteeffekt:</p>
<p className="text-muted-foreground">
{isLightMode ? (
<>Gåvoskatt: ca {formatCurrency(classification.marketValue * 0.32)} ({Math.round(32)}% av marknadsvärde {formatCurrency(classification.marketValue)}). Skatten ingår inte i din paraplyföretags hantering.</>
) : (
<>
Marknadsvärdet {formatCurrency(classification.marketValue)} läggs till din beskattningsbara inkomst.
{classification.deductibleAsExpense && ' Eftersom produkten endast används i verksamheten är den avdragsgill som kostnad.'}
</>
)}
</p>
</div>
)}
</CardContent>
</Card>
)}
{/* Submit */}
<div className="flex justify-end gap-2">
<Button type="submit" disabled={isLoading || !formData.brand_name || !formData.description}>
{isLoading ? 'Sparar...' : initialData ? 'Uppdatera' : 'Spara gåva'}
</Button>
</div>
</form>
)
}
-198
View File
@@ -1,198 +0,0 @@
'use client'
import { useState } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { formatCurrency } from '@/lib/utils'
import { Gift, Pencil, Trash2, Calendar, Building } from 'lucide-react'
import type { Gift as GiftType } from '@/types'
interface GiftListProps {
gifts: GiftType[]
onEdit: (gift: GiftType) => void
onDelete: (id: string) => Promise<void>
isDeleting?: boolean
}
type FilterType = 'all' | 'taxable' | 'tax_free' | 'deductible'
export default function GiftList({ gifts, onEdit, onDelete, isDeleting }: GiftListProps) {
const [filter, setFilter] = useState<FilterType>('all')
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [giftToDelete, setGiftToDelete] = useState<GiftType | null>(null)
// Filter gifts based on selection
const filteredGifts = gifts.filter((gift) => {
if (filter === 'all') return true
if (filter === 'taxable') return gift.classification?.taxable
if (filter === 'tax_free') return !gift.classification?.taxable
if (filter === 'deductible') return gift.classification?.deductibleAsExpense
return true
})
const handleDeleteClick = (gift: GiftType) => {
setGiftToDelete(gift)
setDeleteDialogOpen(true)
}
const handleConfirmDelete = async () => {
if (giftToDelete) {
await onDelete(giftToDelete.id)
setDeleteDialogOpen(false)
setGiftToDelete(null)
}
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('sv-SE', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
if (gifts.length === 0) {
return (
<Card>
<CardContent className="pt-6">
<div className="text-center py-8">
<Gift className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<h3 className="text-lg font-medium">Inga gåvor registrerade</h3>
<p className="text-sm text-muted-foreground mt-1">
Lägg till gåvor och produkter du fått för att hålla koll på skatten
</p>
</div>
</CardContent>
</Card>
)
}
return (
<div className="space-y-4">
{/* Filter */}
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Visar {filteredGifts.length} av {gifts.length} gåvor
</p>
<Select value={filter} onValueChange={(value: FilterType) => setFilter(value)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filtrera" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alla</SelectItem>
<SelectItem value="taxable">Skattepliktiga</SelectItem>
<SelectItem value="tax_free">Skattefria</SelectItem>
<SelectItem value="deductible">Avdragsgilla</SelectItem>
</SelectContent>
</Select>
</div>
{/* Gift list */}
<div className="space-y-3">
{filteredGifts.map((gift) => (
<Card key={gift.id}>
<CardContent className="pt-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium truncate">{gift.description}</h3>
{gift.classification?.taxable ? (
<Badge variant="destructive" className="flex-shrink-0">
Skattepliktig
</Badge>
) : (
<Badge variant="default" className="flex-shrink-0">
Skattefri
</Badge>
)}
{gift.classification?.deductibleAsExpense && (
<Badge variant="outline" className="flex-shrink-0">
Avdragsgill
</Badge>
)}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Building className="h-3 w-3" />
{gift.brand_name}
</span>
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{formatDate(gift.date)}
</span>
<span className="font-medium text-foreground">
{formatCurrency(Number(gift.estimated_value))}
</span>
</div>
{gift.classification?.reasoning && (
<p className="text-xs text-muted-foreground mt-2">
{gift.classification.reasoning}
</p>
)}
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => onEdit(gift)}
title="Redigera"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteClick(gift)}
title="Ta bort"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
{/* Delete confirmation dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Ta bort gåva?</DialogTitle>
<DialogDescription>
Är du säker på att du vill ta bort &quot;{giftToDelete?.description}&quot; från{' '}
{giftToDelete?.brand_name}? Detta kan inte ångras.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
Avbryt
</Button>
<Button variant="destructive" onClick={handleConfirmDelete} disabled={isDeleting}>
{isDeleting ? 'Tar bort...' : 'Ta bort'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
-55
View File
@@ -1,55 +0,0 @@
'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>
)
}
-5
View File
@@ -102,12 +102,7 @@ export function DeadlineForm({
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,
-478
View File
@@ -1,478 +0,0 @@
'use client'
import { useState, useRef } from 'react'
import { Briefing, BriefingType, BRIEFING_TYPE_LABELS } from '@/types'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Progress } from '@/components/ui/progress'
import { useToast } from '@/components/ui/use-toast'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Upload, FileText, X, Link2, AlignLeft, Loader2, Sparkles } from 'lucide-react'
import { cn } from '@/lib/utils'
interface BriefingFormProps {
campaignId: string
briefing?: Briefing | null
onSuccess: () => void
onCancel: () => void
}
export function BriefingForm({ campaignId, briefing, onSuccess, onCancel }: BriefingFormProps) {
const { toast } = useToast()
const fileInputRef = useRef<HTMLInputElement>(null)
const isEditing = !!briefing
// Form state
const [briefingType, setBriefingType] = useState<BriefingType>(briefing?.briefing_type || 'link')
const [title, setTitle] = useState(briefing?.title || '')
const [linkUrl, setLinkUrl] = useState(briefing?.briefing_type === 'link' ? briefing?.content || '' : '')
const [textContent, setTextContent] = useState(briefing?.text_content || '')
const [notes, setNotes] = useState(briefing?.notes || '')
// File upload state
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
// AI summary state
const [aiSummary, setAiSummary] = useState('')
const [isSummarizing, setIsSummarizing] = useState(false)
const handleSummarize = async () => {
if (!textContent.trim()) {
toast({
title: 'Text saknas',
description: 'Ange text att sammanfatta',
variant: 'destructive',
})
return
}
setIsSummarizing(true)
try {
const response = await fetch('/api/briefings/summarize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: textContent }),
})
if (!response.ok) {
throw new Error('Summarization failed')
}
const { data } = await response.json()
setAiSummary(data.summary)
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte sammanfatta texten',
variant: 'destructive',
})
} finally {
setIsSummarizing(false)
}
}
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
// Validate file type (only PDF for briefings)
if (file.type !== 'application/pdf') {
toast({
title: 'Ogiltig filtyp',
description: 'Endast PDF-filer ar tillåtna',
variant: 'destructive',
})
return
}
// Check file size (10MB max)
if (file.size > 10 * 1024 * 1024) {
toast({
title: 'Filen ar for stor',
description: 'Max filstorlek ar 10MB',
variant: 'destructive',
})
return
}
setSelectedFile(file)
// Auto-fill title from filename if empty
if (!title) {
setTitle(file.name.replace(/\.pdf$/i, ''))
}
}
}
const clearFileSelection = () => {
setSelectedFile(null)
if (fileInputRef.current) fileInputRef.current.value = ''
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// Validation
if (!title.trim()) {
toast({
title: 'Titel saknas',
description: 'Ange en titel for briefingen',
variant: 'destructive',
})
return
}
if (briefingType === 'link' && !linkUrl.trim()) {
toast({
title: 'Lank saknas',
description: 'Ange en URL for briefingen',
variant: 'destructive',
})
return
}
if (briefingType === 'text' && !textContent.trim()) {
toast({
title: 'Text saknas',
description: 'Ange textinnehall for briefingen',
variant: 'destructive',
})
return
}
if (briefingType === 'pdf' && !selectedFile && !isEditing) {
toast({
title: 'Fil saknas',
description: 'Valj en PDF-fil att ladda upp',
variant: 'destructive',
})
return
}
setIsSubmitting(true)
setUploadProgress(0)
try {
if (isEditing) {
// Update existing briefing
const updateData: Record<string, unknown> = {
title: title.trim(),
notes: notes.trim() || null,
}
if (briefingType === 'link') {
updateData.content = linkUrl.trim()
} else if (briefingType === 'text') {
updateData.text_content = textContent.trim()
}
const response = await fetch(`/api/briefings/${briefing.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updateData),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Update failed')
}
toast({
title: 'Briefing uppdaterad',
description: title,
})
} else if (briefingType === 'pdf' && selectedFile) {
// Upload PDF file
const formData = new FormData()
formData.append('file', selectedFile)
formData.append('title', title.trim())
if (notes.trim()) formData.append('notes', notes.trim())
// Simulate progress
const progressInterval = setInterval(() => {
setUploadProgress(prev => Math.min(prev + 10, 90))
}, 200)
const response = await fetch(`/api/campaigns/${campaignId}/briefings/upload`, {
method: 'POST',
body: formData,
})
clearInterval(progressInterval)
setUploadProgress(100)
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Upload failed')
}
toast({
title: 'Briefing uppladdad',
description: title,
})
} else {
// Create link or text briefing
const createData = {
briefing_type: briefingType,
title: title.trim(),
content: briefingType === 'link' ? linkUrl.trim() : null,
text_content: briefingType === 'text' ? textContent.trim() : null,
notes: notes.trim() || null,
}
const response = await fetch(`/api/campaigns/${campaignId}/briefings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(createData),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Create failed')
}
toast({
title: 'Briefing tillagd',
description: title,
})
}
onSuccess()
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Något gick fel',
variant: 'destructive',
})
} finally {
setIsSubmitting(false)
setUploadProgress(0)
}
}
const getTypeIcon = (type: BriefingType) => {
switch (type) {
case 'pdf':
return <FileText className="h-4 w-4" />
case 'link':
return <Link2 className="h-4 w-4" />
case 'text':
return <AlignLeft className="h-4 w-4" />
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Type selector - only show when creating new */}
{!isEditing && (
<div>
<Label>Typ av briefing</Label>
<div className="grid grid-cols-3 gap-2 mt-1.5">
{(['link', 'text', 'pdf'] as BriefingType[]).map(type => (
<button
key={type}
type="button"
onClick={() => setBriefingType(type)}
className={cn(
'flex items-center justify-center gap-2 p-3 border rounded-lg transition-colors',
briefingType === type
? 'border-primary bg-primary/10 text-primary'
: 'border-muted hover:border-primary/50 hover:bg-muted/50'
)}
>
{getTypeIcon(type)}
<span className="text-sm font-medium">{BRIEFING_TYPE_LABELS[type]}</span>
</button>
))}
</div>
</div>
)}
{/* Title */}
<div>
<Label htmlFor="title">Titel *</Label>
<Input
id="title"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="T.ex. Kampanjbrief Q1 2025"
disabled={isSubmitting}
/>
</div>
{/* Type-specific content */}
{briefingType === 'link' && (
<div>
<Label htmlFor="link">URL *</Label>
<Input
id="link"
type="url"
value={linkUrl}
onChange={e => setLinkUrl(e.target.value)}
placeholder="https://docs.google.com/... eller https://www.canva.com/..."
disabled={isSubmitting}
/>
<p className="text-xs text-muted-foreground mt-1">
Lankar till Google Docs, Canva, Dropbox, etc.
</p>
</div>
)}
{briefingType === 'text' && (
<div className="space-y-3">
<div>
<Label htmlFor="text">Textinnehall *</Label>
<Textarea
id="text"
value={textContent}
onChange={e => setTextContent(e.target.value)}
placeholder="Klistra in text fran e-post eller annat..."
rows={8}
disabled={isSubmitting}
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleSummarize}
disabled={isSummarizing || !textContent.trim()}
>
{isSummarizing ? (
<>
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
Sammanfattar...
</>
) : (
<>
<Sparkles className="h-4 w-4 mr-1" />
Sammanfatta med AI
</>
)}
</Button>
{aiSummary && (
<div className="p-4 border rounded-lg bg-muted/30">
<div className="flex items-center justify-between mb-2">
<Label className="text-sm font-medium">AI-sammanfattning</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setAiSummary('')}
>
<X className="h-3 w-3" />
</Button>
</div>
<div className="text-sm whitespace-pre-wrap text-muted-foreground">
{aiSummary}
</div>
</div>
)}
</div>
)}
{briefingType === 'pdf' && !isEditing && (
<div>
<Label>PDF-fil *</Label>
<input
ref={fileInputRef}
type="file"
accept=".pdf"
onChange={handleFileSelect}
className="hidden"
/>
{selectedFile ? (
<div className="flex items-center gap-3 p-3 border rounded-lg bg-muted/50 mt-1.5">
<FileText className="h-8 w-8 text-red-500" />
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{selectedFile.name}</p>
<p className="text-sm text-muted-foreground">
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={clearFileSelection}
disabled={isSubmitting}
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="w-full p-6 border-2 border-dashed rounded-lg hover:border-primary hover:bg-muted/50 transition-colors mt-1.5"
>
<div className="flex flex-col items-center gap-2">
<Upload className="h-8 w-8 text-muted-foreground" />
<p className="font-medium">Klicka for att valja PDF-fil</p>
<p className="text-sm text-muted-foreground">Max 10MB</p>
</div>
</button>
)}
</div>
)}
{/* Notes */}
<div>
<Label htmlFor="notes">Anteckningar (valfritt)</Label>
<Textarea
id="notes"
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder="Egna anteckningar om briefingen..."
rows={2}
disabled={isSubmitting}
/>
</div>
{/* Upload progress */}
{isSubmitting && briefingType === 'pdf' && uploadProgress > 0 && (
<div className="space-y-2">
<Progress value={uploadProgress} />
<p className="text-sm text-muted-foreground text-center">
Laddar upp... {uploadProgress}%
</p>
</div>
)}
{/* Actions */}
<div className="flex gap-2 justify-end">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
>
Avbryt
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{isEditing ? 'Sparar...' : 'Laddar upp...'}
</>
) : isEditing ? (
'Spara andringar'
) : (
'Lagg till briefing'
)}
</Button>
</div>
</form>
)
}
-359
View File
@@ -1,359 +0,0 @@
'use client'
import { useState, useRef } from 'react'
import { Briefing, BriefingType, BRIEFING_TYPE_LABELS } from '@/types'
import { BriefingForm } from './BriefingForm'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import {
FileText,
Link2,
AlignLeft,
Download,
Trash2,
Edit2,
Plus,
ChevronUp,
ExternalLink,
ChevronDown,
Loader2,
} from 'lucide-react'
import { cn } from '@/lib/utils'
interface BriefingListProps {
campaignId: string
briefings: Briefing[]
onUpdate: () => void
}
export function BriefingList({ campaignId, briefings, onUpdate }: BriefingListProps) {
const { toast } = useToast()
const [showForm, setShowForm] = useState(false)
const [editingBriefing, setEditingBriefing] = useState<Briefing | null>(null)
const [downloadingId, setDownloadingId] = useState<string | null>(null)
const [expandedTextId, setExpandedTextId] = useState<string | null>(null)
const handleDownload = async (briefing: Briefing) => {
if (briefing.briefing_type !== 'pdf') return
setDownloadingId(briefing.id)
try {
const response = await fetch(`/api/briefings/${briefing.id}/download`)
if (!response.ok) {
throw new Error('Download failed')
}
const data = await response.json()
window.open(data.url, '_blank')
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte ladda ner filen',
variant: 'destructive',
})
} finally {
setDownloadingId(null)
}
}
const handleDelete = async (briefing: Briefing) => {
if (!confirm(`Ta bort "${briefing.title}"?`)) return
try {
const response = await fetch(`/api/briefings/${briefing.id}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Delete failed')
}
toast({
title: 'Briefing borttagen',
description: briefing.title,
})
onUpdate()
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte ta bort briefingen',
variant: 'destructive',
})
}
}
const handleOpenLink = (briefing: Briefing) => {
if (briefing.briefing_type !== 'link' || !briefing.content) return
window.open(briefing.content, '_blank', 'noopener,noreferrer')
}
const handleEdit = (briefing: Briefing) => {
setEditingBriefing(briefing)
setShowForm(true)
}
const handleFormClose = () => {
setShowForm(false)
setEditingBriefing(null)
}
const handleFormSuccess = () => {
handleFormClose()
onUpdate()
}
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('sv-SE', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
const formatFileSize = (bytes: number | null) => {
if (!bytes) return ''
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
const getTypeIcon = (type: BriefingType) => {
switch (type) {
case 'pdf':
return <FileText className="h-5 w-5 text-red-500" />
case 'link':
return <Link2 className="h-5 w-5 text-blue-500" />
case 'text':
return <AlignLeft className="h-5 w-5 text-green-500" />
}
}
const getTypeBadgeColor = (type: BriefingType) => {
switch (type) {
case 'pdf':
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
case 'link':
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
case 'text':
return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
}
}
const truncateUrl = (url: string, maxLength: number = 40) => {
if (url.length <= maxLength) return url
return url.substring(0, maxLength) + '...'
}
// Sort by created date (newest first)
const sortedBriefings = [...briefings].sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium">
Briefing
<span className="text-muted-foreground ml-2">
({briefings.length})
</span>
</h3>
<Button
size="sm"
variant={showForm ? 'secondary' : 'default'}
onClick={() => {
if (showForm) {
handleFormClose()
} else {
setShowForm(true)
}
}}
>
{showForm ? (
<>
<ChevronUp className="h-4 w-4 mr-1" />
Stang
</>
) : (
<>
<Plus className="h-4 w-4 mr-1" />
Lagg till
</>
)}
</Button>
</div>
{/* Form section */}
{showForm && (
<div className="p-4 border rounded-lg bg-muted/30">
<BriefingForm
campaignId={campaignId}
briefing={editingBriefing}
onSuccess={handleFormSuccess}
onCancel={handleFormClose}
/>
</div>
)}
{/* Briefing list */}
{sortedBriefings.length > 0 ? (
<div className="space-y-2">
{sortedBriefings.map(briefing => (
<div
key={briefing.id}
className="p-3 border rounded-lg hover:bg-muted/50 transition-colors"
>
<div className="flex items-start gap-3">
<div className="mt-0.5">
{getTypeIcon(briefing.briefing_type)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium">{briefing.title}</p>
<Badge
variant="secondary"
className={cn('text-xs', getTypeBadgeColor(briefing.briefing_type))}
>
{BRIEFING_TYPE_LABELS[briefing.briefing_type]}
</Badge>
</div>
{/* Type-specific content display */}
{briefing.briefing_type === 'pdf' && briefing.filename && (
<p className="text-sm text-muted-foreground mt-1">
{briefing.filename}
{briefing.file_size && (
<span className="ml-2">({formatFileSize(briefing.file_size)})</span>
)}
</p>
)}
{briefing.briefing_type === 'link' && briefing.content && (
<button
onClick={() => handleOpenLink(briefing)}
className="text-sm text-blue-600 hover:underline flex items-center gap-1 mt-1"
>
{truncateUrl(briefing.content)}
<ExternalLink className="h-3 w-3" />
</button>
)}
{briefing.briefing_type === 'text' && briefing.text_content && (
<div className="mt-2">
<div
className={cn(
'text-sm text-muted-foreground bg-muted/50 rounded p-2 whitespace-pre-wrap',
expandedTextId !== briefing.id && 'max-h-20 overflow-hidden'
)}
>
{briefing.text_content}
</div>
{briefing.text_content.length > 200 && (
<button
onClick={() =>
setExpandedTextId(
expandedTextId === briefing.id ? null : briefing.id
)
}
className="text-xs text-primary hover:underline mt-1 flex items-center gap-1"
>
{expandedTextId === briefing.id ? (
<>
<ChevronUp className="h-3 w-3" />
Visa mindre
</>
) : (
<>
<ChevronDown className="h-3 w-3" />
Visa mer
</>
)}
</button>
)}
</div>
)}
{briefing.notes && (
<p className="text-sm text-muted-foreground italic mt-1">
{briefing.notes}
</p>
)}
<p className="text-xs text-muted-foreground mt-1">
Tillagd: {formatDate(briefing.created_at)}
</p>
</div>
<div className="flex gap-1 shrink-0">
{briefing.briefing_type === 'pdf' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleDownload(briefing)}
disabled={downloadingId === briefing.id}
title="Ladda ner"
>
{downloadingId === briefing.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
</Button>
)}
{briefing.briefing_type === 'link' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenLink(briefing)}
title="Oppna lank"
>
<ExternalLink className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(briefing)}
title="Redigera"
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(briefing)}
className="text-destructive hover:text-destructive"
title="Ta bort"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
))}
</div>
) : !showForm ? (
<div className="text-center py-8 text-muted-foreground">
<FileText className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>Ingen briefing tillagd</p>
<p className="text-sm mt-1">
Lagg till PDF-dokument, lankar eller text fran uppdragsgivaren
</p>
<Button
variant="outline"
size="sm"
className="mt-3"
onClick={() => setShowForm(true)}
>
<Plus className="h-4 w-4 mr-1" />
Lagg till briefing
</Button>
</div>
) : null}
</div>
)
}
-175
View File
@@ -1,175 +0,0 @@
'use client'
import { Campaign, CAMPAIGN_TYPE_LABELS } from '@/types'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { CampaignStatusBadge } from './CampaignStatusBadge'
import { cn } from '@/lib/utils'
import {
Building2,
Calendar,
FileText,
Package,
ChevronRight,
Banknote,
Clock,
AlertCircle,
} from 'lucide-react'
import Link from 'next/link'
interface CampaignCardProps {
campaign: Campaign
onClick?: () => void
}
export function CampaignCard({ campaign, onClick }: CampaignCardProps) {
const formatCurrency = (amount: number | null) => {
if (!amount) return '-'
return new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency: campaign.currency || 'SEK',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount)
}
const formatDate = (date: string | null) => {
if (!date) return '-'
return new Date(date).toLocaleDateString('sv-SE', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
// Deliverable progress
const totalDeliverables = campaign.deliverables?.length || 0
const completedDeliverables = campaign.deliverables?.filter(d =>
['approved', 'published'].includes(d.status)
).length || 0
const pendingDeliverables = totalDeliverables - completedDeliverables
const progressPercent = totalDeliverables > 0 ? Math.round((completedDeliverables / totalDeliverables) * 100) : 0
// Find next due date
const nextDueDate = campaign.deliverables
?.filter(d => d.due_date && !['approved', 'published'].includes(d.status))
.sort((a, b) => new Date(a.due_date!).getTime() - new Date(b.due_date!).getTime())[0]?.due_date
// Check if next deadline is soon (within 3 days)
const isDeadlineSoon = nextDueDate && (() => {
const daysUntil = Math.round((new Date(nextDueDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
return daysUntil <= 3 && daysUntil >= 0
})()
// Check for active exclusivities
const hasActiveExclusivity = campaign.exclusivities?.some(e => {
const today = new Date().toISOString().split('T')[0]
return e.start_date <= today && e.end_date >= today
})
// Customer initial for avatar
const customerInitial = campaign.customer?.name?.charAt(0)?.toUpperCase() || '?'
return (
<Link href={`/campaigns/${campaign.id}`}>
<Card
className={cn(
'hover-lift cursor-pointer',
campaign.status === 'cancelled' && 'opacity-60'
)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-3 flex-1 min-w-0">
{/* Customer avatar */}
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-sm font-medium text-primary">
{customerInitial}
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-lg truncate">{campaign.name || campaign.customer?.name || 'Namnlöst samarbete'}</CardTitle>
{campaign.customer && (
<p className="text-sm text-muted-foreground mt-0.5 truncate">
{campaign.customer.name}
</p>
)}
</div>
</div>
<div className="flex flex-col items-end gap-1">
<CampaignStatusBadge status={campaign.status} />
<Badge variant="secondary" className="text-xs">
{CAMPAIGN_TYPE_LABELS[campaign.campaign_type]}
</Badge>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
{/* Deliverable progress bar */}
{totalDeliverables > 0 && (
<div className="mb-4">
<div className="flex items-center justify-between text-xs mb-1.5">
<span className="text-muted-foreground">Innehåll</span>
<span className="font-medium">{completedDeliverables}/{totalDeliverables} klara</span>
</div>
<Progress value={progressPercent} className="h-1.5" />
</div>
)}
<div className="grid grid-cols-2 gap-3 text-sm">
{/* Value */}
<div className="flex items-center gap-2 text-muted-foreground">
<Banknote className="h-4 w-4" />
<span className="font-medium text-foreground tabular-nums">
{formatCurrency(campaign.total_value)}
</span>
</div>
{/* Date range */}
<div className="flex items-center gap-2 text-muted-foreground">
<Calendar className="h-4 w-4" />
<span>
{campaign.start_date ? formatDate(campaign.start_date) : '-'}
</span>
</div>
{/* Next deadline - more prominent if soon */}
{nextDueDate && (
<div className={cn(
"flex items-center gap-2",
isDeadlineSoon ? "text-warning-foreground" : "text-muted-foreground"
)}>
{isDeadlineSoon ? (
<AlertCircle className="h-4 w-4" />
) : (
<Clock className="h-4 w-4" />
)}
<span className={isDeadlineSoon ? 'font-medium' : ''}>
{formatDate(nextDueDate)}
</span>
</div>
)}
{/* Contracts */}
{campaign.contracts && campaign.contracts.length > 0 && (
<div className="flex items-center gap-2 text-muted-foreground">
<FileText className="h-4 w-4" />
<span>{campaign.contracts.length} avtal</span>
</div>
)}
{/* Exclusivity indicator */}
{hasActiveExclusivity && (
<Badge variant="outline" className="text-xs w-fit">
Aktiv exklusivitet
</Badge>
)}
</div>
<div className="flex justify-end mt-4">
<ChevronRight className="h-5 w-5 text-muted-foreground" />
</div>
</CardContent>
</Card>
</Link>
)
}
-514
View File
@@ -1,514 +0,0 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import {
Campaign,
CAMPAIGN_STATUS_LABELS,
CAMPAIGN_TYPE_LABELS,
BILLING_FREQUENCY_LABELS,
CampaignStatus
} from '@/types'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
import { CampaignStatusBadge } from './CampaignStatusBadge'
import { DeliverableList } from './DeliverableList'
import { ExclusivityList } from './ExclusivityList'
import { ContractList } from './ContractList'
import { BriefingList } from './BriefingList'
import { CampaignInvoiceSummary } from './CampaignInvoiceSummary'
import { cn } from '@/lib/utils'
import {
Building2,
Calendar,
Banknote,
Edit2,
Trash2,
ArrowLeft,
ChevronRight,
Clock,
FileText,
Package,
Shield,
Receipt,
BookOpen,
Check,
Circle,
} from 'lucide-react'
import Link from 'next/link'
interface CampaignDetailProps {
campaign: Campaign
onUpdate: () => void
onEdit: () => void
}
const STATUS_TRANSITIONS: Record<CampaignStatus, CampaignStatus[]> = {
negotiation: ['contracted', 'cancelled'],
contracted: ['active', 'cancelled'],
active: ['delivered', 'cancelled'],
delivered: ['invoiced', 'active'],
invoiced: ['completed', 'delivered'],
completed: ['invoiced'],
cancelled: ['negotiation'],
}
const LIFECYCLE_STEPS: { status: CampaignStatus; label: string }[] = [
{ status: 'negotiation', label: 'Förhandling' },
{ status: 'contracted', label: 'Avtalat' },
{ status: 'active', label: 'Aktivt' },
{ status: 'delivered', label: 'Levererat' },
{ status: 'invoiced', label: 'Fakturerat' },
{ status: 'completed', label: 'Klart' },
]
function getStepState(stepStatus: CampaignStatus, currentStatus: CampaignStatus): 'completed' | 'current' | 'upcoming' {
const stepIndex = LIFECYCLE_STEPS.findIndex(s => s.status === stepStatus)
const currentIndex = LIFECYCLE_STEPS.findIndex(s => s.status === currentStatus)
if (currentStatus === 'cancelled') return 'upcoming'
if (stepIndex < currentIndex) return 'completed'
if (stepIndex === currentIndex) return 'current'
return 'upcoming'
}
export function CampaignDetail({ campaign, onUpdate, onEdit }: CampaignDetailProps) {
const router = useRouter()
const { toast } = useToast()
const [isUpdating, setIsUpdating] = useState(false)
const formatCurrency = (amount: number | null) => {
if (!amount) return '-'
return new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency: campaign.currency || 'SEK',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount)
}
const formatDate = (date: string | null) => {
if (!date) return '-'
return new Date(date).toLocaleDateString('sv-SE', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
const handleStatusChange = async (newStatus: CampaignStatus) => {
setIsUpdating(true)
try {
const response = await fetch(`/api/campaigns/${campaign.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus }),
})
if (!response.ok) {
throw new Error('Failed to update status')
}
toast({
title: 'Status uppdaterad',
description: CAMPAIGN_STATUS_LABELS[newStatus],
})
onUpdate()
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte uppdatera status',
variant: 'destructive',
})
} finally {
setIsUpdating(false)
}
}
const handleDelete = async () => {
if (!confirm(`Ta bort samarbetet "${campaign.name}"? Detta kan inte ångras.`)) return
try {
const response = await fetch(`/api/campaigns/${campaign.id}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Failed to delete')
}
toast({
title: 'Samarbete borttaget',
description: campaign.name,
})
router.push('/campaigns')
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte ta bort samarbetet',
variant: 'destructive',
})
}
}
const availableTransitions = STATUS_TRANSITIONS[campaign.status] || []
// Count items
const deliverableCount = campaign.deliverables?.length || 0
const exclusivityCount = campaign.exclusivities?.length || 0
const contractCount = campaign.contracts?.length || 0
const invoiceCount = campaign.invoices?.length || 0
const briefingCount = campaign.briefings?.length || 0
// Deliverable progress
const completedDeliverables = campaign.deliverables?.filter(d =>
['approved', 'published'].includes(d.status)
).length || 0
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<Link
href="/campaigns"
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 mb-2"
>
<ArrowLeft className="h-4 w-4" />
Tillbaka till samarbeten
</Link>
<h1 className="text-3xl font-bold tracking-tight">{campaign.name}</h1>
{(campaign.customer || campaign.brand_name) && (
<p className="text-muted-foreground flex items-center gap-1 mt-1">
{campaign.brand_name && (
<span className="flex items-center gap-1">
{campaign.brand_name}
</span>
)}
{campaign.brand_name && campaign.customer && (
<ChevronRight className="h-4 w-4" />
)}
{campaign.customer && (
<span className="flex items-center gap-1">
<Building2 className="h-4 w-4" />
{campaign.customer.name}
</span>
)}
</p>
)}
</div>
<div className="flex items-center gap-2">
{/* Prominent "Create invoice" button when status is delivered */}
{campaign.status === 'delivered' && (
<Button
onClick={() => router.push(`/invoices/new?campaign_id=${campaign.id}&customer_id=${campaign.customer_id}`)}
className="bg-accent hover:bg-accent/90 text-accent-foreground"
>
<Receipt className="h-4 w-4 mr-1" />
Skapa faktura
</Button>
)}
<Button variant="outline" size="sm" onClick={onEdit}>
<Edit2 className="h-4 w-4 mr-1" />
Redigera
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDelete}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4 mr-1" />
Ta bort
</Button>
</div>
</div>
{/* Status and quick info */}
<div className="flex flex-wrap items-center gap-3">
<CampaignStatusBadge status={campaign.status} />
<Badge variant="secondary">{CAMPAIGN_TYPE_LABELS[campaign.campaign_type]}</Badge>
{availableTransitions.length > 0 && (
<Select
value=""
onValueChange={(v) => handleStatusChange(v as CampaignStatus)}
disabled={isUpdating}
>
<SelectTrigger className="w-[180px] h-8 text-sm">
<SelectValue placeholder="Ändra status..." />
</SelectTrigger>
<SelectContent>
{availableTransitions.map(status => (
<SelectItem key={status} value={status}>
{CAMPAIGN_STATUS_LABELS[status]}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Campaign lifecycle timeline */}
{campaign.status !== 'cancelled' && (
<Card>
<CardContent className="py-4">
<div className="flex items-center justify-between">
{LIFECYCLE_STEPS.map((step, index) => {
const state = getStepState(step.status, campaign.status)
return (
<div key={step.status} className="flex items-center flex-1">
<div className="flex flex-col items-center">
<div className={cn(
"w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium transition-colors",
state === 'completed' && "bg-success text-success-foreground",
state === 'current' && "bg-primary text-primary-foreground ring-4 ring-primary/20",
state === 'upcoming' && "bg-muted text-muted-foreground"
)}>
{state === 'completed' ? (
<Check className="h-4 w-4" />
) : (
<Circle className="h-3 w-3" />
)}
</div>
<span className={cn(
"text-[10px] mt-1.5 text-center leading-tight whitespace-nowrap",
state === 'current' ? "text-foreground font-medium" : "text-muted-foreground"
)}>
{step.label}
</span>
</div>
{index < LIFECYCLE_STEPS.length - 1 && (
<div className={cn(
"flex-1 h-0.5 mx-1",
state === 'completed' ? "bg-success" : "bg-muted"
)} />
)}
</div>
)
})}
</div>
</CardContent>
</Card>
)}
{/* Overview cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card className="hover-lift">
<CardContent className="pt-4">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Banknote className="h-4 w-4" />
<span className="text-sm">Arvode</span>
</div>
<p className="text-2xl font-bold tabular-nums">{formatCurrency(campaign.total_value)}</p>
{campaign.vat_included && (
<p className="text-xs text-muted-foreground">inkl. moms</p>
)}
</CardContent>
</Card>
<Card className="hover-lift">
<CardContent className="pt-4">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Calendar className="h-4 w-4" />
<span className="text-sm">Publiceringsdatum</span>
</div>
<p className="font-medium">
{campaign.publication_date ? formatDate(campaign.publication_date) : 'Ej satt'}
</p>
{campaign.draft_deadline && (
<p className="text-sm text-muted-foreground">
Utkast: {formatDate(campaign.draft_deadline)}
</p>
)}
</CardContent>
</Card>
<Card className="hover-lift">
<CardContent className="pt-4">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Package className="h-4 w-4" />
<span className="text-sm">Innehåll</span>
</div>
<p className="font-medium">
{deliverableCount > 0 ? (
<span>{completedDeliverables}/{deliverableCount} klara</span>
) : (
<span className="text-muted-foreground">Inga</span>
)}
</p>
{deliverableCount > 0 && (
<div className="mt-2 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-success rounded-full transition-all"
style={{ width: `${Math.round((completedDeliverables / deliverableCount) * 100)}%` }}
/>
</div>
)}
</CardContent>
</Card>
<Card className="hover-lift">
<CardContent className="pt-4">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<FileText className="h-4 w-4" />
<span className="text-sm">Avtal</span>
</div>
<p className="font-medium">
{campaign.contract_signed_at
? formatDate(campaign.contract_signed_at)
: 'Ej signerat'}
</p>
{contractCount > 0 && (
<p className="text-sm text-muted-foreground">
{contractCount} dokument
</p>
)}
</CardContent>
</Card>
</div>
{/* Description */}
{campaign.description && (
<Card>
<CardContent className="pt-4">
<p className="text-muted-foreground whitespace-pre-wrap text-balance">
{campaign.description}
</p>
</CardContent>
</Card>
)}
{/* Tabs for different sections */}
<Tabs defaultValue="deliverables" className="space-y-4">
<TabsList>
<TabsTrigger value="deliverables" className="gap-2">
<Package className="h-4 w-4" />
Innehåll
{deliverableCount > 0 && (
<Badge variant="secondary" className="ml-1">{deliverableCount}</Badge>
)}
</TabsTrigger>
<TabsTrigger value="exclusivities" className="gap-2">
<Shield className="h-4 w-4" />
Exklusivitet
{exclusivityCount > 0 && (
<Badge variant="secondary" className="ml-1">{exclusivityCount}</Badge>
)}
</TabsTrigger>
<TabsTrigger value="briefings" className="gap-2">
<BookOpen className="h-4 w-4" />
Briefing
{briefingCount > 0 && (
<Badge variant="secondary" className="ml-1">{briefingCount}</Badge>
)}
</TabsTrigger>
<TabsTrigger value="contracts" className="gap-2">
<FileText className="h-4 w-4" />
Avtal
{contractCount > 0 && (
<Badge variant="secondary" className="ml-1">{contractCount}</Badge>
)}
</TabsTrigger>
<TabsTrigger value="invoices" className="gap-2">
<Receipt className="h-4 w-4" />
Fakturor
{invoiceCount > 0 && (
<Badge variant="secondary" className="ml-1">{invoiceCount}</Badge>
)}
</TabsTrigger>
</TabsList>
<TabsContent value="deliverables">
<Card>
<CardContent className="pt-6">
<DeliverableList
campaignId={campaign.id}
deliverables={campaign.deliverables || []}
onUpdate={onUpdate}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="exclusivities">
<Card>
<CardContent className="pt-6">
<ExclusivityList
campaignId={campaign.id}
exclusivities={campaign.exclusivities || []}
onUpdate={onUpdate}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="briefings">
<Card>
<CardContent className="pt-6">
<BriefingList
campaignId={campaign.id}
briefings={campaign.briefings || []}
onUpdate={onUpdate}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="contracts">
<Card>
<CardContent className="pt-6">
<ContractList
campaignId={campaign.id}
contracts={campaign.contracts || []}
onUpdate={onUpdate}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="invoices">
<Card>
<CardContent className="pt-6">
<CampaignInvoiceSummary
campaignId={campaign.id}
invoices={campaign.invoices || []}
totalValue={campaign.total_value}
currency={campaign.currency}
onCreateInvoice={() => {
router.push(`/invoices/new?campaign_id=${campaign.id}&customer_id=${campaign.customer_id}`)
}}
/>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Notes */}
{campaign.notes && (
<Card>
<CardHeader>
<CardTitle className="text-base">Anteckningar</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground whitespace-pre-wrap">
{campaign.notes}
</p>
</CardContent>
</Card>
)}
</div>
)
}
-392
View File
@@ -1,392 +0,0 @@
'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>
)
}
@@ -1,195 +0,0 @@
'use client'
import { Invoice, PAYMENT_STATUS_LABELS } from '@/types'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import {
FileText,
Plus,
ExternalLink,
CheckCircle,
Clock,
AlertTriangle
} from 'lucide-react'
import Link from 'next/link'
import { cn } from '@/lib/utils'
interface CampaignInvoiceSummaryProps {
campaignId: string
invoices: Invoice[]
totalValue: number | null
currency: string
onCreateInvoice?: () => void
}
export function CampaignInvoiceSummary({
campaignId,
invoices,
totalValue,
currency,
onCreateInvoice
}: CampaignInvoiceSummaryProps) {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency: currency || 'SEK',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount)
}
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('sv-SE', {
day: 'numeric',
month: 'short',
})
}
// Calculate totals
const totalInvoiced = invoices.reduce((sum, inv) => sum + inv.total, 0)
const totalPaid = invoices
.filter(inv => inv.status === 'paid')
.reduce((sum, inv) => sum + inv.total, 0)
const invoicedPercentage = totalValue ? (totalInvoiced / totalValue) * 100 : 0
const paidPercentage = totalValue ? (totalPaid / totalValue) * 100 : 0
// Group by status
const paidInvoices = invoices.filter(inv => inv.status === 'paid')
const sentInvoices = invoices.filter(inv => inv.status === 'sent')
const overdueInvoices = invoices.filter(inv => inv.status === 'overdue')
const draftInvoices = invoices.filter(inv => inv.status === 'draft')
const getStatusIcon = (status: string) => {
switch (status) {
case 'paid':
return <CheckCircle className="h-4 w-4 text-success" />
case 'overdue':
return <AlertTriangle className="h-4 w-4 text-destructive" />
default:
return <Clock className="h-4 w-4 text-muted-foreground" />
}
}
const getStatusBadge = (status: string) => {
switch (status) {
case 'paid':
return <Badge variant="outline" className="bg-green-100 text-green-700 border-green-200">Betald</Badge>
case 'sent':
return <Badge variant="outline" className="bg-blue-100 text-blue-700 border-blue-200">Skickad</Badge>
case 'overdue':
return <Badge variant="destructive">Förfallen</Badge>
case 'draft':
return <Badge variant="secondary">Utkast</Badge>
default:
return <Badge variant="outline">{status}</Badge>
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium">
Fakturor
<span className="text-muted-foreground ml-2">
({invoices.length})
</span>
</h3>
{onCreateInvoice && (
<Button size="sm" onClick={onCreateInvoice}>
<Plus className="h-4 w-4 mr-1" />
Skapa faktura
</Button>
)}
</div>
{/* Progress summary */}
{totalValue && totalValue > 0 && (
<Card>
<CardContent className="pt-4">
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Fakturerat</span>
<span className="font-medium">
{formatCurrency(totalInvoiced)} / {formatCurrency(totalValue)}
</span>
</div>
<Progress value={invoicedPercentage} className="h-2" />
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Betalt</span>
<span className="font-medium text-success">
{formatCurrency(totalPaid)}
</span>
</div>
<Progress value={paidPercentage} className="h-2 bg-muted [&>div]:bg-success" />
{totalValue > totalInvoiced && (
<p className="text-sm text-muted-foreground">
Kvar att fakturera: {formatCurrency(totalValue - totalInvoiced)}
</p>
)}
</div>
</CardContent>
</Card>
)}
{/* Invoice list */}
{invoices.length > 0 ? (
<div className="space-y-2">
{invoices
.sort((a, b) => new Date(b.invoice_date).getTime() - new Date(a.invoice_date).getTime())
.map(invoice => (
<Link key={invoice.id} href={`/invoices/${invoice.id}`}>
<div className={cn(
'flex items-center gap-3 p-3 border rounded-lg hover:border-primary/50 transition-colors',
invoice.status === 'overdue' && 'border-destructive/50 bg-destructive/5'
)}>
{getStatusIcon(invoice.status)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium">{invoice.invoice_number}</span>
{getStatusBadge(invoice.status)}
</div>
<p className="text-sm text-muted-foreground">
{formatDate(invoice.invoice_date)}
{invoice.due_date && ` • Förfaller: ${formatDate(invoice.due_date)}`}
</p>
</div>
<div className="text-right">
<p className="font-medium">{formatCurrency(invoice.total)}</p>
{invoice.payment_status && (
<p className="text-xs text-muted-foreground">
{PAYMENT_STATUS_LABELS[invoice.payment_status]}
</p>
)}
</div>
<ExternalLink className="h-4 w-4 text-muted-foreground" />
</div>
</Link>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<FileText className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>Inga fakturor ännu</p>
{onCreateInvoice && (
<Button
variant="outline"
size="sm"
className="mt-2"
onClick={onCreateInvoice}
>
<Plus className="h-4 w-4 mr-1" />
Skapa första fakturan
</Button>
)}
</div>
)}
</div>
)
}
-201
View File
@@ -1,201 +0,0 @@
'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>
)
}
@@ -1,37 +0,0 @@
'use client'
import { Badge } from '@/components/ui/badge'
import type { CampaignStatus } from '@/types'
import { CAMPAIGN_STATUS_LABELS } from '@/types'
import { cn } from '@/lib/utils'
const STATUS_STYLES: Record<CampaignStatus, { className: string; variant: 'default' | 'secondary' | 'outline' | 'destructive' }> = {
negotiation: { className: 'bg-blue-100 text-blue-700 border-blue-200', variant: 'outline' },
contracted: { className: 'bg-purple-100 text-purple-700 border-purple-200', variant: 'outline' },
active: { className: 'bg-green-100 text-green-700 border-green-200', variant: 'outline' },
delivered: { className: 'bg-yellow-100 text-yellow-700 border-yellow-200', variant: 'outline' },
invoiced: { className: 'bg-orange-100 text-orange-700 border-orange-200', variant: 'outline' },
completed: { className: 'bg-emerald-100 text-emerald-700 border-emerald-200', variant: 'outline' },
cancelled: { className: 'bg-gray-100 text-gray-500 border-gray-200', variant: 'outline' },
}
interface CampaignStatusBadgeProps {
status: CampaignStatus
size?: 'sm' | 'default'
}
export function CampaignStatusBadge({ status, size = 'default' }: CampaignStatusBadgeProps) {
const style = STATUS_STYLES[status]
return (
<Badge
variant={style.variant}
className={cn(
style.className,
size === 'sm' && 'text-xs px-1.5 py-0'
)}
>
{CAMPAIGN_STATUS_LABELS[status]}
</Badge>
)
}
-159
View File
@@ -1,159 +0,0 @@
'use client'
import Link from 'next/link'
import { Campaign, Deliverable, CAMPAIGN_STATUS_LABELS } from '@/types'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { CampaignStatusBadge } from './CampaignStatusBadge'
import {
Megaphone,
ArrowRight,
Clock,
Package,
AlertTriangle,
Plus
} from 'lucide-react'
import { cn } from '@/lib/utils'
interface CampaignsWidgetProps {
campaigns: Campaign[]
className?: string
}
export function CampaignsWidget({ campaigns, className }: CampaignsWidgetProps) {
const today = new Date().toISOString().split('T')[0]
// Get active campaigns
const activeCampaigns = campaigns.filter(c =>
['active', 'contracted', 'negotiation'].includes(c.status)
)
// Find upcoming deliverables (next 7 days)
const next7Days = new Date()
next7Days.setDate(next7Days.getDate() + 7)
const next7DaysStr = next7Days.toISOString().split('T')[0]
const upcomingDeliverables: (Deliverable & { campaignName: string })[] = []
for (const campaign of activeCampaigns) {
for (const deliverable of campaign.deliverables || []) {
if (
deliverable.due_date &&
deliverable.due_date >= today &&
deliverable.due_date <= next7DaysStr &&
!['approved', 'published'].includes(deliverable.status)
) {
upcomingDeliverables.push({
...deliverable,
campaignName: campaign.name
})
}
}
}
// Sort by due date
upcomingDeliverables.sort((a, b) =>
new Date(a.due_date!).getTime() - new Date(b.due_date!).getTime()
)
// Find overdue deliverables
const overdueCount = activeCampaigns.reduce((count, campaign) => {
return count + (campaign.deliverables?.filter(d =>
d.due_date &&
d.due_date < today &&
!['approved', 'published'].includes(d.status)
).length || 0)
}, 0)
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('sv-SE', {
weekday: 'short',
day: 'numeric',
month: 'short',
})
}
if (activeCampaigns.length === 0) {
return null
}
return (
<Card className={className}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<Megaphone className="h-4 w-4" />
Samarbeten
</CardTitle>
<Link href="/campaigns">
<Button variant="ghost" size="sm" className="text-xs">
Visa alla
<ArrowRight className="h-3 w-3 ml-1" />
</Button>
</Link>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Active campaigns summary */}
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Aktiva samarbeten</span>
<span className="font-medium">{activeCampaigns.length}</span>
</div>
{/* Overdue warning */}
{overdueCount > 0 && (
<div className="flex items-center gap-2 p-2 bg-destructive/10 rounded-lg">
<AlertTriangle className="h-4 w-4 text-destructive" />
<span className="text-sm text-destructive">
{overdueCount} försenat innehåll
</span>
</div>
)}
{/* Upcoming deliverables */}
{upcomingDeliverables.length > 0 && (
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-2">
Kommande denna vecka
</p>
<div className="space-y-2">
{upcomingDeliverables.slice(0, 3).map(deliverable => (
<div
key={deliverable.id}
className="flex items-center gap-2 p-2 rounded-lg border bg-card"
>
<Package className="h-4 w-4 text-muted-foreground" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{deliverable.title}
</p>
<p className="text-xs text-muted-foreground">
{deliverable.campaignName}
</p>
</div>
<div className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatDate(deliverable.due_date!)}
</div>
</div>
))}
{upcomingDeliverables.length > 3 && (
<p className="text-xs text-muted-foreground text-center">
+{upcomingDeliverables.length - 3} till
</p>
)}
</div>
</div>
)}
{/* Quick add button */}
<Link href="/campaigns/new" className="block">
<Button variant="outline" size="sm" className="w-full">
<Plus className="h-4 w-4 mr-1" />
Nytt samarbete
</Button>
</Link>
</CardContent>
</Card>
)
}
-326
View File
@@ -1,326 +0,0 @@
'use client'
import { useState } from 'react'
import { Contract, ExtractionStatus } from '@/types'
import { ContractUpload } from './ContractUpload'
import { ExtractionStatusBadge } from '@/components/contracts/ExtractionStatusBadge'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import {
FileText,
Download,
Trash2,
Star,
Calendar,
Plus,
ChevronDown,
ChevronUp,
Sparkles,
Loader2,
} from 'lucide-react'
import { cn } from '@/lib/utils'
interface ContractListProps {
campaignId: string
contracts: Contract[]
onUpdate: () => void
}
export function ContractList({ campaignId, contracts, onUpdate }: ContractListProps) {
const { toast } = useToast()
const [showUpload, setShowUpload] = useState(false)
const [downloadingId, setDownloadingId] = useState<string | null>(null)
const [extractingId, setExtractingId] = useState<string | null>(null)
const handleDownload = async (contract: Contract) => {
setDownloadingId(contract.id)
try {
const response = await fetch(`/api/contracts/${contract.id}/download`)
if (!response.ok) {
throw new Error('Download failed')
}
const data = await response.json()
// Open download in new tab
window.open(data.url, '_blank')
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte ladda ner filen',
variant: 'destructive',
})
} finally {
setDownloadingId(null)
}
}
const handleDelete = async (contract: Contract) => {
if (!confirm(`Ta bort "${contract.filename}"?`)) return
try {
const response = await fetch(`/api/contracts/${contract.id}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Delete failed')
}
toast({
title: 'Avtal borttaget',
description: contract.filename,
})
onUpdate()
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte ta bort avtalet',
variant: 'destructive',
})
}
}
const handleSetPrimary = async (contract: Contract) => {
try {
const response = await fetch(`/api/contracts/${contract.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_primary: true }),
})
if (!response.ok) {
throw new Error('Update failed')
}
toast({
title: 'Huvudavtal uppdaterat',
})
onUpdate()
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte uppdatera',
variant: 'destructive',
})
}
}
const handleExtract = async (contract: Contract) => {
if (contract.mime_type !== 'application/pdf') {
toast({
title: 'Fel',
description: 'Endast PDF-filer kan analyseras',
variant: 'destructive',
})
return
}
setExtractingId(contract.id)
try {
const response = await fetch(`/api/contracts/${contract.id}/extract`, {
method: 'POST',
})
const result = await response.json()
if (!response.ok) {
throw new Error(result.error || 'Extraction failed')
}
toast({
title: 'Analys klar',
description: 'Avtalsinformationen har extraherats',
})
onUpdate()
} catch (error) {
toast({
title: 'Fel vid analys',
description: error instanceof Error ? error.message : 'Kunde inte analysera avtalet',
variant: 'destructive',
})
} finally {
setExtractingId(null)
}
}
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('sv-SE', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
const formatFileSize = (bytes: number | null) => {
if (!bytes) return ''
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
const getFileIcon = (mimeType: string | null) => {
if (mimeType?.startsWith('image/')) return '🖼️'
if (mimeType === 'application/pdf') return '📄'
if (mimeType?.includes('word')) return '📝'
return '📎'
}
// Sort: primary first, then by upload date
const sortedContracts = [...contracts].sort((a, b) => {
if (a.is_primary && !b.is_primary) return -1
if (!a.is_primary && b.is_primary) return 1
return new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime()
})
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium">
Avtal
<span className="text-muted-foreground ml-2">
({contracts.length})
</span>
</h3>
<Button
size="sm"
variant={showUpload ? 'secondary' : 'default'}
onClick={() => setShowUpload(!showUpload)}
>
{showUpload ? (
<>
<ChevronUp className="h-4 w-4 mr-1" />
Stäng
</>
) : (
<>
<Plus className="h-4 w-4 mr-1" />
Ladda upp
</>
)}
</Button>
</div>
{/* Upload section */}
{showUpload && (
<div className="p-4 border rounded-lg bg-muted/30">
<ContractUpload
campaignId={campaignId}
onSuccess={() => {
setShowUpload(false)
onUpdate()
}}
/>
</div>
)}
{/* Contract list */}
{sortedContracts.length > 0 ? (
<div className="space-y-2">
{sortedContracts.map(contract => (
<div
key={contract.id}
className={cn(
'flex items-center gap-3 p-3 border rounded-lg',
contract.is_primary && 'border-primary/50 bg-primary/5'
)}
>
<div className="text-2xl">
{getFileIcon(contract.mime_type)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium truncate">{contract.filename}</p>
{contract.is_primary && (
<Badge variant="default" className="text-xs">
<Star className="h-3 w-3 mr-1" />
Huvudavtal
</Badge>
)}
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span>{formatFileSize(contract.file_size)}</span>
{contract.signing_date && (
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
Signerat: {formatDate(contract.signing_date)}
</span>
)}
<span>Uppladdat: {formatDate(contract.uploaded_at)}</span>
{contract.extraction_status && contract.extraction_status !== 'pending' && (
<ExtractionStatusBadge status={contract.extraction_status as ExtractionStatus} />
)}
</div>
</div>
<div className="flex gap-1">
{contract.mime_type === 'application/pdf' &&
contract.extraction_status !== 'completed' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleExtract(contract)}
disabled={extractingId === contract.id}
title="Analysera med AI"
>
{extractingId === contract.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
</Button>
)}
{!contract.is_primary && (
<Button
variant="ghost"
size="sm"
onClick={() => handleSetPrimary(contract)}
title="Sätt som huvudavtal"
>
<Star className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => handleDownload(contract)}
disabled={downloadingId === contract.id}
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(contract)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
) : !showUpload ? (
<div className="text-center py-8 text-muted-foreground">
<FileText className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>Inga avtal uppladdade</p>
<Button
variant="outline"
size="sm"
className="mt-2"
onClick={() => setShowUpload(true)}
>
<Plus className="h-4 w-4 mr-1" />
Ladda upp avtal
</Button>
</div>
) : null}
</div>
)
}
-214
View File
@@ -1,214 +0,0 @@
'use client'
import { useState, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Progress } from '@/components/ui/progress'
import { useToast } from '@/components/ui/use-toast'
import { Upload, FileText, X } from 'lucide-react'
interface ContractUploadProps {
campaignId: string
onSuccess?: () => void
}
export function ContractUpload({ campaignId, onSuccess }: ContractUploadProps) {
const { toast } = useToast()
const fileInputRef = useRef<HTMLInputElement>(null)
const [isUploading, setIsUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [signingDate, setSigningDate] = useState('')
const [isPrimary, setIsPrimary] = useState(true)
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
// Validate file type
const allowedTypes = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'image/jpeg',
'image/png',
'image/webp'
]
if (!allowedTypes.includes(file.type)) {
toast({
title: 'Ogiltig filtyp',
description: 'Tillåtna format: PDF, DOC, DOCX, JPG, PNG, WEBP',
variant: 'destructive',
})
return
}
// Check file size (10MB max)
if (file.size > 10 * 1024 * 1024) {
toast({
title: 'Filen är för stor',
description: 'Max filstorlek är 10MB',
variant: 'destructive',
})
return
}
setSelectedFile(file)
}
}
const handleUpload = async () => {
if (!selectedFile) return
setIsUploading(true)
setUploadProgress(0)
try {
const formData = new FormData()
formData.append('file', selectedFile)
if (signingDate) formData.append('signing_date', signingDate)
formData.append('is_primary', isPrimary.toString())
// Simulate progress for better UX
const progressInterval = setInterval(() => {
setUploadProgress(prev => Math.min(prev + 10, 90))
}, 200)
const response = await fetch(`/api/campaigns/${campaignId}/contracts`, {
method: 'POST',
body: formData,
})
clearInterval(progressInterval)
setUploadProgress(100)
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Upload failed')
}
toast({
title: 'Avtal uppladdat',
description: selectedFile.name,
})
// Reset form
setSelectedFile(null)
setSigningDate('')
setIsPrimary(true)
if (fileInputRef.current) fileInputRef.current.value = ''
onSuccess?.()
} catch (error) {
toast({
title: 'Uppladdning misslyckades',
description: error instanceof Error ? error.message : 'Något gick fel',
variant: 'destructive',
})
} finally {
setIsUploading(false)
setUploadProgress(0)
}
}
const clearSelection = () => {
setSelectedFile(null)
if (fileInputRef.current) fileInputRef.current.value = ''
}
return (
<div className="space-y-4">
{/* File input */}
<div>
<input
ref={fileInputRef}
type="file"
accept=".pdf,.doc,.docx,.jpg,.jpeg,.png,.webp"
onChange={handleFileSelect}
className="hidden"
/>
{selectedFile ? (
<div className="flex items-center gap-3 p-3 border rounded-lg bg-muted/50">
<FileText className="h-8 w-8 text-primary" />
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{selectedFile.name}</p>
<p className="text-sm text-muted-foreground">
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={clearSelection}
disabled={isUploading}
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<button
onClick={() => fileInputRef.current?.click()}
className="w-full p-8 border-2 border-dashed rounded-lg hover:border-primary hover:bg-muted/50 transition-colors"
>
<div className="flex flex-col items-center gap-2">
<Upload className="h-8 w-8 text-muted-foreground" />
<p className="font-medium">Klicka för att välja fil</p>
<p className="text-sm text-muted-foreground">
PDF, DOC, DOCX, JPG, PNG (max 10MB)
</p>
</div>
</button>
)}
</div>
{/* Options */}
{selectedFile && (
<>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="signing_date">Signeringsdatum</Label>
<Input
id="signing_date"
type="date"
value={signingDate}
onChange={(e) => setSigningDate(e.target.value)}
/>
</div>
<div className="flex items-end gap-2 pb-2">
<Switch
id="is_primary"
checked={isPrimary}
onCheckedChange={setIsPrimary}
/>
<Label htmlFor="is_primary" className="font-normal">
Huvudavtal
</Label>
</div>
</div>
{/* Upload progress */}
{isUploading && (
<div className="space-y-2">
<Progress value={uploadProgress} />
<p className="text-sm text-muted-foreground text-center">
Laddar upp... {uploadProgress}%
</p>
</div>
)}
{/* Upload button */}
<Button
onClick={handleUpload}
disabled={isUploading}
className="w-full"
>
{isUploading ? 'Laddar upp...' : 'Ladda upp avtal'}
</Button>
</>
)}
</div>
)
}
-247
View File
@@ -1,247 +0,0 @@
'use client'
import { Deliverable, DELIVERABLE_STATUS_LABELS, DELIVERABLE_TYPE_LABELS, PLATFORM_LABELS } from '@/types'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import {
CheckCircle,
Circle,
Clock,
Edit2,
Trash2,
ExternalLink,
RotateCcw,
Send
} from 'lucide-react'
const STATUS_STYLES: Record<string, string> = {
pending: 'bg-gray-100 text-gray-700 border-gray-200',
in_progress: 'bg-blue-100 text-blue-700 border-blue-200',
submitted: 'bg-purple-100 text-purple-700 border-purple-200',
revision: 'bg-orange-100 text-orange-700 border-orange-200',
approved: 'bg-green-100 text-green-700 border-green-200',
published: 'bg-emerald-100 text-emerald-700 border-emerald-200',
}
interface DeliverableCardProps {
deliverable: Deliverable
onStatusChange?: (deliverable: Deliverable, newStatus: string) => void
onEdit?: (deliverable: Deliverable) => void
onDelete?: (deliverable: Deliverable) => void
compact?: boolean
}
export function DeliverableCard({
deliverable,
onStatusChange,
onEdit,
onDelete,
compact = false
}: DeliverableCardProps) {
const isCompleted = deliverable.status === 'approved' || deliverable.status === 'published'
const isOverdue = deliverable.due_date && new Date(deliverable.due_date) < new Date() && !isCompleted
const formatDate = (date: string | null) => {
if (!date) return null
return new Date(date).toLocaleDateString('sv-SE', {
day: 'numeric',
month: 'short',
})
}
// Next logical status based on current
const getNextStatus = () => {
switch (deliverable.status) {
case 'pending': return 'in_progress'
case 'in_progress': return 'submitted'
case 'submitted': return 'approved'
case 'revision': return 'submitted'
case 'approved': return 'published'
default: return null
}
}
const nextStatus = getNextStatus()
if (compact) {
return (
<div
className={cn(
'flex items-center gap-2 p-2 rounded-lg border',
isOverdue ? 'border-destructive/50 bg-destructive/5' : 'border-border'
)}
>
<button
onClick={() => nextStatus && onStatusChange?.(deliverable, nextStatus)}
className="flex-shrink-0"
disabled={!onStatusChange || !nextStatus}
>
{isCompleted ? (
<CheckCircle className="h-4 w-4 text-success" />
) : (
<Circle className="h-4 w-4 text-muted-foreground" />
)}
</button>
<div className="flex-1 min-w-0">
<p className={cn(
'text-sm font-medium truncate',
isCompleted && 'line-through text-muted-foreground'
)}>
{deliverable.title}
</p>
</div>
<Badge variant="outline" className="text-xs">
{PLATFORM_LABELS[deliverable.platform]}
</Badge>
</div>
)
}
return (
<div
className={cn(
'p-4 rounded-lg border',
isOverdue ? 'border-destructive/50 bg-destructive/5' : 'border-border'
)}
>
<div className="flex items-start gap-3">
<button
onClick={() => nextStatus && onStatusChange?.(deliverable, nextStatus)}
className="mt-0.5 flex-shrink-0"
disabled={!onStatusChange || !nextStatus}
>
{isCompleted ? (
<CheckCircle className="h-5 w-5 text-success" />
) : isOverdue ? (
<Clock className="h-5 w-5 text-destructive" />
) : (
<Circle className="h-5 w-5 text-muted-foreground" />
)}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div>
<p className={cn(
'font-medium',
isCompleted && 'line-through text-muted-foreground'
)}>
{deliverable.title}
{deliverable.quantity > 1 && (
<span className="text-muted-foreground ml-1">×{deliverable.quantity}</span>
)}
</p>
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground">
<span>{PLATFORM_LABELS[deliverable.platform]}</span>
<span>·</span>
<span>{DELIVERABLE_TYPE_LABELS[deliverable.deliverable_type]}</span>
{deliverable.account_handle && (
<>
<span>·</span>
<span>{deliverable.account_handle}</span>
</>
)}
</div>
{deliverable.due_date && (
<p className={cn(
'text-sm mt-1',
isOverdue && !isCompleted ? 'text-destructive' : 'text-muted-foreground'
)}>
Deadline: {formatDate(deliverable.due_date)}
</p>
)}
</div>
<div className="flex flex-col items-end gap-1">
<Badge
variant="outline"
className={cn('text-xs', STATUS_STYLES[deliverable.status])}
>
{DELIVERABLE_STATUS_LABELS[deliverable.status]}
</Badge>
{isOverdue && !isCompleted && (
<Badge variant="destructive" className="text-xs">
Försenad
</Badge>
)}
</div>
</div>
{deliverable.description && (
<p className="text-sm text-muted-foreground mt-2">
{deliverable.description}
</p>
)}
{/* Timestamps */}
{(deliverable.submitted_at || deliverable.approved_at || deliverable.published_at) && (
<div className="flex flex-wrap gap-3 mt-2 text-xs text-muted-foreground">
{deliverable.submitted_at && (
<span>Inskickat: {formatDate(deliverable.submitted_at)}</span>
)}
{deliverable.approved_at && (
<span>Godkänt: {formatDate(deliverable.approved_at)}</span>
)}
{deliverable.published_at && (
<span>Publicerat: {formatDate(deliverable.published_at)}</span>
)}
</div>
)}
</div>
</div>
{/* Actions */}
{(onStatusChange || onEdit || onDelete) && (
<div className="flex justify-between items-center gap-1 mt-3 pt-3 border-t">
{/* Status progression buttons */}
<div className="flex gap-1">
{onStatusChange && nextStatus && (
<Button
variant="outline"
size="sm"
onClick={() => onStatusChange(deliverable, nextStatus)}
className="text-xs"
>
{nextStatus === 'in_progress' && <Clock className="h-3 w-3 mr-1" />}
{nextStatus === 'submitted' && <Send className="h-3 w-3 mr-1" />}
{nextStatus === 'approved' && <CheckCircle className="h-3 w-3 mr-1" />}
{nextStatus === 'published' && <ExternalLink className="h-3 w-3 mr-1" />}
{DELIVERABLE_STATUS_LABELS[nextStatus as keyof typeof DELIVERABLE_STATUS_LABELS]}
</Button>
)}
{onStatusChange && deliverable.status === 'submitted' && (
<Button
variant="outline"
size="sm"
onClick={() => onStatusChange(deliverable, 'revision')}
className="text-xs text-orange-600"
>
<RotateCcw className="h-3 w-3 mr-1" />
Revision
</Button>
)}
</div>
<div className="flex gap-1">
{onEdit && (
<Button variant="ghost" size="sm" onClick={() => onEdit(deliverable)}>
<Edit2 className="h-4 w-4" />
</Button>
)}
{onDelete && (
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(deliverable)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
)}
</div>
)
}
-251
View File
@@ -1,251 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
import type { Deliverable, DeliverableType, PlatformType, CreateDeliverableInput } from '@/types'
import { DELIVERABLE_TYPE_LABELS, PLATFORM_LABELS } from '@/types'
interface DeliverableFormProps {
open: boolean
onOpenChange: (open: boolean) => void
campaignId: string
initialData?: Partial<Deliverable>
onSuccess?: (deliverable: Deliverable) => void
}
export function DeliverableForm({
open,
onOpenChange,
campaignId,
initialData,
onSuccess
}: DeliverableFormProps) {
const { toast } = useToast()
const [isLoading, setIsLoading] = useState(false)
const [formData, setFormData] = useState<Omit<CreateDeliverableInput, 'campaign_id'>>({
title: '',
deliverable_type: 'video',
platform: 'instagram',
account_handle: '',
quantity: 1,
description: '',
due_date: '',
notes: '',
})
useEffect(() => {
if (open) {
setFormData({
title: initialData?.title || '',
deliverable_type: initialData?.deliverable_type || 'video',
platform: initialData?.platform || 'instagram',
account_handle: initialData?.account_handle || '',
quantity: initialData?.quantity || 1,
description: initialData?.description || '',
due_date: initialData?.due_date || '',
notes: initialData?.notes || '',
})
}
}, [open, initialData])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!formData.title) {
toast({ title: 'Titel krävs', variant: 'destructive' })
return
}
setIsLoading(true)
try {
const url = initialData?.id
? `/api/deliverables/${initialData.id}`
: `/api/campaigns/${campaignId}/deliverables`
const method = initialData?.id ? 'PATCH' : 'POST'
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...formData,
due_date: formData.due_date || null,
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to save deliverable')
}
const { data } = await response.json()
toast({
title: initialData?.id ? 'Leverabel uppdaterad' : 'Leverabel tillagd',
description: formData.title,
})
onOpenChange(false)
onSuccess?.(data)
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Något gick fel',
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
{initialData?.id ? 'Redigera leverabel' : 'Lägg till leverabel'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="title">Titel *</Label>
<Input
id="title"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="T.ex. Instagram Reel - Produktrecension"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="platform">Plattform *</Label>
<Select
value={formData.platform}
onValueChange={(v) => setFormData({ ...formData, platform: v as PlatformType })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(PLATFORM_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="deliverable_type">Typ *</Label>
<Select
value={formData.deliverable_type}
onValueChange={(v) => setFormData({ ...formData, deliverable_type: v as DeliverableType })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(DELIVERABLE_TYPE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="account_handle">Konto/Kanal</Label>
<Input
id="account_handle"
value={formData.account_handle || ''}
onChange={(e) => setFormData({ ...formData, account_handle: e.target.value })}
placeholder="@användarnamn"
/>
</div>
<div>
<Label htmlFor="quantity">Antal</Label>
<Input
id="quantity"
type="number"
min="1"
value={formData.quantity || 1}
onChange={(e) => setFormData({ ...formData, quantity: parseInt(e.target.value) || 1 })}
/>
</div>
</div>
<div>
<Label htmlFor="due_date">Deadline</Label>
<Input
id="due_date"
type="date"
value={formData.due_date || ''}
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
/>
<p className="text-xs text-muted-foreground mt-1">
En deadline skapas automatiskt i kalendern
</p>
</div>
<div>
<Label htmlFor="description">Beskrivning</Label>
<Textarea
id="description"
value={formData.description || ''}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Specifika instruktioner eller krav..."
rows={3}
/>
</div>
<div>
<Label htmlFor="notes">Interna anteckningar</Label>
<Textarea
id="notes"
value={formData.notes || ''}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
placeholder="Anteckningar..."
rows={2}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Avbryt
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Sparar...' : initialData?.id ? 'Spara' : 'Lägg till'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
-190
View File
@@ -1,190 +0,0 @@
'use client'
import { useState } from 'react'
import { Deliverable, DeliverableStatus, DELIVERABLE_STATUS_LABELS } from '@/types'
import { DeliverableCard } from './DeliverableCard'
import { DeliverableForm } from './DeliverableForm'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { Plus, ChevronDown, ChevronUp } from 'lucide-react'
import { cn } from '@/lib/utils'
interface DeliverableListProps {
campaignId: string
deliverables: Deliverable[]
onUpdate: () => void
}
export function DeliverableList({ campaignId, deliverables, onUpdate }: DeliverableListProps) {
const { toast } = useToast()
const [showCompleted, setShowCompleted] = useState(false)
const [formOpen, setFormOpen] = useState(false)
const [editingDeliverable, setEditingDeliverable] = useState<Deliverable | null>(null)
// Split deliverables by completion status
const activeDeliverables = deliverables.filter(d =>
!['approved', 'published'].includes(d.status)
)
const completedDeliverables = deliverables.filter(d =>
['approved', 'published'].includes(d.status)
)
const handleStatusChange = async (deliverable: Deliverable, newStatus: string) => {
try {
const response = await fetch(`/api/deliverables/${deliverable.id}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus }),
})
if (!response.ok) {
throw new Error('Failed to update status')
}
toast({
title: 'Status uppdaterad',
description: `${deliverable.title}: ${DELIVERABLE_STATUS_LABELS[newStatus as DeliverableStatus]}`,
})
onUpdate()
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte uppdatera status',
variant: 'destructive',
})
}
}
const handleDelete = async (deliverable: Deliverable) => {
if (!confirm(`Ta bort "${deliverable.title}"?`)) return
try {
const response = await fetch(`/api/deliverables/${deliverable.id}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Failed to delete')
}
toast({
title: 'Innehåll borttaget',
description: deliverable.title,
})
onUpdate()
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte ta bort innehåll',
variant: 'destructive',
})
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium">
Innehåll
<span className="text-muted-foreground ml-2">
({activeDeliverables.length} aktiva)
</span>
</h3>
<Button size="sm" onClick={() => setFormOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Lägg till
</Button>
</div>
{/* Active deliverables */}
{activeDeliverables.length > 0 ? (
<div className="space-y-3">
{activeDeliverables
.sort((a, b) => {
// Sort by due date, nulls last
if (!a.due_date && !b.due_date) return 0
if (!a.due_date) return 1
if (!b.due_date) return -1
return new Date(a.due_date).getTime() - new Date(b.due_date).getTime()
})
.map(deliverable => (
<DeliverableCard
key={deliverable.id}
deliverable={deliverable}
onStatusChange={handleStatusChange}
onEdit={(d) => {
setEditingDeliverable(d)
setFormOpen(true)
}}
onDelete={handleDelete}
/>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<p>Inget aktivt innehåll</p>
<Button
variant="outline"
size="sm"
className="mt-2"
onClick={() => setFormOpen(true)}
>
<Plus className="h-4 w-4 mr-1" />
Lägg till innehåll
</Button>
</div>
)}
{/* Completed deliverables */}
{completedDeliverables.length > 0 && (
<div className="pt-4 border-t">
<button
onClick={() => setShowCompleted(!showCompleted)}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
>
{showCompleted ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
Klara ({completedDeliverables.length})
</button>
{showCompleted && (
<div className="space-y-3 mt-3">
{completedDeliverables.map(deliverable => (
<DeliverableCard
key={deliverable.id}
deliverable={deliverable}
onEdit={(d) => {
setEditingDeliverable(d)
setFormOpen(true)
}}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
)}
{/* Form dialog */}
<DeliverableForm
open={formOpen}
onOpenChange={(open) => {
setFormOpen(open)
if (!open) setEditingDeliverable(null)
}}
campaignId={campaignId}
initialData={editingDeliverable || undefined}
onSuccess={() => {
setFormOpen(false)
setEditingDeliverable(null)
onUpdate()
}}
/>
</div>
)
}
-381
View File
@@ -1,381 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import type { Exclusivity, CreateExclusivityInput, ExclusivityConflict } from '@/types'
import { AlertTriangle, X, Plus } from 'lucide-react'
interface ExclusivityFormProps {
open: boolean
onOpenChange: (open: boolean) => void
campaignId: string
initialData?: Partial<Exclusivity>
onSuccess?: (exclusivity: Exclusivity) => void
}
// Common category suggestions
const CATEGORY_SUGGESTIONS = [
'Skönhet', 'Hudvård', 'Smink', 'Mode', 'Kläder', 'Accessoarer',
'Mat', 'Dryck', 'Kaffe', 'Energidryck', 'Snacks',
'Teknik', 'Mobil', 'Gaming', 'Elektronik',
'Träning', 'Gym', 'Kosttillskott', 'Sport',
'Resor', 'Hotell', 'Flyg',
'Bank', 'Finans', 'Försäkring',
'Bil', 'Bilar', 'Transport'
]
export function ExclusivityForm({
open,
onOpenChange,
campaignId,
initialData,
onSuccess
}: ExclusivityFormProps) {
const { toast } = useToast()
const [isLoading, setIsLoading] = useState(false)
const [isCheckingConflicts, setIsCheckingConflicts] = useState(false)
const [conflicts, setConflicts] = useState<ExclusivityConflict[]>([])
const [newCategory, setNewCategory] = useState('')
const [newBrand, setNewBrand] = useState('')
const [formData, setFormData] = useState<Omit<CreateExclusivityInput, 'campaign_id'>>({
categories: [],
excluded_brands: [],
start_date: '',
end_date: '',
notes: '',
})
useEffect(() => {
if (open) {
setFormData({
categories: initialData?.categories || [],
excluded_brands: initialData?.excluded_brands || [],
start_date: initialData?.start_date || '',
end_date: initialData?.end_date || '',
notes: initialData?.notes || '',
})
setConflicts([])
setNewCategory('')
setNewBrand('')
}
}, [open, initialData])
// Check for conflicts when dates or categories change
const checkConflicts = async () => {
if (!formData.categories.length || !formData.start_date || !formData.end_date) {
setConflicts([])
return
}
setIsCheckingConflicts(true)
try {
const params = new URLSearchParams({
categories: formData.categories.join(','),
start_date: formData.start_date,
end_date: formData.end_date,
exclude_campaign_id: campaignId,
})
const response = await fetch(`/api/exclusivities/conflicts?${params}`)
if (response.ok) {
const data = await response.json()
setConflicts(data.conflicts || [])
}
} catch (error) {
console.error('Failed to check conflicts:', error)
} finally {
setIsCheckingConflicts(false)
}
}
useEffect(() => {
const timer = setTimeout(checkConflicts, 500)
return () => clearTimeout(timer)
}, [formData.categories, formData.start_date, formData.end_date])
const addCategory = (category: string) => {
const trimmed = category.trim()
if (trimmed && !formData.categories.includes(trimmed)) {
setFormData({
...formData,
categories: [...formData.categories, trimmed]
})
}
setNewCategory('')
}
const removeCategory = (category: string) => {
setFormData({
...formData,
categories: formData.categories.filter(c => c !== category)
})
}
const addBrand = (brand: string) => {
const trimmed = brand.trim()
if (trimmed && !formData.excluded_brands?.includes(trimmed)) {
setFormData({
...formData,
excluded_brands: [...(formData.excluded_brands || []), trimmed]
})
}
setNewBrand('')
}
const removeBrand = (brand: string) => {
setFormData({
...formData,
excluded_brands: formData.excluded_brands?.filter(b => b !== brand) || []
})
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (formData.categories.length === 0) {
toast({ title: 'Minst en kategori krävs', variant: 'destructive' })
return
}
if (!formData.start_date || !formData.end_date) {
toast({ title: 'Start- och slutdatum krävs', variant: 'destructive' })
return
}
setIsLoading(true)
try {
const url = initialData?.id
? `/api/exclusivities/${initialData.id}`
: `/api/campaigns/${campaignId}/exclusivities`
const method = initialData?.id ? 'PATCH' : 'POST'
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to save exclusivity')
}
const result = await response.json()
// Show warning if there are conflicts
if (result.warning) {
toast({
title: 'Exklusivitet sparad med varning',
description: result.warning,
variant: 'default',
})
} else {
toast({
title: initialData?.id ? 'Exklusivitet uppdaterad' : 'Exklusivitet tillagd',
})
}
onOpenChange(false)
onSuccess?.(result.data)
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Något gick fel',
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{initialData?.id ? 'Redigera exklusivitet' : 'Lägg till exklusivitet'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Categories */}
<div>
<Label>Kategorier *</Label>
<div className="flex flex-wrap gap-2 mt-2 mb-2">
{formData.categories.map(category => (
<Badge key={category} variant="secondary" className="gap-1">
{category}
<button
type="button"
onClick={() => removeCategory(category)}
className="hover:text-destructive"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
<div className="flex gap-2">
<Input
value={newCategory}
onChange={(e) => setNewCategory(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addCategory(newCategory)
}
}}
placeholder="Lägg till kategori..."
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => addCategory(newCategory)}
>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="flex flex-wrap gap-1 mt-2">
{CATEGORY_SUGGESTIONS
.filter(s => !formData.categories.includes(s))
.slice(0, 8)
.map(suggestion => (
<button
key={suggestion}
type="button"
onClick={() => addCategory(suggestion)}
className="text-xs text-muted-foreground hover:text-foreground px-2 py-0.5 rounded border hover:border-primary"
>
+ {suggestion}
</button>
))}
</div>
</div>
{/* Excluded brands */}
<div>
<Label>Specifika varumärken som exkluderas (valfritt)</Label>
<div className="flex flex-wrap gap-2 mt-2 mb-2">
{formData.excluded_brands?.map(brand => (
<Badge key={brand} variant="outline" className="gap-1">
{brand}
<button
type="button"
onClick={() => removeBrand(brand)}
className="hover:text-destructive"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
<div className="flex gap-2">
<Input
value={newBrand}
onChange={(e) => setNewBrand(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addBrand(newBrand)
}
}}
placeholder="Lägg till varumärke..."
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => addBrand(newBrand)}
>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
{/* Date range */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="start_date">Startdatum *</Label>
<Input
id="start_date"
type="date"
value={formData.start_date}
onChange={(e) => setFormData({ ...formData, start_date: e.target.value })}
required
/>
</div>
<div>
<Label htmlFor="end_date">Slutdatum *</Label>
<Input
id="end_date"
type="date"
value={formData.end_date}
onChange={(e) => setFormData({ ...formData, end_date: e.target.value })}
required
/>
</div>
</div>
{/* Conflict warnings */}
{conflicts.length > 0 && (
<div className="p-3 bg-orange-50 border border-orange-200 rounded-lg">
<div className="flex items-start gap-2">
<AlertTriangle className="h-5 w-5 text-orange-600 flex-shrink-0" />
<div>
<p className="font-medium text-orange-800">
{conflicts.length} överlappande exklusivitet(er)
</p>
<ul className="text-sm text-orange-700 mt-1 space-y-1">
{conflicts.map((conflict, i) => (
<li key={i}>
<strong>{conflict.conflictingCampaign?.name || 'Okänd kampanj'}</strong>
: {conflict.overlappingCategories.join(', ')}
{' '}({conflict.overlapStart} - {conflict.overlapEnd})
</li>
))}
</ul>
</div>
</div>
</div>
)}
{/* Notes */}
<div>
<Label htmlFor="notes">Anteckningar</Label>
<Textarea
id="notes"
value={formData.notes || ''}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
placeholder="Detaljer om exklusiviteten..."
rows={2}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Avbryt
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Sparar...' : initialData?.id ? 'Spara' : 'Lägg till'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
-202
View File
@@ -1,202 +0,0 @@
'use client'
import { useState } from 'react'
import { Exclusivity } from '@/types'
import { ExclusivityForm } from './ExclusivityForm'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import { Plus, Trash2, Edit2, Calendar, AlertTriangle } from 'lucide-react'
import { cn } from '@/lib/utils'
interface ExclusivityListProps {
campaignId: string
exclusivities: Exclusivity[]
onUpdate: () => void
}
export function ExclusivityList({ campaignId, exclusivities, onUpdate }: ExclusivityListProps) {
const { toast } = useToast()
const [formOpen, setFormOpen] = useState(false)
const [editingExclusivity, setEditingExclusivity] = useState<Exclusivity | null>(null)
const today = new Date().toISOString().split('T')[0]
const handleDelete = async (exclusivity: Exclusivity) => {
if (!confirm('Ta bort denna exklusivitet?')) return
try {
const response = await fetch(`/api/exclusivities/${exclusivity.id}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Failed to delete')
}
toast({
title: 'Exklusivitet borttagen',
})
onUpdate()
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte ta bort exklusivitet',
variant: 'destructive',
})
}
}
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('sv-SE', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
const getStatus = (exclusivity: Exclusivity) => {
if (exclusivity.end_date < today) return 'expired'
if (exclusivity.start_date > today) return 'upcoming'
return 'active'
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium">
Exklusiviteter
<span className="text-muted-foreground ml-2">
({exclusivities.length})
</span>
</h3>
<Button size="sm" onClick={() => setFormOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Lägg till
</Button>
</div>
{exclusivities.length > 0 ? (
<div className="space-y-3">
{exclusivities
.sort((a, b) => new Date(a.start_date).getTime() - new Date(b.start_date).getTime())
.map(exclusivity => {
const status = getStatus(exclusivity)
return (
<div
key={exclusivity.id}
className={cn(
'p-4 rounded-lg border',
status === 'active' && 'border-orange-200 bg-orange-50',
status === 'upcoming' && 'border-blue-200 bg-blue-50',
status === 'expired' && 'border-gray-200 bg-gray-50 opacity-60'
)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
{status === 'active' && (
<Badge variant="outline" className="text-orange-700 border-orange-300 bg-orange-100">
<AlertTriangle className="h-3 w-3 mr-1" />
Aktiv
</Badge>
)}
{status === 'upcoming' && (
<Badge variant="outline" className="text-blue-700 border-blue-300 bg-blue-100">
Kommande
</Badge>
)}
{status === 'expired' && (
<Badge variant="outline" className="text-gray-500">
Avslutad
</Badge>
)}
</div>
<div className="flex flex-wrap gap-1.5 mb-2">
{exclusivity.categories.map(category => (
<Badge key={category} variant="secondary">
{category}
</Badge>
))}
</div>
{exclusivity.excluded_brands && exclusivity.excluded_brands.length > 0 && (
<div className="text-sm text-muted-foreground mb-2">
Exkluderade varumärken: {exclusivity.excluded_brands.join(', ')}
</div>
)}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar className="h-4 w-4" />
<span>
{formatDate(exclusivity.start_date)} - {formatDate(exclusivity.end_date)}
</span>
</div>
{exclusivity.notes && (
<p className="text-sm text-muted-foreground mt-2">
{exclusivity.notes}
</p>
)}
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingExclusivity(exclusivity)
setFormOpen(true)
}}
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(exclusivity)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
})}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<p>Inga exklusiviteter</p>
<Button
variant="outline"
size="sm"
className="mt-2"
onClick={() => setFormOpen(true)}
>
<Plus className="h-4 w-4 mr-1" />
Lägg till exklusivitet
</Button>
</div>
)}
{/* Form dialog */}
<ExclusivityForm
open={formOpen}
onOpenChange={(open) => {
setFormOpen(open)
if (!open) setEditingExclusivity(null)
}}
campaignId={campaignId}
initialData={editingExclusivity || undefined}
onSuccess={() => {
setFormOpen(false)
setEditingExclusivity(null)
onUpdate()
}}
/>
</div>
)
}
-14
View File
@@ -1,14 +0,0 @@
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'
+5 -5
View File
@@ -86,20 +86,20 @@ export function ChatPanel({ className }: ChatPanelProps) {
<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.
ekonomiska frågor för företagare.
</p>
<div className="mt-6 space-y-2 w-full max-w-xs">
<SuggestionButton
onClick={() => sendMessage('Hur fungerar momsen för YouTube-intäkter?')}
onClick={() => sendMessage('Hur fungerar momsen på mina fakturor?')}
disabled={isLoading}
>
Hur fungerar momsen för YouTube-intäkter?
Hur fungerar momsen på mina fakturor?
</SuggestionButton>
<SuggestionButton
onClick={() => sendMessage('Vad kan jag dra av som influencer?')}
onClick={() => sendMessage('Vad kan jag dra av som företagare?')}
disabled={isLoading}
>
Vad kan jag dra av som influencer?
Vad kan jag dra av som företagare?
</SuggestionButton>
<SuggestionButton
onClick={() => sendMessage('När måste jag momsregistrera mig?')}
@@ -1,100 +0,0 @@
'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
@@ -1,68 +0,0 @@
'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>
)
}
+2 -100
View File
@@ -14,8 +14,6 @@ import {
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,
@@ -23,15 +21,13 @@ import {
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'
import type { CompanySettings, EntityType, MileageEntry, SchablonavdragSettings, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
interface DashboardContentProps {
firstName?: string | null
@@ -48,56 +44,15 @@ interface DashboardContentProps {
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
@@ -111,11 +66,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
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 totalTaxableIncome = summary.ytd.net
const taxEstimate =
entityType === 'enskild_firma'
@@ -242,36 +193,6 @@ export default function DashboardContent({ firstName, settings, summary, onboard
)
}
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
@@ -280,7 +201,6 @@ export default function DashboardContent({ firstName, settings, summary, onboard
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' },
]
@@ -404,24 +324,6 @@ export default function DashboardContent({ firstName, settings, summary, onboard
/>
</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">
+13 -30
View File
@@ -16,18 +16,14 @@ import {
Settings,
LogOut,
Calculator,
Gift,
Upload,
Calendar,
Megaphone,
FileUp,
Camera,
Menu,
X,
HelpCircle,
ChevronDown,
ChevronUp,
Wallet,
} from 'lucide-react'
import type { EntityType } from '@/types'
@@ -48,19 +44,14 @@ interface NavItem {
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: '/invoices', label: 'Fakturor', icon: Receipt, group: 'finans' },
{ href: '/customers', label: 'Kunder', icon: Users, group: 'finans' },
{ href: '/transactions', label: 'Transaktioner', icon: ArrowLeftRight, group: 'finans' },
{ href: '/receipts', label: 'Kvitton', icon: Camera, group: 'finans' },
{ href: '/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: '/deductions', label: 'Avdrag', icon: Calculator, group: 'finans' },
{ href: '/bookkeeping', label: 'Bokföring', icon: BookOpen, group: 'finans' },
{ href: '/import', label: 'Importera', icon: Upload, group: 'övrigt' },
{ href: '/reports', label: 'Rapporter', icon: BarChart3, group: 'övrigt' },
{ href: '/help', label: 'Hjälp', icon: HelpCircle, group: 'övrigt' },
{ href: '/settings', label: 'Inställningar', icon: Settings, group: 'övrigt' },
]
@@ -101,20 +92,12 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
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 },
]
const mobileNavItems = [
{ href: '/', label: 'Översikt', icon: LayoutDashboard },
{ href: '/invoices', label: 'Fakturor', icon: Receipt },
{ href: '/receipts/scan', label: 'Skanna', icon: Camera, isScan: true },
{ href: '/transactions', label: 'Transaktioner', icon: ArrowLeftRight },
]
return (
<>
@@ -128,7 +111,7 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
{companyName}
</h1>
<p className="text-xs text-muted-foreground mt-0.5 tracking-wide uppercase">
{entityType === 'light' ? 'Egenanställd' : 'Ekonomi'}
Ekonomi
</p>
</div>
-79
View File
@@ -1,79 +0,0 @@
'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>
)
}
@@ -1,134 +0,0 @@
'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>
)
}
@@ -1,94 +0,0 @@
'use client'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { formatCurrency, formatDate } from '@/lib/utils'
import { ArrowRight, Banknote, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface RecentPayoutsCardProps {
entries: Array<{
id: string
date: string
description: string | null
gross_amount: number
net_amount: number
service_fee: number
pension_deduction: number
social_fees: number
income_tax_withheld: number
platform_fee: number
type: string
provider: string | null
}>
}
export default function RecentPayoutsCard({ entries }: RecentPayoutsCardProps) {
const displayEntries = entries.slice(0, 5)
const isEmpty = displayEntries.length === 0
return (
<Card className="h-full">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Banknote className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-lg">Senaste utbetalningar</CardTitle>
</div>
{!isEmpty && (
<Link
href="/shadow-ledger"
className="text-sm text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
>
Visa alla
<ArrowRight className="h-3.5 w-3.5" />
</Link>
)}
</div>
</CardHeader>
<CardContent>
{isEmpty ? (
<div className="text-center py-6">
<p className="text-sm text-muted-foreground mb-4">
Inga utbetalningar registrerade
</p>
<Link href="/shadow-ledger/new">
<Button variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" />
Logga utbetalning
</Button>
</Link>
</div>
) : (
<div className="space-y-3">
{displayEntries.map((entry) => (
<div
key={entry.id}
className="flex items-center justify-between py-2 border-b border-border/50 last:border-0"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">
{entry.description || entry.provider || 'Utbetalning'}
</p>
<p className="text-xs text-muted-foreground">
{formatDate(entry.date)}
</p>
</div>
<div className="text-right ml-4 flex-shrink-0">
<p className="text-sm tabular-nums font-medium">
{formatCurrency(entry.net_amount)}
</p>
{entry.gross_amount !== entry.net_amount && (
<p className="text-xs text-muted-foreground tabular-nums">
brutto {formatCurrency(entry.gross_amount)}
</p>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)
}
-107
View File
@@ -1,107 +0,0 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import { Shield, Info } from 'lucide-react'
interface SGIShieldWidgetProps {
daysSinceLastPayout: number | null
}
export default function SGIShieldWidget({
daysSinceLastPayout,
}: SGIShieldWidgetProps) {
const maxDays = 90
const days = daysSinceLastPayout ?? 0
const progress = daysSinceLastPayout !== null ? Math.min((days / maxDays) * 100, 100) : 0
// Determine status
let level: 'green' | 'yellow' | 'red'
let statusMessage: string | null = null
if (daysSinceLastPayout === null) {
level = 'green'
} else if (days < 30) {
level = 'green'
} else if (days < 75) {
level = 'yellow'
statusMessage = 'Varning: Logga uppdrag snart'
} else {
level = 'red'
statusMessage = 'Kritiskt: SGI riskerar nollst\u00e4llning'
}
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Shield
className={cn(
'h-5 w-5',
level === 'green' && 'text-emerald-500',
level === 'yellow' && 'text-amber-500',
level === 'red' && 'text-red-500'
)}
/>
<CardTitle className="text-lg">SGI-skydd</CardTitle>
</div>
{daysSinceLastPayout !== null && (
<span className="text-sm text-muted-foreground tabular-nums">
{days} dagar sedan senast
</span>
)}
</div>
</CardHeader>
<CardContent>
{daysSinceLastPayout === null ? (
<div className="flex items-start gap-3 py-2">
<Info className="h-4 w-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm text-muted-foreground">
Inga utbetalningar registrerade \u00e4nnu
</p>
<p className="text-xs text-muted-foreground mt-1">
SGI (sjukpenninggrundande inkomst) baseras p\u00e5 dina l\u00f6neutbetalningar.
Logga din f\u00f6rsta utbetalning f\u00f6r att b\u00f6rja sp\u00e5ra.
</p>
</div>
</div>
) : (
<>
{/* Progress bar */}
<div className="w-full h-3 rounded-full overflow-hidden bg-muted mb-2">
<div
className={cn(
'h-full rounded-full transition-all duration-500',
level === 'green' && 'bg-emerald-500',
level === 'yellow' && 'bg-amber-500',
level === 'red' && 'bg-red-500'
)}
style={{ width: `${progress}%` }}
/>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="tabular-nums">{days}/{maxDays}</span>
<span>N\u00e4sta varning: dag 30 {'\u00b7'} Kritisk: dag 75</span>
</div>
{/* Status message */}
{statusMessage && (
<div className="mt-3">
<Badge
variant={level === 'red' ? 'destructive' : 'warning'}
className="text-xs"
>
{statusMessage}
</Badge>
</div>
)}
</>
)}
</CardContent>
</Card>
)
}
-131
View File
@@ -1,131 +0,0 @@
'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>
)
}
+1 -1
View File
@@ -24,7 +24,7 @@ interface TourStep {
position?: 'top' | 'bottom' | 'center'
}
const TOUR_COMPLETED_KEY = 'influencer_dashboard_tour_completed'
const TOUR_COMPLETED_KEY = 'erp_dashboard_tour_completed'
const tourSteps: TourStep[] = [
{
+1 -1
View File
@@ -36,7 +36,7 @@ interface NewUserChecklistProps {
className?: string
}
const CHECKLIST_DISMISSED_KEY = 'influencer_checklist_dismissed'
const CHECKLIST_DISMISSED_KEY = 'erp_checklist_dismissed'
export default function NewUserChecklist({
hasCustomers,
+1 -7
View File
@@ -4,7 +4,7 @@ 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 { Loader2, ArrowRight, Building2, User, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { EntityType } from '@/types'
@@ -33,12 +33,6 @@ const entityOptions: {
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) {
@@ -78,7 +78,7 @@ export default function Step2CompanyDetails({
</Label>
<Input
id="company_name"
placeholder={isAB ? 'AB Företaget' : 'Alices Influencer-verksamhet'}
placeholder={isAB ? 'AB Företaget' : 'Alices Konsultverksamhet'}
{...register('company_name')}
/>
{errors.company_name && (
@@ -1,100 +0,0 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { 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>
)
}
@@ -1,526 +0,0 @@
'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>
)
}
@@ -159,7 +159,7 @@ export default function Step3TaxRegistration({
<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>
<p className="text-xs text-muted-foreground">Om din omsättning överstiger 80 000 kr per år behöver du momsregistrera dig.</p>
</div>
}
side="right"
-473
View File
@@ -1,473 +0,0 @@
'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
}
+2 -12
View File
@@ -3,13 +3,12 @@
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 { Flame, Receipt, CreditCard, Camera, ArrowRight } from 'lucide-react'
import type { ReceiptQueueSummary } from '@/types'
interface ReceiptDashboardProps {
summary: ReceiptQueueSummary
onScanReceipt: () => void
onRegisterProduct: () => void
onViewReceiptQueue: () => void
onViewTransactionQueue: () => void
}
@@ -17,7 +16,6 @@ interface ReceiptDashboardProps {
export default function ReceiptDashboard({
summary,
onScanReceipt,
onRegisterProduct,
onViewReceiptQueue,
onViewTransactionQueue,
}: ReceiptDashboardProps) {
@@ -29,7 +27,7 @@ export default function ReceiptDashboard({
return (
<div className="space-y-4">
{/* Quick actions */}
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3">
<Button
variant="default"
className="h-auto py-4 flex-col gap-2"
@@ -38,14 +36,6 @@ export default function ReceiptDashboard({
<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 */}
-1
View File
@@ -3,4 +3,3 @@ 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'
@@ -332,39 +332,6 @@ export function CalendarFeedSettings() {
/>
</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>
@@ -66,7 +66,6 @@ export function NotificationSettings({ onSettingsChange }: NotificationSettingsP
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',
@@ -290,22 +289,6 @@ export function NotificationSettings({ onSettingsChange }: NotificationSettingsP
/>
</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>
@@ -1,139 +0,0 @@
'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>
)
}
@@ -1,388 +0,0 @@
'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>
)
}
@@ -1,199 +0,0 @@
'use client'
import { useState } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { formatCurrency } from '@/lib/utils'
import { cn } from '@/lib/utils'
import { ChevronDown, ChevronUp, Trash2 } from 'lucide-react'
import PayoutWaterfall from '@/components/shadow-ledger/PayoutWaterfall'
import type { ShadowLedgerEntry, ShadowLedgerEntryType } from '@/types'
interface ShadowLedgerListProps {
entries: ShadowLedgerEntry[]
onDelete?: (id: string) => void
isDeleting?: boolean
}
const TYPE_LABELS: Record<ShadowLedgerEntryType, string> = {
payout: 'Utbetalning',
gift: 'G\u00e5va',
expense: 'Utgift',
hobby_income: 'Hobbyinkomst',
hobby_expense: 'Hobbyutgift',
}
const TYPE_BADGE_VARIANT: Record<
ShadowLedgerEntryType,
'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
> = {
payout: 'default',
gift: 'warning',
expense: 'destructive',
hobby_income: 'success',
hobby_expense: 'secondary',
}
function formatDateShort(dateStr: string): string {
const d = new Date(dateStr)
return d.toLocaleDateString('sv-SE', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
export default function ShadowLedgerList({
entries,
onDelete,
isDeleting,
}: ShadowLedgerListProps) {
const [expandedId, setExpandedId] = useState<string | null>(null)
const toggle = (id: string) => {
setExpandedId((prev) => (prev === id ? null : id))
}
if (entries.length === 0) {
return (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">Inga poster hittades.</p>
</CardContent>
</Card>
)
}
return (
<div className="space-y-3">
{entries.map((entry) => {
const isExpanded = expandedId === entry.id
const isPayout = entry.type === 'payout'
return (
<Card key={entry.id}>
<CardContent className="p-0">
{/* Collapsed row */}
<button
type="button"
className="flex w-full items-center gap-3 p-4 text-left transition-colors hover:bg-muted/30"
onClick={() => toggle(entry.id)}
>
{/* Date */}
<span className="shrink-0 text-sm text-muted-foreground w-24">
{formatDateShort(entry.date)}
</span>
{/* Type badge */}
<Badge
variant={TYPE_BADGE_VARIANT[entry.type]}
className="shrink-0"
>
{TYPE_LABELS[entry.type]}
</Badge>
{/* Description */}
<span className="min-w-0 flex-1 truncate text-sm">
{entry.description || entry.provider || '\u2014'}
</span>
{/* Amounts */}
<span className="shrink-0 text-sm font-medium tabular-nums">
{formatCurrency(entry.gross_amount)}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
&rarr;
</span>
<span
className={cn(
'shrink-0 text-sm font-semibold tabular-nums',
entry.net_amount < entry.gross_amount
? 'text-sky-600'
: 'text-foreground'
)}
>
{formatCurrency(entry.net_amount)}
</span>
{/* Expand icon */}
{isExpanded ? (
<ChevronUp className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
</button>
{/* Expanded content */}
{isExpanded && (
<div className="border-t px-4 pb-4 pt-3 space-y-4">
{/* Waterfall (only for payouts with gross > 0) */}
{isPayout && entry.gross_amount > 0 && (
<PayoutWaterfall
grossAmount={entry.gross_amount}
platformFee={entry.platform_fee}
serviceFee={entry.service_fee}
pensionDeduction={entry.pension_deduction}
socialFees={entry.social_fees}
incomeTaxWithheld={entry.income_tax_withheld}
netAmount={entry.net_amount}
/>
)}
{/* Metadata grid */}
<div className="grid gap-x-6 gap-y-2 text-sm sm:grid-cols-2">
{entry.provider && (
<div>
<span className="text-muted-foreground">Leverant\u00f6r: </span>
<span className="font-medium">{entry.provider}</span>
</div>
)}
{entry.campaign_id && (
<div>
<span className="text-muted-foreground">Kampanj: </span>
<span className="font-medium">{entry.campaign_id}</span>
</div>
)}
{entry.virtual_tax_debt > 0 && (
<div>
<span className="text-muted-foreground">
Virtuell skatteskuld:{' '}
</span>
<span className="font-medium text-destructive">
{formatCurrency(entry.virtual_tax_debt)}
</span>
</div>
)}
<div>
<span className="text-muted-foreground">K\u00e4lla: </span>
<span className="font-medium capitalize">{entry.source}</span>
</div>
</div>
{/* Delete button */}
{onDelete && (
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
disabled={isDeleting}
onClick={(e) => {
e.stopPropagation()
onDelete(entry.id)
}}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Ta bort
</Button>
</div>
)}
</div>
)}
</CardContent>
</Card>
)
})}
</div>
)
}
-225
View File
@@ -1,225 +0,0 @@
'use client'
import { useState } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import { TIKTOK_STATUS_LABELS } from '@/types'
import type { TikTokAccount } from '@/types'
import { formatDate } from '@/lib/utils'
import {
Loader2,
RefreshCw,
Trash2,
ExternalLink,
AlertTriangle,
} from 'lucide-react'
interface TikTokAccountCardProps {
account: TikTokAccount
onDisconnect?: () => void
onSync?: () => void
}
export function TikTokAccountCard({
account,
onDisconnect,
onSync,
}: TikTokAccountCardProps) {
const [isSyncing, setIsSyncing] = useState(false)
const [isDisconnecting, setIsDisconnecting] = useState(false)
const { toast } = useToast()
const handleSync = async () => {
setIsSyncing(true)
try {
const response = await fetch('/api/tiktok/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account_id: account.id, sync_type: 'full' }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Synkronisering misslyckades')
}
toast({
title: 'Synkronisering klar',
description: `${data.videos_synced} videor synkade, ${data.new_videos} nya`,
})
onSync?.()
} catch (error) {
toast({
title: 'Synkronisering misslyckades',
description: error instanceof Error ? error.message : 'Ett fel uppstod',
variant: 'destructive',
})
}
setIsSyncing(false)
}
const handleDisconnect = async () => {
if (!confirm('Vill du koppla bort detta TikTok-konto?')) {
return
}
setIsDisconnecting(true)
try {
const response = await fetch('/api/tiktok/disconnect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account_id: account.id }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Kunde inte koppla bort')
}
toast({
title: 'Konto bortkopplat',
description: 'TikTok-kontot har kopplats bort',
})
onDisconnect?.()
} catch (error) {
toast({
title: 'Kunde inte koppla bort',
description: error instanceof Error ? error.message : 'Ett fel uppstod',
variant: 'destructive',
})
setIsDisconnecting(false)
}
}
const getStatusVariant = (status: TikTokAccount['status']) => {
switch (status) {
case 'active':
return 'default'
case 'expired':
case 'error':
return 'destructive'
case 'revoked':
return 'secondary'
default:
return 'outline'
}
}
const tokenExpiresAt = new Date(account.token_expires_at)
const isExpiringSoon = tokenExpiresAt.getTime() - Date.now() < 24 * 60 * 60 * 1000
return (
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-4">
{/* Avatar */}
{account.avatar_url ? (
<img
src={account.avatar_url}
alt={account.display_name || account.username}
className="h-12 w-12 rounded-full"
/>
) : (
<div className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
<TikTokIcon className="h-6 w-6 text-primary" />
</div>
)}
{/* Account info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium truncate">
{account.display_name || account.username}
</p>
<Badge variant={getStatusVariant(account.status)}>
{TIKTOK_STATUS_LABELS[account.status]}
</Badge>
</div>
<p className="text-sm text-muted-foreground">@{account.username}</p>
<div className="flex items-center gap-4 mt-1 text-xs text-muted-foreground">
{account.last_synced_at && (
<span>Synkad: {formatDate(account.last_synced_at)}</span>
)}
{isExpiringSoon && account.status === 'active' && (
<span className="flex items-center gap-1 text-warning-foreground">
<AlertTriangle className="h-3 w-3" />
Token går ut snart
</span>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
asChild
>
<a
href={`https://www.tiktok.com/@${account.username}`}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-4 w-4" />
</a>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleSync}
disabled={isSyncing || account.status !== 'active'}
>
{isSyncing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleDisconnect}
disabled={isDisconnecting}
>
{isDisconnecting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4 text-destructive" />
)}
</Button>
</div>
</div>
{/* Error message */}
{account.last_error && (
<div className="mt-3 p-2 bg-destructive/10 rounded text-sm text-destructive">
{account.last_error}
</div>
)}
</CardContent>
</Card>
)
}
function TikTokIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z" />
</svg>
)
}
-84
View File
@@ -1,84 +0,0 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Loader2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
interface TikTokConnectButtonProps {
disabled?: boolean
variant?: 'default' | 'outline' | 'ghost'
size?: 'default' | 'sm' | 'lg'
className?: string
}
export function TikTokConnectButton({
disabled = false,
variant = 'default',
size = 'default',
className,
}: TikTokConnectButtonProps) {
const [isConnecting, setIsConnecting] = useState(false)
const { toast } = useToast()
const handleConnect = async () => {
setIsConnecting(true)
try {
const response = await fetch('/api/tiktok/connect', {
method: 'POST',
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to start connection')
}
// Redirect to TikTok authorization
window.location.href = data.authorization_url
} catch (error) {
toast({
title: 'Anslutning misslyckades',
description: error instanceof Error ? error.message : 'Ett fel uppstod',
variant: 'destructive',
})
setIsConnecting(false)
}
}
return (
<Button
onClick={handleConnect}
disabled={disabled || isConnecting}
variant={variant}
size={size}
className={className}
>
{isConnecting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Ansluter...
</>
) : (
<>
<TikTokIcon className="mr-2 h-4 w-4" />
Koppla TikTok
</>
)}
</Button>
)
}
function TikTokIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z" />
</svg>
)
}
-174
View File
@@ -1,174 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Area,
AreaChart,
} from 'recharts'
import { cn } from '@/lib/utils'
interface TikTokGrowthChartProps {
accountId: string
className?: string
}
type Period = '7d' | '30d' | '90d' | '1y'
interface ChartData {
date: string
followers: number
change: number | null
}
export function TikTokGrowthChart({ accountId, className }: TikTokGrowthChartProps) {
const [period, setPeriod] = useState<Period>('30d')
const [data, setData] = useState<ChartData[]>([])
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
fetchData()
}, [accountId, period])
const fetchData = async () => {
setIsLoading(true)
try {
const days = period === '7d' ? 7 : period === '30d' ? 30 : period === '90d' ? 90 : 365
const response = await fetch(
`/api/tiktok/stats?account_id=${accountId}&days=${days}`
)
const result = await response.json()
setData(result.history || [])
} catch (error) {
console.error('Failed to fetch growth data:', error)
}
setIsLoading(false)
}
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
if (period === '7d') {
return date.toLocaleDateString('sv-SE', { weekday: 'short' })
}
if (period === '30d') {
return date.toLocaleDateString('sv-SE', { day: 'numeric', month: 'short' })
}
return date.toLocaleDateString('sv-SE', { day: 'numeric', month: 'short' })
}
const formatFollowers = (value: number) => {
if (value >= 1000000) {
return (value / 1000000).toFixed(1) + 'M'
}
if (value >= 1000) {
return (value / 1000).toFixed(1) + 'K'
}
return value.toString()
}
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
return (
<div className="bg-popover border rounded-lg shadow-lg p-3">
<p className="text-sm font-medium">{label}</p>
<p className="text-sm text-muted-foreground">
Följare: {payload[0].value.toLocaleString('sv-SE')}
</p>
{payload[0].payload.change !== null && (
<p className={cn(
'text-sm',
payload[0].payload.change >= 0 ? 'text-success' : 'text-destructive'
)}>
{payload[0].payload.change >= 0 ? '+' : ''}
{payload[0].payload.change.toLocaleString('sv-SE')} från föregående dag
</p>
)}
</div>
)
}
return null
}
const periods: { value: Period; label: string }[] = [
{ value: '7d', label: '7 dagar' },
{ value: '30d', label: '30 dagar' },
{ value: '90d', label: '90 dagar' },
{ value: '1y', label: '1 år' },
]
return (
<Card className={className}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">Följartillväxt</CardTitle>
<div className="flex gap-1">
{periods.map((p) => (
<Button
key={p.value}
variant={period === p.value ? 'default' : 'ghost'}
size="sm"
onClick={() => setPeriod(p.value)}
className="text-xs"
>
{p.label}
</Button>
))}
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="h-64 flex items-center justify-center">
<p className="text-sm text-muted-foreground">Laddar...</p>
</div>
) : data.length === 0 ? (
<div className="h-64 flex items-center justify-center">
<p className="text-sm text-muted-foreground">Ingen data tillgänglig</p>
</div>
) : (
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="followerGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(var(--primary))" stopOpacity={0.3} />
<stop offset="95%" stopColor="hsl(var(--primary))" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis
dataKey="date"
tickFormatter={formatDate}
tick={{ fontSize: 12 }}
className="text-muted-foreground"
/>
<YAxis
tickFormatter={formatFollowers}
tick={{ fontSize: 12 }}
className="text-muted-foreground"
width={50}
/>
<Tooltip content={<CustomTooltip />} />
<Area
type="monotone"
dataKey="followers"
stroke="hsl(var(--primary))"
strokeWidth={2}
fill="url(#followerGradient)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
)
}
-142
View File
@@ -1,142 +0,0 @@
'use client'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import Link from 'next/link'
import type { TikTokCampaignROI } from '@/types'
import { formatCurrency } from '@/lib/utils'
interface TikTokROITableProps {
campaigns: TikTokCampaignROI[]
className?: string
}
export function TikTokROITable({ campaigns, className }: TikTokROITableProps) {
const formatNumber = (num: number) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
}
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return num.toLocaleString('sv-SE')
}
const formatCPV = (cpv: number) => {
if (cpv === 0) return '-'
if (cpv < 0.01) return '< 0.01 kr'
return formatCurrency(cpv)
}
const getROIBadge = (cpv: number) => {
if (cpv === 0) return null
if (cpv < 0.1) return <Badge variant="default">Utmärkt</Badge>
if (cpv < 0.5) return <Badge variant="secondary">Bra</Badge>
if (cpv < 1) return <Badge variant="outline">OK</Badge>
return <Badge variant="destructive">Hög kostnad</Badge>
}
if (campaigns.length === 0) {
return (
<div className="text-center py-8">
<p className="text-muted-foreground">
Inga samarbeten med kopplade TikTok-videor
</p>
</div>
)
}
return (
<div className={className}>
<Table>
<TableHeader>
<TableRow>
<TableHead>Samarbete</TableHead>
<TableHead className="text-right">Videor</TableHead>
<TableHead className="text-right">Visningar</TableHead>
<TableHead className="text-right">Engagemang</TableHead>
<TableHead className="text-right">Kostnad</TableHead>
<TableHead className="text-right">CPV</TableHead>
<TableHead className="text-right">CPE</TableHead>
<TableHead>ROI</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{campaigns.map(campaign => (
<TableRow key={campaign.campaignId}>
<TableCell>
<Link
href={`/campaigns/${campaign.campaignId}`}
className="font-medium hover:underline"
>
{campaign.campaignName}
</Link>
</TableCell>
<TableCell className="text-right">
{campaign.videos.length}
</TableCell>
<TableCell className="text-right">
{formatNumber(campaign.totalViews)}
</TableCell>
<TableCell className="text-right">
{formatNumber(campaign.totalEngagements)}
</TableCell>
<TableCell className="text-right">
{formatCurrency(campaign.totalCost)}
</TableCell>
<TableCell className="text-right">
{formatCPV(campaign.costPerView)}
</TableCell>
<TableCell className="text-right">
{formatCPV(campaign.costPerEngagement)}
</TableCell>
<TableCell>
{getROIBadge(campaign.costPerView)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* Summary */}
<div className="mt-4 p-4 bg-secondary/30 rounded-lg">
<div className="grid grid-cols-4 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Totala visningar</p>
<p className="font-medium">
{formatNumber(campaigns.reduce((sum, c) => sum + c.totalViews, 0))}
</p>
</div>
<div>
<p className="text-muted-foreground">Totalt engagemang</p>
<p className="font-medium">
{formatNumber(campaigns.reduce((sum, c) => sum + c.totalEngagements, 0))}
</p>
</div>
<div>
<p className="text-muted-foreground">Total kostnad</p>
<p className="font-medium">
{formatCurrency(campaigns.reduce((sum, c) => sum + c.totalCost, 0))}
</p>
</div>
<div>
<p className="text-muted-foreground">Genomsnittlig CPV</p>
<p className="font-medium">
{formatCPV(
campaigns.reduce((sum, c) => sum + c.totalCost, 0) /
Math.max(1, campaigns.reduce((sum, c) => sum + c.totalViews, 0))
)}
</p>
</div>
</div>
</div>
</div>
)
}
-162
View File
@@ -1,162 +0,0 @@
'use client'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { formatDate } from '@/lib/utils'
import type { TikTokStatsSummary } from '@/types'
import {
Users,
TrendingUp,
TrendingDown,
Heart,
Video,
ArrowRight,
RefreshCw,
} from 'lucide-react'
import { cn } from '@/lib/utils'
interface TikTokStatsWidgetProps {
stats: TikTokStatsSummary | null
onSync?: () => void
isSyncing?: boolean
className?: string
}
export function TikTokStatsWidget({
stats,
onSync,
isSyncing,
className,
}: TikTokStatsWidgetProps) {
if (!stats) {
return null
}
const formatNumber = (num: number) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
}
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return num.toLocaleString('sv-SE')
}
const formatChange = (change: number) => {
const prefix = change >= 0 ? '+' : ''
return prefix + formatNumber(change)
}
return (
<Card className={className}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<TikTokIcon className="h-4 w-4" />
TikTok
</CardTitle>
<div className="flex items-center gap-2">
{onSync && (
<Button
variant="ghost"
size="sm"
onClick={onSync}
disabled={isSyncing}
>
<RefreshCw className={cn('h-4 w-4', isSyncing && 'animate-spin')} />
</Button>
)}
<Link href="/analytics">
<Button variant="ghost" size="sm" className="text-xs">
Detaljer
<ArrowRight className="h-3 w-3 ml-1" />
</Button>
</Link>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Main follower count */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Följare</span>
</div>
<div className="text-right">
<p className="font-display text-2xl font-medium">
{formatNumber(stats.currentFollowers)}
</p>
</div>
</div>
{/* Growth indicators */}
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center gap-2">
<div className={cn(
'flex items-center gap-1 text-sm',
stats.followerChange7d >= 0 ? 'text-success' : 'text-destructive'
)}>
{stats.followerChange7d >= 0 ? (
<TrendingUp className="h-4 w-4" />
) : (
<TrendingDown className="h-4 w-4" />
)}
{formatChange(stats.followerChange7d)}
</div>
<span className="text-xs text-muted-foreground">7d</span>
</div>
<div className="flex items-center gap-2">
<div className={cn(
'flex items-center gap-1 text-sm',
stats.followerChange30d >= 0 ? 'text-success' : 'text-destructive'
)}>
{stats.followerChange30d >= 0 ? (
<TrendingUp className="h-4 w-4" />
) : (
<TrendingDown className="h-4 w-4" />
)}
{formatChange(stats.followerChange30d)}
</div>
<span className="text-xs text-muted-foreground">30d</span>
</div>
</div>
{/* Secondary stats */}
<div className="flex items-center justify-between pt-2 border-t">
<div className="flex items-center gap-2">
<Heart className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{formatNumber(stats.totalLikes)}</span>
</div>
<div className="flex items-center gap-2">
<Video className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{stats.totalVideos} videor</span>
</div>
<div className="text-sm text-muted-foreground">
{stats.engagementRate.toFixed(1)}% eng.
</div>
</div>
{/* Last synced */}
{stats.lastSynced && (
<p className="text-xs text-muted-foreground text-center">
Senast uppdaterad: {formatDate(stats.lastSynced)}
</p>
)}
</CardContent>
</Card>
)
}
function TikTokIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z" />
</svg>
)
}
-132
View File
@@ -1,132 +0,0 @@
'use client'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import type { TikTokVideo } from '@/types'
import { formatDate } from '@/lib/utils'
import {
Eye,
Heart,
MessageCircle,
Share2,
ExternalLink,
Link as LinkIcon,
} from 'lucide-react'
interface TikTokVideoCardProps {
video: TikTokVideo
onLinkClick?: (video: TikTokVideo) => void
showLinkButton?: boolean
}
export function TikTokVideoCard({
video,
onLinkClick,
showLinkButton = true,
}: TikTokVideoCardProps) {
const formatNumber = (num: number) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
}
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return num.toLocaleString('sv-SE')
}
const engagementRate = video.view_count > 0
? ((video.like_count + video.comment_count + video.share_count) / video.view_count) * 100
: 0
return (
<Card className="overflow-hidden">
<div className="flex">
{/* Thumbnail */}
{video.cover_image_url && (
<div className="relative w-24 h-36 flex-shrink-0">
<img
src={video.cover_image_url}
alt={video.title || 'Video'}
className="w-full h-full object-cover"
/>
{video.duration && (
<div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded">
{Math.floor(video.duration / 60)}:{(video.duration % 60).toString().padStart(2, '0')}
</div>
)}
</div>
)}
<CardContent className="flex-1 p-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="font-medium text-sm line-clamp-2">
{video.title || 'Untitled video'}
</p>
{video.published_at && (
<p className="text-xs text-muted-foreground mt-1">
{formatDate(video.published_at)}
</p>
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{video.share_url && (
<Button variant="ghost" size="sm" asChild>
<a
href={video.share_url}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-4 w-4" />
</a>
</Button>
)}
{showLinkButton && onLinkClick && (
<Button
variant="ghost"
size="sm"
onClick={() => onLinkClick(video)}
>
<LinkIcon className="h-4 w-4" />
</Button>
)}
</div>
</div>
{/* Metrics */}
<div className="flex items-center gap-3 mt-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Eye className="h-3 w-3" />
{formatNumber(video.view_count)}
</div>
<div className="flex items-center gap-1">
<Heart className="h-3 w-3" />
{formatNumber(video.like_count)}
</div>
<div className="flex items-center gap-1">
<MessageCircle className="h-3 w-3" />
{formatNumber(video.comment_count)}
</div>
<div className="flex items-center gap-1">
<Share2 className="h-3 w-3" />
{formatNumber(video.share_count)}
</div>
</div>
{/* Tags */}
<div className="flex items-center gap-2 mt-2">
<Badge variant="secondary" className="text-xs">
{engagementRate.toFixed(1)}% eng.
</Badge>
{video.campaign_id && (
<Badge variant="default" className="text-xs">
Kopplad till samarbete
</Badge>
)}
</div>
</CardContent>
</div>
</Card>
)
}
-117
View File
@@ -1,117 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { TikTokVideoCard } from './TikTokVideoCard'
import type { TikTokVideo } from '@/types'
import { Loader2 } from 'lucide-react'
interface TikTokVideoListProps {
accountId?: string
campaignId?: string
unlinkedOnly?: boolean
onLinkClick?: (video: TikTokVideo) => void
showLinkButton?: boolean
limit?: number
}
export function TikTokVideoList({
accountId,
campaignId,
unlinkedOnly = false,
onLinkClick,
showLinkButton = true,
limit = 10,
}: TikTokVideoListProps) {
const [videos, setVideos] = useState<TikTokVideo[]>([])
const [total, setTotal] = useState(0)
const [offset, setOffset] = useState(0)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
fetchVideos()
}, [accountId, campaignId, unlinkedOnly, offset])
const fetchVideos = async () => {
setIsLoading(true)
try {
const params = new URLSearchParams({
limit: limit.toString(),
offset: offset.toString(),
})
if (accountId) params.append('account_id', accountId)
if (campaignId) params.append('campaign_id', campaignId)
if (unlinkedOnly) params.append('unlinked_only', 'true')
const response = await fetch(`/api/tiktok/videos?${params.toString()}`)
const data = await response.json()
if (offset === 0) {
setVideos(data.videos || [])
} else {
setVideos(prev => [...prev, ...(data.videos || [])])
}
setTotal(data.total || 0)
} catch (error) {
console.error('Failed to fetch videos:', error)
}
setIsLoading(false)
}
const loadMore = () => {
setOffset(prev => prev + limit)
}
const hasMore = videos.length < total
if (isLoading && videos.length === 0) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)
}
if (videos.length === 0) {
return (
<div className="text-center py-8">
<p className="text-muted-foreground">Inga videor hittades</p>
</div>
)
}
return (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
{videos.map(video => (
<TikTokVideoCard
key={video.id}
video={video}
onLinkClick={onLinkClick}
showLinkButton={showLinkButton}
/>
))}
</div>
{hasMore && (
<div className="flex justify-center">
<Button
variant="outline"
onClick={loadMore}
disabled={isLoading}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Laddar...
</>
) : (
`Visa fler (${videos.length} av ${total})`
)}
</Button>
</div>
)}
</div>
)
}
-249
View File
@@ -1,249 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
import type { TikTokVideo, Campaign, Deliverable } from '@/types'
import { Loader2 } from 'lucide-react'
interface VideoLinkModalProps {
video: TikTokVideo | null
isOpen: boolean
onClose: () => void
onSuccess?: () => void
}
export function VideoLinkModal({
video,
isOpen,
onClose,
onSuccess,
}: VideoLinkModalProps) {
const [campaigns, setCampaigns] = useState<Campaign[]>([])
const [deliverables, setDeliverables] = useState<Deliverable[]>([])
const [selectedCampaign, setSelectedCampaign] = useState<string>('')
const [selectedDeliverable, setSelectedDeliverable] = useState<string>('')
const [isLoading, setIsLoading] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const { toast } = useToast()
useEffect(() => {
if (isOpen) {
fetchCampaigns()
}
}, [isOpen])
useEffect(() => {
if (selectedCampaign) {
fetchDeliverables(selectedCampaign)
} else {
setDeliverables([])
setSelectedDeliverable('')
}
}, [selectedCampaign])
const fetchCampaigns = async () => {
setIsLoading(true)
try {
const response = await fetch('/api/campaigns')
const data = await response.json()
setCampaigns(data.campaigns || [])
} catch (error) {
console.error('Failed to fetch campaigns:', error)
}
setIsLoading(false)
}
const fetchDeliverables = async (campaignId: string) => {
try {
const response = await fetch(`/api/campaigns/${campaignId}/deliverables`)
const data = await response.json()
setDeliverables(data.deliverables || [])
} catch (error) {
console.error('Failed to fetch deliverables:', error)
}
}
const handleSave = async () => {
if (!video || !selectedCampaign) return
setIsSaving(true)
try {
const response = await fetch(`/api/tiktok/videos/${video.id}/link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
campaign_id: selectedCampaign,
deliverable_id: selectedDeliverable || undefined,
}),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to link video')
}
toast({
title: 'Video kopplad',
description: 'Videon har kopplats till samarbetet',
})
onSuccess?.()
onClose()
} catch (error) {
toast({
title: 'Kunde inte koppla video',
description: error instanceof Error ? error.message : 'Ett fel uppstod',
variant: 'destructive',
})
}
setIsSaving(false)
}
const handleUnlink = async () => {
if (!video) return
setIsSaving(true)
try {
const response = await fetch(`/api/tiktok/videos/${video.id}/link`, {
method: 'DELETE',
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to unlink video')
}
toast({
title: 'Koppling borttagen',
description: 'Videon har kopplats bort från samarbetet',
})
onSuccess?.()
onClose()
} catch (error) {
toast({
title: 'Kunde inte ta bort koppling',
description: error instanceof Error ? error.message : 'Ett fel uppstod',
variant: 'destructive',
})
}
setIsSaving(false)
}
const handleClose = () => {
setSelectedCampaign('')
setSelectedDeliverable('')
onClose()
}
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Koppla video till samarbete</DialogTitle>
<DialogDescription>
{video?.title || 'Välj ett samarbete att koppla denna video till för ROI-spårning'}
</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="campaign">Samarbete</Label>
<Select
value={selectedCampaign}
onValueChange={setSelectedCampaign}
>
<SelectTrigger id="campaign">
<SelectValue placeholder="Välj samarbete" />
</SelectTrigger>
<SelectContent>
{campaigns.map(campaign => (
<SelectItem key={campaign.id} value={campaign.id}>
{campaign.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{deliverables.length > 0 && (
<div className="space-y-2">
<Label htmlFor="deliverable">Leverabel (valfritt)</Label>
<Select
value={selectedDeliverable}
onValueChange={setSelectedDeliverable}
>
<SelectTrigger id="deliverable">
<SelectValue placeholder="Välj leverabel" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Ingen specifik leverabel</SelectItem>
{deliverables.map(deliverable => (
<SelectItem key={deliverable.id} value={deliverable.id}>
{deliverable.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
)}
<DialogFooter>
{video?.campaign_id && (
<Button
variant="destructive"
onClick={handleUnlink}
disabled={isSaving}
>
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Ta bort koppling'
)}
</Button>
)}
<Button variant="outline" onClick={handleClose}>
Avbryt
</Button>
<Button
onClick={handleSave}
disabled={!selectedCampaign || isSaving}
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
'Koppla'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
-8
View File
@@ -1,8 +0,0 @@
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'
@@ -39,8 +39,6 @@ const expenseCategories: { value: TransactionCategory; label: string }[] = [
const incomeCategories: { value: TransactionCategory; label: string }[] = [
{ value: 'income_services', label: 'Tjänster' },
{ value: 'income_sponsorship', label: 'Sponsring' },
{ value: 'income_affiliate', label: 'Affiliate' },
{ value: 'income_products', label: 'Produkter' },
{ value: 'income_other', label: 'Övrigt' },
]
@@ -32,8 +32,6 @@ interface TransactionFormProps {
const categories: { value: TransactionCategory; label: string; isIncome?: boolean }[] = [
{ value: 'income_services', label: 'Intäkt: Tjänster', isIncome: true },
{ value: 'income_sponsorship', label: 'Intäkt: Sponsring', isIncome: true },
{ value: 'income_affiliate', label: 'Intäkt: Affiliate', isIncome: true },
{ value: 'income_products', label: 'Intäkt: Produkter', isIncome: true },
{ value: 'income_other', label: 'Intäkt: Övrigt', isIncome: true },
{ value: 'expense_equipment', label: 'Kostnad: Utrustning' },
-28
View File
@@ -9,8 +9,6 @@ import {
Users,
ArrowLeftRight,
Camera,
Gift,
Megaphone,
Building2,
FileText,
Calendar,
@@ -143,32 +141,6 @@ export function EmptyReceipts() {
)
}
export function EmptyGifts() {
return (
<EmptyState
icon={Gift}
title="Inga gåvor registrerade"
description="Fått produkter från varumärken? Registrera dem här för korrekt skattehantering."
actionLabel="Registrera gåva"
actionHref="/gifts/new"
/>
)
}
export function EmptyCampaigns() {
return (
<EmptyState
icon={Megaphone}
title="Inga samarbeten"
description="Skapa samarbeten för att hålla koll på innehåll, deadlines och fakturering."
actionLabel="Skapa samarbete"
actionHref="/campaigns/new"
secondaryActionLabel="Importera avtal"
secondaryActionHref="/campaigns/import"
/>
)
}
export function EmptyDeadlines() {
return (
<EmptyState