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:
co-authored by
Claude Opus 4.6
parent
a25d10a528
commit
0aecf5b41c
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 "{giftToDelete?.description}" från{' '}
|
||||
{giftToDelete?.brand_name}? Detta kan inte ångras.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleConfirmDelete} disabled={isDeleting}>
|
||||
{isDeleting ? 'Tar bort...' : 'Ta bort'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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">
|
||||
→
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user