Refactor to generic ERP base

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-02-13 15:39:13 +01:00
parent a25d10a528
commit 0aecf5b41c
184 changed files with 1418 additions and 24763 deletions
+2 -2
View File
@@ -89,9 +89,9 @@ export default function LoginPage() {
<div className="mx-auto mb-4 h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
<Sparkles className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-2xl">Influencer Assistant</CardTitle>
<CardTitle className="text-2xl">ERP Base</CardTitle>
<CardDescription>
Logga in med din e-post för att hantera din verksamhet
Logga in med din e-post för att hantera din ekonomi
</CardDescription>
</CardHeader>
<CardContent>
-333
View File
@@ -1,333 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
TikTokConnectButton,
TikTokAccountCard,
TikTokStatsWidget,
TikTokGrowthChart,
TikTokVideoList,
VideoLinkModal,
TikTokROITable,
} from '@/components/tiktok'
import type { TikTokAccount, TikTokStatsSummary, TikTokVideo, TikTokCampaignROI } from '@/types'
import {
Loader2,
TrendingUp,
Video,
Target,
BarChart3,
} from 'lucide-react'
export default function AnalyticsPage() {
const router = useRouter()
const supabase = createClient()
const [isLoading, setIsLoading] = useState(true)
const [accounts, setAccounts] = useState<TikTokAccount[]>([])
const [stats, setStats] = useState<TikTokStatsSummary | null>(null)
const [roiData, setRoiData] = useState<TikTokCampaignROI[]>([])
const [selectedVideo, setSelectedVideo] = useState<TikTokVideo | null>(null)
const [isLinkModalOpen, setIsLinkModalOpen] = useState(false)
const [isSyncing, setIsSyncing] = useState(false)
useEffect(() => {
checkAuth()
}, [])
const checkAuth = async () => {
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
router.push('/login')
return
}
fetchData()
}
const fetchData = async () => {
setIsLoading(true)
await Promise.all([
fetchAccounts(),
fetchStats(),
fetchROI(),
])
setIsLoading(false)
}
const fetchAccounts = async () => {
try {
const response = await fetch('/api/tiktok/accounts')
const data = await response.json()
setAccounts(data.accounts || [])
} catch (error) {
console.error('Failed to fetch accounts:', error)
}
}
const fetchStats = async () => {
try {
const response = await fetch('/api/tiktok/stats')
const data = await response.json()
setStats(data.summary || null)
} catch (error) {
console.error('Failed to fetch stats:', error)
}
}
const fetchROI = async () => {
// Fetch campaigns with TikTok videos for ROI calculation
try {
const response = await fetch('/api/tiktok/videos?limit=100')
const data = await response.json()
// Group videos by campaign and calculate ROI
// This is a simplified version - the actual calculation is in the API
const campaignVideos = new Map<string, TikTokVideo[]>()
for (const video of data.videos || []) {
if (video.campaign_id) {
if (!campaignVideos.has(video.campaign_id)) {
campaignVideos.set(video.campaign_id, [])
}
campaignVideos.get(video.campaign_id)!.push(video)
}
}
// For now, just set empty - actual ROI data would come from a dedicated endpoint
setRoiData([])
} catch (error) {
console.error('Failed to fetch ROI data:', error)
}
}
const handleSync = async () => {
if (accounts.length === 0) return
setIsSyncing(true)
try {
const activeAccount = accounts.find(a => 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 fetchData()
}
} catch (error) {
console.error('Sync failed:', error)
}
setIsSyncing(false)
}
const handleVideoLinkClick = (video: TikTokVideo) => {
setSelectedVideo(video)
setIsLinkModalOpen(true)
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)
}
const activeAccount = accounts.find(a => a.status === 'active')
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Analytics</h1>
<p className="text-muted-foreground">
Analysera din sociala medieprestanda och kampanj-ROI
</p>
</div>
{/* No connected account */}
{accounts.length === 0 && (
<Card>
<CardHeader>
<CardTitle>Koppla TikTok</CardTitle>
<CardDescription>
Anslut ditt TikTok-konto för att se statistik och analysera kampanjprestanda
</CardDescription>
</CardHeader>
<CardContent>
<TikTokConnectButton />
</CardContent>
</Card>
)}
{/* Connected account */}
{activeAccount && (
<>
{/* Stats overview */}
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Följare</span>
</div>
<p className="text-2xl font-bold mt-2">
{stats?.currentFollowers.toLocaleString('sv-SE') || '0'}
</p>
{stats?.followerChange7d !== undefined && (
<p className={`text-sm ${stats.followerChange7d >= 0 ? 'text-success' : 'text-destructive'}`}>
{stats.followerChange7d >= 0 ? '+' : ''}{stats.followerChange7d.toLocaleString('sv-SE')} senaste 7 dagar
</p>
)}
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2">
<Video className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Videor</span>
</div>
<p className="text-2xl font-bold mt-2">
{stats?.totalVideos || 0}
</p>
<p className="text-sm text-muted-foreground">
{stats?.totalLikes.toLocaleString('sv-SE') || '0'} totala likes
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2">
<Target className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Engagement Rate</span>
</div>
<p className="text-2xl font-bold mt-2">
{stats?.engagementRate.toFixed(1) || '0'}%
</p>
<p className="text-sm text-muted-foreground">
Genomsnitt senaste videor
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">30-dagars tillväxt</span>
</div>
<p className="text-2xl font-bold mt-2">
{stats?.followerChange30d !== undefined ? (
<>
{stats.followerChange30d >= 0 ? '+' : ''}
{stats.followerChange30d.toLocaleString('sv-SE')}
</>
) : '0'}
</p>
<p className="text-sm text-muted-foreground">
nya följare
</p>
</CardContent>
</Card>
</div>
{/* Tabs for different views */}
<Tabs defaultValue="growth" className="space-y-6">
<TabsList>
<TabsTrigger value="growth">Tillväxt</TabsTrigger>
<TabsTrigger value="videos">Videor</TabsTrigger>
<TabsTrigger value="roi">Kampanj-ROI</TabsTrigger>
</TabsList>
<TabsContent value="growth" className="space-y-6">
<TikTokGrowthChart accountId={activeAccount.id} />
{/* Recent videos with metrics */}
<Card>
<CardHeader>
<CardTitle>Senaste videor</CardTitle>
<CardDescription>
Prestanda för dina senaste publiceringar
</CardDescription>
</CardHeader>
<CardContent>
<TikTokVideoList
accountId={activeAccount.id}
limit={6}
onLinkClick={handleVideoLinkClick}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="videos" className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Alla videor</CardTitle>
<CardDescription>
Klicka länk-ikonen för att koppla en video till en kampanj
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<TikTokVideoList
accountId={activeAccount.id}
limit={20}
onLinkClick={handleVideoLinkClick}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="roi" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Kampanj-ROI</CardTitle>
<CardDescription>
Analysera avkastningen dina influencer-kampanjer baserat TikTok-prestanda
</CardDescription>
</CardHeader>
<CardContent>
<TikTokROITable campaigns={roiData} />
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Account info at bottom */}
<Card>
<CardHeader>
<CardTitle>Kopplat konto</CardTitle>
</CardHeader>
<CardContent>
<TikTokAccountCard
account={activeAccount}
onDisconnect={fetchData}
onSync={fetchData}
/>
</CardContent>
</Card>
</>
)}
{/* Video link modal */}
<VideoLinkModal
video={selectedVideo}
isOpen={isLinkModalOpen}
onClose={() => {
setIsLinkModalOpen(false)
setSelectedVideo(null)
}}
onSuccess={fetchData}
/>
</div>
)
}
-103
View File
@@ -1,103 +0,0 @@
'use client'
import { useState, useEffect, use } from 'react'
import { createClient } from '@/lib/supabase/client'
import { Campaign, Customer } from '@/types'
import { CampaignDetail, CampaignForm } from '@/components/campaigns'
import { useToast } from '@/components/ui/use-toast'
import { Skeleton } from '@/components/ui/skeleton'
interface PageProps {
params: Promise<{ id: string }>
}
export default function CampaignDetailPage({ params }: PageProps) {
const { id } = use(params)
const supabase = createClient()
const { toast } = useToast()
const [campaign, setCampaign] = useState<Campaign | null>(null)
const [customers, setCustomers] = useState<Customer[]>([])
const [loading, setLoading] = useState(true)
const [editFormOpen, setEditFormOpen] = useState(false)
const fetchCampaign = async () => {
try {
const response = await fetch(`/api/campaigns/${id}`)
if (response.ok) {
const { data } = await response.json()
setCampaign(data)
} else {
toast({
title: 'Fel',
description: 'Samarbetet hittades inte',
variant: 'destructive',
})
}
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte ladda samarbetet',
variant: 'destructive',
})
} finally {
setLoading(false)
}
}
const fetchCustomers = async () => {
const { data } = await supabase
.from('customers')
.select('*')
.order('name')
setCustomers(data || [])
}
useEffect(() => {
fetchCampaign()
fetchCustomers()
}, [id])
if (loading) {
return (
<div className="space-y-6">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-6 w-48" />
<div className="grid gap-4 md:grid-cols-4">
{[1, 2, 3, 4].map(i => (
<Skeleton key={i} className="h-24" />
))}
</div>
<Skeleton className="h-96" />
</div>
)
}
if (!campaign) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground">Samarbetet hittades inte</p>
</div>
)
}
return (
<>
<CampaignDetail
campaign={campaign}
onUpdate={fetchCampaign}
onEdit={() => setEditFormOpen(true)}
/>
<CampaignForm
open={editFormOpen}
onOpenChange={setEditFormOpen}
initialData={campaign}
customers={customers}
onSuccess={() => {
setEditFormOpen(false)
fetchCampaign()
}}
/>
</>
)
}
-40
View File
@@ -1,40 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { ContractImportWizard } from '@/components/contracts/ContractImportWizard'
export const metadata = {
title: 'Importera avtal | Samarbeten',
description: 'Importera och analysera avtal med AI',
}
export default async function CampaignImportPage() {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
redirect('/login')
}
// Fetch customers for matching
const { data: customers } = await supabase
.from('customers')
.select('*')
.eq('user_id', user.id)
.order('name')
return (
<div className="container max-w-6xl py-8">
<div className="mb-8">
<h1 className="text-2xl font-semibold tracking-tight">Importera avtal</h1>
<p className="text-muted-foreground mt-1">
Ladda upp ett avtal och låt AI extrahera samarbetsinformation automatiskt
</p>
</div>
<ContractImportWizard customers={customers || []} />
</div>
)
}
-351
View File
@@ -1,351 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
import { Customer, CreateCampaignInput, CampaignType, BillingFrequency } from '@/types'
import {
CAMPAIGN_TYPE_LABELS,
BILLING_FREQUENCY_LABELS,
} from '@/types'
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { useToast } from '@/components/ui/use-toast'
import { ArrowLeft } from 'lucide-react'
import Link from 'next/link'
const CURRENCIES = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']
export default function NewCampaignPage() {
const router = useRouter()
const supabase = createClient()
const { toast } = useToast()
const [customers, setCustomers] = useState<Customer[]>([])
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(() => {
const fetchCustomers = async () => {
const { data } = await supabase
.from('customers')
.select('*')
.order('name')
setCustomers(data || [])
}
fetchCustomers()
}, [])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!formData.name) {
toast({ title: 'Namn krävs', variant: 'destructive' })
return
}
setIsLoading(true)
try {
const response = await fetch('/api/campaigns', {
method: 'POST',
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 create campaign')
}
const { data } = await response.json()
toast({
title: 'Samarbete skapat',
description: formData.name,
})
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 (
<div className="max-w-2xl mx-auto space-y-6">
<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-2xl font-bold">Nytt samarbete</h1>
<p className="text-muted-foreground">
Skapa ett nytt samarbete för att spåra innehåll, avtal och betalningar
</p>
</div>
<form onSubmit={handleSubmit}>
<Card>
<CardHeader>
<CardTitle>Grundläggande information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="name">Namn 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="T.ex. Nike, Adidas..."
/>
</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 kund" />
</SelectTrigger>
<SelectContent>
{customers.map((customer) => (
<SelectItem key={customer.id} value={customer.id}>
{customer.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
<Card className="mt-4">
<CardHeader>
<CardTitle>Ekonomi</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div>
<Label htmlFor="total_value">Totalvärde</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 pb-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 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>
</CardContent>
</Card>
<Card className="mt-4">
<CardHeader>
<CardTitle>Datum</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<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 })}
/>
</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 })}
/>
</div>
</div>
<div>
<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>
</CardContent>
</Card>
<div className="flex justify-end gap-3 mt-6">
<Link href="/campaigns">
<Button type="button" variant="outline">Avbryt</Button>
</Link>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Skapar...' : 'Skapa samarbete'}
</Button>
</div>
</form>
</div>
)
}
-85
View File
@@ -1,85 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { createClient } from '@/lib/supabase/client'
import { Campaign, Customer } from '@/types'
import { CampaignList, CampaignForm } from '@/components/campaigns'
import { useToast } from '@/components/ui/use-toast'
import { PageHeader } from '@/components/ui/page-header'
import { Button } from '@/components/ui/button'
import { Plus, FileUp } from 'lucide-react'
import { useRouter } from 'next/navigation'
export default function CampaignsPage() {
const supabase = createClient()
const router = useRouter()
const { toast } = useToast()
const [campaigns, setCampaigns] = useState<Campaign[]>([])
const [customers, setCustomers] = useState<Customer[]>([])
const [loading, setLoading] = useState(true)
const [formOpen, setFormOpen] = useState(false)
const fetchData = async () => {
try {
// Fetch campaigns
const campaignsResponse = await fetch('/api/campaigns')
if (campaignsResponse.ok) {
const { data } = await campaignsResponse.json()
setCampaigns(data || [])
}
// Fetch customers for the form
const { data: customersData } = await supabase
.from('customers')
.select('*')
.order('name')
setCustomers(customersData || [])
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte ladda data',
variant: 'destructive',
})
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchData()
}, [])
return (
<div className="space-y-6">
<PageHeader
title="Samarbeten"
description="Hantera dina samarbeten, innehåll och avtal"
action={
<div className="flex gap-2">
<Button onClick={() => setFormOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Skapa samarbete
</Button>
<Button variant="outline" onClick={() => router.push('/campaigns/import')}>
<FileUp className="mr-2 h-4 w-4" />
Importera avtal
</Button>
</div>
}
/>
<CampaignList campaigns={campaigns} loading={loading} />
<CampaignForm
open={formOpen}
onOpenChange={setFormOpen}
customers={customers}
onSuccess={() => {
setFormOpen(false)
fetchData()
}}
/>
</div>
)
}
+1 -62
View File
@@ -10,7 +10,6 @@ import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import CustomerForm from '@/components/customers/CustomerForm'
import { CampaignStatusBadge } from '@/components/campaigns/CampaignStatusBadge'
import {
ArrowLeft,
Building,
@@ -21,12 +20,10 @@ import {
MapPin,
Edit2,
Trash2,
FileText,
Loader2,
Receipt,
Briefcase,
} from 'lucide-react'
import type { Customer, CustomerType, CreateCustomerInput, CampaignStatus } from '@/types'
import type { Customer, CustomerType, CreateCustomerInput } from '@/types'
const customerTypeLabels: Record<CustomerType, string> = {
individual: 'Privatperson',
@@ -42,16 +39,6 @@ const customerTypeIcons: Record<CustomerType, React.ElementType> = {
non_eu_business: Globe,
}
interface RelatedCampaign {
id: string
name: string
status: CampaignStatus
total_value: number | null
currency: string | null
publication_date: string | null
brand_name: string | null
}
interface RelatedInvoice {
id: string
invoice_number: string
@@ -64,7 +51,6 @@ interface RelatedInvoice {
}
interface CustomerWithRelations extends Customer {
campaigns: RelatedCampaign[]
invoices: RelatedInvoice[]
}
@@ -303,10 +289,6 @@ export default function CustomerDetailPage({
<CardTitle className="text-base">Oversikt</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-2 text-sm">
<Briefcase className="h-4 w-4 text-muted-foreground" />
<span>{customer.campaigns?.length || 0} samarbeten</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Receipt className="h-4 w-4 text-muted-foreground" />
<span>{customer.invoices?.length || 0} fakturor</span>
@@ -327,49 +309,6 @@ export default function CustomerDetailPage({
</Card>
)}
{/* Related campaigns */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Briefcase className="h-4 w-4" />
Samarbeten
{customer.campaigns?.length > 0 && (
<Badge variant="secondary">{customer.campaigns.length}</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent>
{customer.campaigns?.length > 0 ? (
<div className="space-y-2">
{customer.campaigns.map((campaign) => (
<Link
key={campaign.id}
href={`/campaigns/${campaign.id}`}
className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50 transition-colors"
>
<div>
<p className="font-medium">{campaign.name}</p>
{campaign.brand_name && (
<p className="text-sm text-muted-foreground">{campaign.brand_name}</p>
)}
</div>
<div className="flex items-center gap-3">
<span className="text-sm tabular-nums">
{formatCurrency(campaign.total_value, campaign.currency)}
</span>
<CampaignStatusBadge status={campaign.status} />
</div>
</Link>
))}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-4">
Inga samarbeten kopplade till denna kund
</p>
)}
</CardContent>
</Card>
{/* Related invoices */}
<Card>
<CardHeader>
-366
View File
@@ -1,366 +0,0 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Skeleton } from '@/components/ui/skeleton'
import { useToast } from '@/components/ui/use-toast'
import GiftForm from '@/components/benefits/GiftForm'
import GiftList from '@/components/benefits/GiftList'
import { formatCurrency } from '@/lib/utils'
import { Plus, Gift, TrendingUp, CheckCircle, Receipt, AlertTriangle } from 'lucide-react'
import type { Gift as GiftType, GiftSummary, CreateGiftInput, EntityType } from '@/types'
export default function GiftsPage() {
const router = useRouter()
const searchParams = useSearchParams()
const { toast } = useToast()
// State
const [gifts, setGifts] = useState<GiftType[]>([])
const [summary, setSummary] = useState<GiftSummary | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isSubmitting, setIsSubmitting] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [entityType, setEntityType] = useState<EntityType>('enskild_firma')
const isLightMode = entityType === 'light'
// Dialog state
const [isFormOpen, setIsFormOpen] = useState(false)
const [editingGift, setEditingGift] = useState<GiftType | null>(null)
// Year filter
const currentYear = new Date().getFullYear()
const [selectedYear, setSelectedYear] = useState(
searchParams.get('year') || currentYear.toString()
)
const years = Array.from({ length: 5 }, (_, i) => currentYear - i)
// Fetch gifts and summary
const fetchData = useCallback(async () => {
setIsLoading(true)
try {
const [giftsRes, summaryRes, settingsRes] = await Promise.all([
fetch(`/api/gifts?year=${selectedYear}`),
fetch(`/api/gifts/summary?year=${selectedYear}`),
fetch('/api/settings'),
])
if (giftsRes.ok) {
const giftsData = await giftsRes.json()
setGifts(giftsData.data || [])
}
if (summaryRes.ok) {
const summaryData = await summaryRes.json()
setSummary(summaryData.data || null)
}
if (settingsRes.ok) {
const settingsData = await settingsRes.json()
if (settingsData.data?.entity_type) {
setEntityType(settingsData.data.entity_type as EntityType)
}
}
} catch (error) {
console.error('Failed to fetch gifts:', error)
toast({
title: 'Fel',
description: 'Kunde inte hämta gåvor',
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}, [selectedYear, toast])
useEffect(() => {
fetchData()
}, [fetchData])
// Update URL when year changes
const handleYearChange = (year: string) => {
setSelectedYear(year)
router.push(`/gifts?year=${year}`)
}
// Handle form submit (create or update)
const handleSubmit = async (data: CreateGiftInput) => {
setIsSubmitting(true)
try {
const url = editingGift ? `/api/gifts/${editingGift.id}` : '/api/gifts'
const method = editingGift ? 'PUT' : 'POST'
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) {
const error = await res.json()
throw new Error(error.error || 'Unknown error')
}
toast({
title: editingGift ? 'Gåva uppdaterad' : 'Gåva sparad',
description: `${data.description} från ${data.brand_name}`,
})
setIsFormOpen(false)
setEditingGift(null)
fetchData()
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte spara gåva',
variant: 'destructive',
})
} finally {
setIsSubmitting(false)
}
}
// Handle edit
const handleEdit = (gift: GiftType) => {
setEditingGift(gift)
setIsFormOpen(true)
}
// Handle delete
const handleDelete = async (id: string) => {
setIsDeleting(true)
try {
const res = await fetch(`/api/gifts/${id}`, { method: 'DELETE' })
if (!res.ok) {
const error = await res.json()
throw new Error(error.error || 'Unknown error')
}
toast({
title: 'Gåva borttagen',
})
fetchData()
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte ta bort gåva',
variant: 'destructive',
})
} finally {
setIsDeleting(false)
}
}
// Handle dialog close
const handleDialogClose = (open: boolean) => {
if (!open) {
setIsFormOpen(false)
setEditingGift(null)
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Gåvor & Förmåner</h1>
<p className="text-muted-foreground">
Logga produkter och gåvor du fått för korrekt skattehantering
</p>
</div>
<div className="flex items-center gap-2">
<Select value={selectedYear} onValueChange={handleYearChange}>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{years.map((year) => (
<SelectItem key={year} value={year.toString()}>
{year}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={() => setIsFormOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Ny gåva
</Button>
</div>
</div>
{/* Summary Cards */}
{isLoading ? (
<div className="grid gap-4 md:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardContent className="pt-6">
<Skeleton className="h-4 w-24 mb-2" />
<Skeleton className="h-8 w-32" />
</CardContent>
</Card>
))}
</div>
) : summary ? (
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Gift className="h-4 w-4" />
<span className="text-sm">Totalt antal</span>
</div>
<p className="text-2xl font-bold">{summary.total_count}</p>
<p className="text-sm text-muted-foreground">{formatCurrency(summary.total_value)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-destructive mb-1">
<TrendingUp className="h-4 w-4" />
<span className="text-sm">Skattepliktig</span>
</div>
<p className="text-2xl font-bold">{summary.taxable_count}</p>
<p className="text-sm text-muted-foreground">
{formatCurrency(summary.taxable_value)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-success mb-1">
<CheckCircle className="h-4 w-4" />
<span className="text-sm">Skattefria</span>
</div>
<p className="text-2xl font-bold">{summary.tax_free_count}</p>
<p className="text-sm text-muted-foreground">
{formatCurrency(summary.tax_free_value)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
{isLightMode ? (
<>
<div className="flex items-center gap-2 text-warning mb-1">
<AlertTriangle className="h-4 w-4" />
<span className="text-sm">Virtuell skatteskuld</span>
</div>
<p className="text-2xl font-bold">
{formatCurrency(summary.taxable_value * 0.32)}
</p>
<p className="text-sm text-muted-foreground">
ca 32% av skattepliktiga gåvor
</p>
</>
) : (
<>
<div className="flex items-center gap-2 text-primary mb-1">
<Receipt className="h-4 w-4" />
<span className="text-sm">Avdragsgilla</span>
</div>
<p className="text-2xl font-bold">{summary.deductible_count}</p>
<p className="text-sm text-muted-foreground">
{formatCurrency(summary.deductible_value)}
</p>
</>
)}
</CardContent>
</Card>
</div>
) : null}
{/* Info Card */}
<Card className="bg-muted/50">
<CardContent className="pt-6">
<div className="flex gap-4">
<Gift className="h-8 w-8 text-primary flex-shrink-0" />
<div>
<h3 className="font-medium mb-1">Varför logga gåvor?</h3>
<p className="text-sm text-muted-foreground">
Skatteverket granskar aktivt gåvor och förmåner som influencers får. Produkter du
fått i utbyte mot att posta om dem är skattepliktiga. Genom att logga allt korrekt
undviker du skattetillägg och kan dessutom göra avdrag för produkter som endast
används i verksamheten.
</p>
</div>
</div>
</CardContent>
</Card>
{/* Gift List */}
{isLoading ? (
<div className="space-y-3">
{[...Array(3)].map((_, i) => (
<Card key={i}>
<CardContent className="pt-4">
<Skeleton className="h-6 w-48 mb-2" />
<Skeleton className="h-4 w-32" />
</CardContent>
</Card>
))}
</div>
) : (
<GiftList
gifts={gifts}
onEdit={handleEdit}
onDelete={handleDelete}
isDeleting={isDeleting}
/>
)}
{/* Form Dialog */}
<Dialog open={isFormOpen} onOpenChange={handleDialogClose}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingGift ? 'Redigera gåva' : 'Lägg till ny gåva'}</DialogTitle>
<DialogDescription>
Fyll i information om produkten eller gåvan du fått
</DialogDescription>
</DialogHeader>
<GiftForm
onSubmit={handleSubmit}
initialData={
editingGift
? {
date: editingGift.date,
brand_name: editingGift.brand_name,
description: editingGift.description,
estimated_value: Number(editingGift.estimated_value),
has_motprestation: editingGift.has_motprestation,
used_in_business: editingGift.used_in_business,
used_privately: editingGift.used_privately,
is_simple_promo: editingGift.is_simple_promo,
returned: editingGift.returned,
}
: undefined
}
isLoading={isSubmitting}
isLightMode={isLightMode}
/>
</DialogContent>
</Dialog>
</div>
)
}
-35
View File
@@ -48,7 +48,6 @@ export default function NewInvoicePage() {
const { toast } = useToast()
const supabase = createClient()
const searchParams = useSearchParams()
const campaignId = searchParams.get('campaign_id')
const preselectedCustomerId = searchParams.get('customer_id')
const [customers, setCustomers] = useState<Customer[]>([])
@@ -126,40 +125,6 @@ export default function NewInvoicePage() {
setIsLoading(false)
}
useEffect(() => {
if (!campaignId || customers.length === 0) return
async function fetchCampaign() {
const response = await fetch(`/api/campaigns/${campaignId}`)
if (!response.ok) return
const { data: campaign } = await response.json()
if (campaign.customer_id) {
setValue('customer_id', campaign.customer_id)
} else if (preselectedCustomerId) {
setValue('customer_id', preselectedCustomerId)
}
if (campaign.currency) {
setValue('currency', campaign.currency)
}
if (campaign.total_value) {
setValue('items.0.description', campaign.name || '')
setValue('items.0.quantity', 1)
setValue('items.0.unit', 'st')
setValue('items.0.unit_price', campaign.total_value)
}
// Calculate due date from publication_date + payment_terms
const paymentTerms = campaign.payment_terms || 30
const baseDate = campaign.publication_date ? new Date(campaign.publication_date) : new Date()
setValue('due_date', format(addDays(baseDate, paymentTerms), 'yyyy-MM-dd'))
}
fetchCampaign()
}, [campaignId, customers, setValue])
const subtotal = watchItems.reduce((sum, item) => {
return sum + (item.quantity || 0) * (item.unit_price || 0)
}, 0)
+2 -174
View File
@@ -1,8 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import DashboardContent from '@/components/dashboard/DashboardContent'
import LightDashboardContent from '@/components/dashboard/LightDashboardContent'
import type { Gift, GiftSummary, Deadline, Campaign, ReceiptQueueSummary, OnboardingProgress, ShadowLedgerEntry } from '@/types'
import type { Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
export const dynamic = 'force-dynamic'
@@ -31,122 +30,7 @@ export default async function DashboardPage() {
.eq('user_id', user.id)
.single()
// ── Light mode early return ──────────────────────────────────────────
if (settings?.entity_type === 'light') {
const currentYear = new Date().getFullYear()
const lightStartOfYear = `${currentYear}-01-01`
const lightEndOfYear = `${currentYear}-12-31`
// Fetch bank balance
const { data: lightBankConnections } = await supabase
.from('bank_connections')
.select('accounts, status')
.eq('user_id', user.id)
.eq('status', 'active')
.limit(1)
let lightBankBalance: number | null = null
if (lightBankConnections && lightBankConnections.length > 0) {
const accounts = lightBankConnections[0].accounts as { balance: number }[] | null
if (accounts && accounts.length > 0) {
lightBankBalance = accounts.reduce((sum, acc) => sum + (acc.balance || 0), 0)
}
}
// Fetch gifts for current year
const { data: lightGifts } = await supabase
.from('gifts')
.select('*')
.eq('user_id', user.id)
.gte('date', lightStartOfYear)
// Fetch shadow ledger entries for current year
const { data: shadowLedgerEntriesRaw } = await supabase
.from('shadow_ledger_entries')
.select('*')
.eq('user_id', user.id)
.gte('date', lightStartOfYear)
.lte('date', lightEndOfYear)
.order('date', { ascending: false })
const shadowLedgerEntries: ShadowLedgerEntry[] = (shadowLedgerEntriesRaw || []) as ShadowLedgerEntry[]
// Fetch active campaigns
const { data: lightCampaigns } = await supabase
.from('campaigns')
.select(`
*,
customer:customers!campaigns_customer_id_fkey(id, name),
deliverables(*)
`)
.eq('user_id', user.id)
.in('status', ['negotiation', 'contracted', 'active'])
.order('created_at', { ascending: false })
// Fetch upcoming deadlines (next 7 days + overdue)
const lightToday = new Date().toISOString().split('T')[0]
const lightNextWeek = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
const { data: lightDeadlines } = await supabase
.from('deadlines')
.select('*, customer:customers(id, name)')
.eq('user_id', user.id)
.eq('is_completed', false)
.or(`due_date.lt.${lightToday},due_date.lte.${lightNextWeek}`)
.order('due_date', { ascending: true })
// Compute gift tax debt from gifts data
const taxableGifts = (lightGifts || []).filter(
(g: Gift) => g.classification?.taxable && !g.returned
)
const taxableGiftValue = taxableGifts.reduce(
(sum: number, g: Gift) => sum + Number(g.estimated_value), 0
)
const municipalRate = Number(settings.municipal_tax_rate) || 0.3238
const churchRate = settings.church_tax ? (Number(settings.church_tax_rate) || 0.01) : 0
const effectiveRate = municipalRate + churchRate
const giftTaxDebt = Math.round(taxableGiftValue * effectiveRate * 100) / 100
// Find days since last payout
const lastPayout = shadowLedgerEntries.find(e => e.type === 'payout')
let daysSinceLastPayout: number | null = null
if (lastPayout) {
const lastPayoutDate = new Date(lastPayout.date)
const today = new Date()
daysSinceLastPayout = Math.floor((today.getTime() - lastPayoutDate.getTime()) / (1000 * 60 * 60 * 24))
}
// Recent entries for payout card (last 5)
const recentEntries = shadowLedgerEntries.slice(0, 5).map(e => ({
id: e.id,
date: e.date,
description: e.description,
gross_amount: Number(e.gross_amount),
net_amount: Number(e.net_amount),
service_fee: Number(e.service_fee),
pension_deduction: Number(e.pension_deduction),
social_fees: Number(e.social_fees),
income_tax_withheld: Number(e.income_tax_withheld),
platform_fee: Number(e.platform_fee),
type: e.type,
provider: e.provider,
}))
return (
<LightDashboardContent
firstName={firstName}
bankBalance={lightBankBalance}
giftTaxDebt={giftTaxDebt}
taxableGiftCount={taxableGifts.length}
effectiveRate={effectiveRate}
daysSinceLastPayout={daysSinceLastPayout}
recentEntries={recentEntries}
hobbyReserve={0}
/>
)
}
// ── EF / AB dashboard (existing logic) ──────────────────────────────
// ── EF / AB dashboard ──────────────────────────────────────────────
// Fetch onboarding progress for new user checklist
const { count: customerCount } = await supabase
.from('customers')
@@ -273,25 +157,6 @@ export default async function DashboardPage() {
.or(`due_date.lt.${today},due_date.lte.${nextWeek}`)
.order('due_date', { ascending: true })
// Fetch active campaigns with deliverables
const { data: campaigns } = await supabase
.from('campaigns')
.select(`
*,
customer:customers!campaigns_customer_id_fkey(id, name),
deliverables(*)
`)
.eq('user_id', user.id)
.in('status', ['negotiation', 'contracted', 'active'])
.order('created_at', { ascending: false })
// Fetch gift summary for current year
const { data: gifts } = await supabase
.from('gifts')
.select('estimated_value, classification')
.eq('user_id', user.id)
.gte('date', startOfYear.split('T')[0])
// Fetch receipt queue summary
const { count: pendingReviewCount } = await supabase
.from('receipts')
@@ -345,41 +210,6 @@ export default async function DashboardPage() {
streak_count: streakCount,
}
let giftSummary: GiftSummary | null = null
if (gifts && gifts.length > 0) {
giftSummary = {
year: new Date().getFullYear(),
total_count: gifts.length,
total_value: 0,
taxable_count: 0,
taxable_value: 0,
tax_free_count: 0,
tax_free_value: 0,
deductible_count: 0,
deductible_value: 0,
}
for (const gift of gifts as Pick<Gift, 'estimated_value' | 'classification'>[]) {
const value = Number(gift.estimated_value)
const classification = gift.classification
giftSummary.total_value += value
if (classification?.taxable) {
giftSummary.taxable_count++
giftSummary.taxable_value += value
} else {
giftSummary.tax_free_count++
giftSummary.tax_free_value += value
}
if (classification?.deductibleAsExpense) {
giftSummary.deductible_count++
giftSummary.deductible_value += value
}
}
}
return (
<DashboardContent
firstName={firstName}
@@ -396,9 +226,7 @@ export default async function DashboardPage() {
overdueInvoicesCount: overdueCount,
bankBalance,
mileageEntries: mileageEntries || [],
giftSummary,
deadlines: (deadlines || []) as Deadline[],
campaigns: (campaigns || []) as Campaign[],
receiptQueue,
}}
onboardingProgress={onboardingProgress}
+1 -26
View File
@@ -8,23 +8,20 @@ import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Camera,
Package,
Receipt,
Check,
Clock,
AlertCircle,
ArrowRight,
ExternalLink,
} from 'lucide-react'
import { formatCurrency, formatDate } from '@/lib/utils'
import { useRouter } from 'next/navigation'
import ReceiptDashboard from '@/components/receipts/ReceiptDashboard'
import ReceiptReviewView from '@/components/receipts/ReceiptReviewView'
import TransactionMatcher from '@/components/receipts/TransactionMatcher'
import ProductCapture from '@/components/receipts/ProductCapture'
import type { Receipt as ReceiptType, ReceiptLineItem, ReceiptQueueSummary, ConfirmLineItemInput } from '@/types'
type ViewMode = 'dashboard' | 'list' | 'review' | 'match' | 'product'
type ViewMode = 'dashboard' | 'list' | 'review' | 'match'
type ListFilter = 'all' | 'pending' | 'confirmed'
export default function ReceiptsPage() {
@@ -83,11 +80,6 @@ export default function ReceiptsPage() {
router.push('/receipts/scan')
}
// Handle register product
const handleRegisterProduct = () => {
setViewMode('product')
}
// Handle view receipt queue
const handleViewReceiptQueue = () => {
setListFilter('pending')
@@ -150,12 +142,6 @@ export default function ReceiptsPage() {
}
}
// Handle product capture complete
const handleProductComplete = () => {
setViewMode('dashboard')
fetchData()
}
// Render receipt review view
if (viewMode === 'review' && selectedReceipt) {
return (
@@ -189,16 +175,6 @@ export default function ReceiptsPage() {
)
}
// Render product capture
if (viewMode === 'product') {
return (
<ProductCapture
onComplete={handleProductComplete}
onCancel={() => setViewMode('dashboard')}
/>
)
}
// Render main page
return (
<div className="container max-w-4xl mx-auto p-4 space-y-6">
@@ -236,7 +212,6 @@ export default function ReceiptsPage() {
<ReceiptDashboard
summary={summary}
onScanReceipt={handleScanReceipt}
onRegisterProduct={handleRegisterProduct}
onViewReceiptQueue={handleViewReceiptQueue}
onViewTransactionQueue={handleViewTransactionQueue}
/>
+1 -75
View File
@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Download, FileText, TrendingUp, Scale, AlertCircle, Receipt, Briefcase, Gift } from 'lucide-react'
import { Download, FileText, TrendingUp, Scale, AlertCircle, Receipt, Briefcase } from 'lucide-react'
import type {
FiscalPeriod,
TrialBalanceRow,
@@ -1063,80 +1063,6 @@ function NEDeclarationView({ periodId }: { periodId: string }) {
</CardContent>
</Card>
{/* Gift breakdown (if any gifts exist) */}
{data.giftBreakdown && data.giftBreakdown.gifts.length > 0 && (
<Card className="border-amber-200 bg-amber-50/50">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Gift className="h-5 w-5 text-amber-600" />
Gåvor & Förmåner
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
Skattepliktiga gåvor som ingår i intäkter/kostnader ovan (via bokföringsverifikationer).
</p>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2">Datum</th>
<th className="py-2">Varumärke</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 text-right">Värde</th>
<th className="py-2">NE-ruta</th>
</tr>
</thead>
<tbody>
{data.giftBreakdown.gifts.map((gift) => (
<tr key={gift.id} className="border-b last:border-0">
<td className="py-2">{gift.date}</td>
<td className="py-2">{gift.brandName}</td>
<td className="py-2 max-w-[200px] truncate">{gift.description}</td>
<td className="py-2 text-right tabular-nums">{formatAmount(gift.marketValue)} kr</td>
<td className="py-2">
<div className="flex gap-1">
{gift.neIncomeRuta && (
<Badge variant="outline" className="font-mono text-xs">
{gift.neIncomeRuta}
</Badge>
)}
{gift.neExpenseRuta && (
<Badge variant="outline" className="font-mono text-xs text-green-600">
{gift.neExpenseRuta}
</Badge>
)}
</div>
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 font-semibold">
<td colSpan={3} className="py-2">Summa i R1 (med moms)</td>
<td className="py-2 text-right tabular-nums">{formatAmount(data.giftBreakdown.summary.r1Total)} kr</td>
<td></td>
</tr>
<tr className="font-semibold">
<td colSpan={3} className="py-2">Summa i R2 (momsfri)</td>
<td className="py-2 text-right tabular-nums">{formatAmount(data.giftBreakdown.summary.r2Total)} kr</td>
<td></td>
</tr>
<tr className="font-semibold">
<td colSpan={3} className="py-2">Summa avdrag i R6</td>
<td className="py-2 text-right tabular-nums text-green-600">-{formatAmount(data.giftBreakdown.summary.r6Total)} kr</td>
<td></td>
</tr>
<tr className="border-t font-bold">
<td colSpan={3} className="py-2">Netto skatteeffekt</td>
<td className="py-2 text-right tabular-nums">{formatAmount(data.giftBreakdown.summary.netTaxableIncome)} kr</td>
<td></td>
</tr>
</tfoot>
</table>
</CardContent>
</Card>
)}
{/* Result */}
<Card className="border-2">
<CardContent className="py-4">
+11 -319
View File
@@ -8,14 +8,6 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useToast } from '@/components/ui/use-toast'
import { formatDate } from '@/lib/utils'
@@ -29,13 +21,11 @@ import {
RefreshCw,
Trash2,
LogOut,
Share2,
Bell,
Calendar,
} from 'lucide-react'
import type { CompanySettings, BankConnection, TikTokAccount } from '@/types'
import type { CompanySettings, BankConnection } from '@/types'
import { BankSelector, type Bank } from '@/components/banking/BankSelector'
import { TikTokConnectButton, TikTokAccountCard } from '@/components/tiktok'
import { NotificationSettings } from '@/components/settings/NotificationSettings'
import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings'
@@ -49,28 +39,15 @@ export default function SettingsPage() {
const [isSaving, setIsSaving] = useState(false)
const [settings, setSettings] = useState<CompanySettings | null>(null)
const [bankConnections, setBankConnections] = useState<BankConnection[]>([])
const [tiktokAccounts, setTiktokAccounts] = useState<TikTokAccount[]>([])
const [isSyncing, setIsSyncing] = useState(false)
const [isConnecting, setIsConnecting] = useState(false)
// Light mode specific state
const [municipalityCode, setMunicipalityCode] = useState('')
const [municipalTaxRate, setMunicipalTaxRate] = useState('')
const [churchTax, setChurchTax] = useState(false)
const [churchTaxRate, setChurchTaxRate] = useState('')
const [umbrellaProvider, setUmbrellaProvider] = useState('')
const [umbrellaFeePercent, setUmbrellaFeePercent] = useState('')
const [umbrellaPensionPercent, setUmbrellaPensionPercent] = useState('')
const [umbrellaFeeCustom, setUmbrellaFeeCustom] = useState(false)
useEffect(() => {
fetchData()
// Handle callback messages
const bankConnected = searchParams.get('bank_connected')
const bankError = searchParams.get('bank_error')
const tiktokConnected = searchParams.get('tiktok_connected')
const tiktokError = searchParams.get('tiktok_error')
if (bankConnected === 'true') {
toast({
@@ -88,23 +65,6 @@ export default function SettingsPage() {
})
router.replace('/settings')
}
if (tiktokConnected === 'true') {
toast({
title: 'TikTok anslutet!',
description: 'Ditt TikTok-konto är nu kopplat.',
})
router.replace('/settings')
}
if (tiktokError) {
toast({
title: 'TikTok-anslutning misslyckades',
description: decodeURIComponent(tiktokError),
variant: 'destructive',
})
router.replace('/settings')
}
}, [searchParams])
async function fetchData() {
@@ -125,18 +85,6 @@ export default function SettingsPage() {
setSettings(settingsData)
// Initialize light mode fields from fetched settings
if (settingsData) {
setMunicipalityCode(settingsData.municipality_code || '')
setMunicipalTaxRate(settingsData.municipal_tax_rate?.toString() || '')
setChurchTax(settingsData.church_tax || false)
setChurchTaxRate(settingsData.church_tax_rate?.toString() || '')
setUmbrellaProvider(settingsData.umbrella_provider || '')
setUmbrellaFeePercent(settingsData.umbrella_fee_percent?.toString() || '')
setUmbrellaPensionPercent(settingsData.umbrella_pension_percent?.toString() || '')
setUmbrellaFeeCustom(settingsData.umbrella_fee_custom || false)
}
// Fetch bank connections
const { data: connections } = await supabase
.from('bank_connections')
@@ -146,15 +94,6 @@ export default function SettingsPage() {
setBankConnections(connections || [])
// Fetch TikTok accounts
try {
const tiktokResponse = await fetch('/api/tiktok/accounts')
const tiktokData = await tiktokResponse.json()
setTiktokAccounts(tiktokData.accounts || [])
} catch (error) {
console.error('Failed to fetch TikTok accounts:', error)
}
setIsLoading(false)
}
@@ -165,34 +104,17 @@ export default function SettingsPage() {
setIsSaving(true)
const formData = new FormData(e.currentTarget)
const isLight = settings.entity_type === 'light'
let updates: Record<string, unknown>
if (isLight) {
updates = {
company_name: formData.get('company_name') as string,
municipality_code: municipalityCode || null,
municipal_tax_rate: parseFloat(municipalTaxRate) || null,
church_tax: churchTax,
church_tax_rate: churchTax ? (parseFloat(churchTaxRate) || null) : null,
umbrella_provider: umbrellaProvider || null,
umbrella_fee_percent: parseFloat(umbrellaFeePercent) || null,
umbrella_pension_percent: parseFloat(umbrellaPensionPercent) || null,
umbrella_fee_custom: umbrellaFeeCustom,
}
} else {
updates = {
company_name: formData.get('company_name') as string,
org_number: formData.get('org_number') as string,
address_line1: formData.get('address_line1') as string,
postal_code: formData.get('postal_code') as string,
city: formData.get('city') as string,
bank_name: formData.get('bank_name') as string,
clearing_number: formData.get('clearing_number') as string,
account_number: formData.get('account_number') as string,
preliminary_tax_monthly: parseFloat(formData.get('preliminary_tax_monthly') as string) || null,
}
const updates: Record<string, unknown> = {
company_name: formData.get('company_name') as string,
org_number: formData.get('org_number') as string,
address_line1: formData.get('address_line1') as string,
postal_code: formData.get('postal_code') as string,
city: formData.get('city') as string,
bank_name: formData.get('bank_name') as string,
clearing_number: formData.get('clearing_number') as string,
account_number: formData.get('account_number') as string,
preliminary_tax_monthly: parseFloat(formData.get('preliminary_tax_monthly') as string) || null,
}
try {
@@ -340,10 +262,6 @@ export default function SettingsPage() {
<CreditCard className="mr-2 h-4 w-4" />
Bank
</TabsTrigger>
<TabsTrigger value="social">
<Share2 className="mr-2 h-4 w-4" />
Sociala medier
</TabsTrigger>
<TabsTrigger value="notifications">
<Bell className="mr-2 h-4 w-4" />
Aviseringar
@@ -360,190 +278,6 @@ export default function SettingsPage() {
{/* Company settings */}
<TabsContent value="company">
{settings?.entity_type === 'light' ? (
/* ---- Light mode: Personuppgifter ---- */
<form onSubmit={handleSaveSettings}>
<div className="space-y-6">
{/* Personal details */}
<Card>
<CardHeader>
<CardTitle>Personuppgifter</CardTitle>
<CardDescription>
Ditt namn som visas i appen
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="company_name">Namn</Label>
<Input
id="company_name"
name="company_name"
defaultValue={settings?.company_name || ''}
/>
</div>
</CardContent>
</Card>
{/* Tax settings for light mode */}
<Card>
<CardHeader>
<CardTitle>Skatteinställningar</CardTitle>
<CardDescription>
Kommunalskatt och kyrkoskatt som används för att beräkna din skatteskuld
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Municipality section */}
<div className="space-y-4">
<h3 className="font-medium">Kommun</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="municipality_code">Kommun</Label>
<Input
id="municipality_code"
placeholder="T.ex. Stockholm"
value={municipalityCode}
onChange={(e) => setMunicipalityCode(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Ange din kommun för att beräkna kommunalskatt
</p>
</div>
<div className="space-y-2">
<Label htmlFor="municipal_tax_rate">Total kommunalskatt (%)</Label>
<Input
id="municipal_tax_rate"
type="number"
step="0.01"
placeholder="T.ex. 32.38"
value={municipalTaxRate}
onChange={(e) => setMunicipalTaxRate(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Kommunalskatt + landstingsskatt + begravningsavgift
</p>
</div>
</div>
</div>
{/* Church tax section */}
<div className="pt-4 border-t space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium">Kyrkoavgift</h3>
<p className="text-sm text-muted-foreground">
Aktivera om du betalar kyrkoavgift
</p>
</div>
<Switch
checked={churchTax}
onCheckedChange={setChurchTax}
/>
</div>
{churchTax && (
<div className="space-y-2">
<Label htmlFor="church_tax_rate">Kyrkoavgift (%)</Label>
<Input
id="church_tax_rate"
type="number"
step="0.01"
placeholder="T.ex. 1.00"
value={churchTaxRate}
onChange={(e) => setChurchTaxRate(e.target.value)}
/>
</div>
)}
</div>
</CardContent>
</Card>
{/* Umbrella provider section */}
<Card>
<CardHeader>
<CardTitle>Egenanställningsföretag</CardTitle>
<CardDescription>
Välj ditt egenanställningsföretag för att beräkna avgifter automatiskt
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Leverantör</Label>
<Select
value={umbrellaProvider}
onValueChange={setUmbrellaProvider}
>
<SelectTrigger>
<SelectValue placeholder="Välj leverantör" />
</SelectTrigger>
<SelectContent>
<SelectItem value="frilans_finans">Frilans Finans</SelectItem>
<SelectItem value="cool_company">Cool Company</SelectItem>
<SelectItem value="gigapay">Gigapay</SelectItem>
<SelectItem value="other">Annan</SelectItem>
</SelectContent>
</Select>
</div>
{umbrellaProvider && (
<>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="umbrella_fee_percent">Serviceavgift (%)</Label>
<Input
id="umbrella_fee_percent"
type="number"
step="0.01"
placeholder="T.ex. 6.00"
value={umbrellaFeePercent}
onChange={(e) => setUmbrellaFeePercent(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="umbrella_pension_percent">Pensionsavsättning (%)</Label>
<Input
id="umbrella_pension_percent"
type="number"
step="0.01"
placeholder="T.ex. 4.50"
value={umbrellaPensionPercent}
onChange={(e) => setUmbrellaPensionPercent(e.target.value)}
/>
</div>
</div>
<div className="flex items-center justify-between pt-2">
<div>
<Label>Anpassa avgifter</Label>
<p className="text-sm text-muted-foreground">
Åsidosätt standardavgifter med egna värden
</p>
</div>
<Switch
checked={umbrellaFeeCustom}
onCheckedChange={setUmbrellaFeeCustom}
/>
</div>
</>
)}
</CardContent>
</Card>
<div className="flex justify-end">
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
'Spara ändringar'
)}
</Button>
</div>
</div>
</form>
) : (
/* ---- EF/AB mode: Företagsuppgifter (existing) ---- */
<form onSubmit={handleSaveSettings}>
<Card>
<CardHeader>
@@ -660,7 +394,6 @@ export default function SettingsPage() {
</CardContent>
</Card>
</form>
)}
</TabsContent>
{/* Banking settings */}
@@ -751,47 +484,6 @@ export default function SettingsPage() {
</Card>
</TabsContent>
{/* Social media settings */}
<TabsContent value="social" className="space-y-6">
{/* Connected TikTok accounts */}
{tiktokAccounts.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Kopplade konton</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{tiktokAccounts.map((account) => (
<TikTokAccountCard
key={account.id}
account={account}
onDisconnect={fetchData}
onSync={fetchData}
/>
))}
</CardContent>
</Card>
)}
{/* Connect TikTok */}
{!tiktokAccounts.some(a => a.status === 'active') && (
<Card>
<CardHeader>
<CardTitle>Anslut TikTok</CardTitle>
<CardDescription>
Koppla ditt TikTok-konto för att se statistik och analysera kampanjprestanda
</CardDescription>
</CardHeader>
<CardContent>
<TikTokConnectButton />
<p className="text-sm text-muted-foreground mt-4">
Vi använder TikToks officiella API och begär endast läsrättigheter för statistik.
Vi kan aldrig posta eller ändra något ditt konto.
</p>
</CardContent>
</Card>
)}
</TabsContent>
{/* Notification settings */}
<TabsContent value="notifications">
<NotificationSettings />
-140
View File
@@ -1,140 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { useToast } from '@/components/ui/use-toast'
import ShadowLedgerForm from '@/components/shadow-ledger/ShadowLedgerForm'
import { createClient } from '@/lib/supabase/client'
import { ArrowLeft } from 'lucide-react'
import type { CreateShadowLedgerEntryInput } from '@/types'
interface UmbrellaSettings {
umbrella_provider: string | null
umbrella_fee_percent: number | null
umbrella_pension_percent: number | null
municipal_tax_rate: number | null
}
export default function NewShadowLedgerEntryPage() {
const router = useRouter()
const { toast } = useToast()
const [settings, setSettings] = useState<UmbrellaSettings | undefined>(undefined)
const [isLoadingSettings, setIsLoadingSettings] = useState(true)
const [isSubmitting, setIsSubmitting] = useState(false)
// Fetch umbrella settings from company_settings
useEffect(() => {
async function fetchSettings() {
try {
const supabase = createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return
const { data, error } = await supabase
.from('company_settings')
.select(
'umbrella_provider, umbrella_fee_percent, umbrella_pension_percent, municipal_tax_rate'
)
.eq('user_id', user.id)
.single()
if (error) {
console.error('Failed to fetch settings:', error)
return
}
if (data) {
setSettings({
umbrella_provider: data.umbrella_provider,
umbrella_fee_percent: data.umbrella_fee_percent,
umbrella_pension_percent: data.umbrella_pension_percent,
municipal_tax_rate: data.municipal_tax_rate,
})
}
} catch (error) {
console.error('Failed to load settings:', error)
} finally {
setIsLoadingSettings(false)
}
}
fetchSettings()
}, [])
const handleSubmit = async (data: CreateShadowLedgerEntryInput) => {
setIsSubmitting(true)
try {
const res = await fetch('/api/shadow-ledger', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) {
const error = await res.json()
throw new Error(error.error || 'Kunde inte spara post')
}
toast({
title: 'Post sparad',
description: data.description || 'Ny skuggbokf\u00f6ringspost skapad',
})
router.push('/shadow-ledger')
} catch (error) {
toast({
title: 'Fel',
description:
error instanceof Error ? error.message : 'Kunde inte spara post',
variant: 'destructive',
})
} finally {
setIsSubmitting(false)
}
}
return (
<div className="space-y-6">
{/* Back link */}
<Button variant="ghost" size="sm" asChild>
<Link href="/shadow-ledger">
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka till skuggbokf\u00f6ring
</Link>
</Button>
<Card className="max-w-2xl">
<CardHeader>
<CardTitle>Ny post</CardTitle>
<CardDescription>
Registrera en utbetalning, g\u00e5va eller annan transaktion
</CardDescription>
</CardHeader>
<CardContent>
{isLoadingSettings ? (
<div className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-3/4" />
</div>
) : (
<ShadowLedgerForm
onSubmit={handleSubmit}
isLoading={isSubmitting}
settings={settings}
/>
)}
</CardContent>
</Card>
</div>
)
}
-237
View File
@@ -1,237 +0,0 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { useToast } from '@/components/ui/use-toast'
import ShadowLedgerList from '@/components/shadow-ledger/ShadowLedgerList'
import { formatCurrency } from '@/lib/utils'
import {
Plus,
Wallet,
ArrowDownToLine,
Receipt,
ShieldAlert,
PiggyBank,
Landmark,
} from 'lucide-react'
import type { ShadowLedgerEntry, ShadowLedgerSummary } from '@/types'
export default function ShadowLedgerPage() {
const router = useRouter()
const searchParams = useSearchParams()
const { toast } = useToast()
// State
const [entries, setEntries] = useState<ShadowLedgerEntry[]>([])
const [summary, setSummary] = useState<ShadowLedgerSummary | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isDeleting, setIsDeleting] = useState(false)
// Year filter
const currentYear = new Date().getFullYear()
const [selectedYear, setSelectedYear] = useState(
searchParams.get('year') || currentYear.toString()
)
const years = Array.from({ length: 5 }, (_, i) => currentYear - i)
// Fetch entries + summary
const fetchData = useCallback(async () => {
setIsLoading(true)
try {
const [entriesRes, summaryRes] = await Promise.all([
fetch(`/api/shadow-ledger?year=${selectedYear}`),
fetch(`/api/shadow-ledger/summary?year=${selectedYear}`),
])
if (entriesRes.ok) {
const entriesData = await entriesRes.json()
setEntries(entriesData.data || [])
}
if (summaryRes.ok) {
const summaryData = await summaryRes.json()
setSummary(summaryData.data || null)
}
} catch (error) {
console.error('Failed to fetch shadow ledger:', error)
toast({
title: 'Fel',
description: 'Kunde inte h\u00e4mta skuggbokf\u00f6ring',
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}, [selectedYear, toast])
useEffect(() => {
fetchData()
}, [fetchData])
// Year change
const handleYearChange = (year: string) => {
setSelectedYear(year)
router.push(`/shadow-ledger?year=${year}`)
}
// Delete entry
const handleDelete = async (id: string) => {
setIsDeleting(true)
try {
const res = await fetch(`/api/shadow-ledger/${id}`, { method: 'DELETE' })
if (!res.ok) {
const error = await res.json()
throw new Error(error.error || 'Kunde inte ta bort post')
}
toast({ title: 'Post borttagen' })
fetchData()
} catch (error) {
toast({
title: 'Fel',
description:
error instanceof Error ? error.message : 'Kunde inte ta bort post',
variant: 'destructive',
})
} finally {
setIsDeleting(false)
}
}
// Summary cards data
const summaryCards = summary
? [
{
label: 'Brutto i \u00e5r',
value: formatCurrency(summary.total_gross),
icon: Wallet,
color: 'text-emerald-600',
},
{
label: 'Netto i \u00e5r',
value: formatCurrency(summary.total_net),
icon: ArrowDownToLine,
color: 'text-sky-600',
},
{
label: 'Avgifter betalda',
value: formatCurrency(summary.total_fees),
icon: Receipt,
color: 'text-amber-600',
},
{
label: 'Skatt inneh\u00e5llen',
value: formatCurrency(summary.total_tax_withheld),
icon: Landmark,
color: 'text-red-600',
},
{
label: 'Pension avsatt',
value: formatCurrency(summary.total_pension),
icon: PiggyBank,
color: 'text-violet-600',
},
{
label: 'Virtuell skatteskuld',
value: formatCurrency(summary.virtual_tax_debt),
icon: ShieldAlert,
color: 'text-destructive',
},
]
: []
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Skuggbokf\u00f6ring</h1>
<p className="text-muted-foreground">
\u00d6versikt \u00f6ver utbetalningar, avgifter och skatt
</p>
</div>
<div className="flex items-center gap-2">
<Select value={selectedYear} onValueChange={handleYearChange}>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{years.map((year) => (
<SelectItem key={year} value={year.toString()}>
{year}
</SelectItem>
))}
</SelectContent>
</Select>
<Button asChild>
<Link href="/shadow-ledger/new">
<Plus className="mr-2 h-4 w-4" />
Ny post
</Link>
</Button>
</div>
</div>
{/* Summary Cards */}
{isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[...Array(6)].map((_, i) => (
<Card key={i}>
<CardContent className="pt-6">
<Skeleton className="h-4 w-28 mb-2" />
<Skeleton className="h-8 w-36" />
</CardContent>
</Card>
))}
</div>
) : summary ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{summaryCards.map((card) => (
<Card key={card.label}>
<CardContent className="pt-6">
<div className="flex items-center gap-2 mb-1">
<card.icon className={`h-4 w-4 ${card.color}`} />
<span className="text-sm text-muted-foreground">
{card.label}
</span>
</div>
<p className="text-2xl font-bold">{card.value}</p>
</CardContent>
</Card>
))}
</div>
) : null}
{/* Entry List */}
{isLoading ? (
<div className="space-y-3">
{[...Array(3)].map((_, i) => (
<Card key={i}>
<CardContent className="pt-4">
<Skeleton className="h-6 w-48 mb-2" />
<Skeleton className="h-4 w-32" />
</CardContent>
</Card>
))}
</div>
) : (
<ShadowLedgerList
entries={entries}
onDelete={handleDelete}
isDeleting={isDeleting}
/>
)}
</div>
)
}
+11 -96
View File
@@ -14,10 +14,7 @@ import Step2CompanyDetails from '@/components/onboarding/Step2CompanyDetails'
import Step3TaxRegistration from '@/components/onboarding/Step3TaxRegistration'
import Step4PreliminaryTax from '@/components/onboarding/Step4PreliminaryTax'
import Step6ConnectBank from '@/components/onboarding/Step6ConnectBank'
import Step2LightPersonalInfo from '@/components/onboarding/Step2LightPersonalInfo'
import Step3LightTaxProfile from '@/components/onboarding/Step3LightTaxProfile'
const EF_AB_STEP_TITLES = [
const STEP_TITLES = [
'Verksamhetsform',
'Företagsuppgifter',
'Skatteregistrering',
@@ -25,13 +22,6 @@ const EF_AB_STEP_TITLES = [
'Anslut bank',
]
const LIGHT_STEP_TITLES = [
'Verksamhetsform',
'Dina uppgifter',
'Skatteprofil',
'Anslut bank',
]
export default function OnboardingPage() {
return (
<Suspense fallback={<div className="flex items-center justify-center h-64"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div>}>
@@ -51,9 +41,8 @@ function OnboardingPageContent() {
const [currentStep, setCurrentStep] = useState(1)
const [settings, setSettings] = useState<Partial<CompanySettings>>({})
const isLight = settings.entity_type === 'light'
const totalSteps = isLight ? 4 : 5
const stepTitles = isLight ? LIGHT_STEP_TITLES : EF_AB_STEP_TITLES
const totalSteps = 5
const stepTitles = STEP_TITLES
// Load existing settings on mount
useEffect(() => {
@@ -138,15 +127,12 @@ function OnboardingPageContent() {
}
const handleNext = async (stepData: Partial<CompanySettings>) => {
const entityType = stepData.entity_type || settings.entity_type
const isLightMode = entityType === 'light'
const stepsTotal = isLightMode ? 4 : 5
const nextStep = currentStep + 1
const success = await saveSettings(stepData, nextStep)
if (success) {
// After step 1 (entity type selection): seed chart of accounts (skip for light)
if (currentStep === 1 && stepData.entity_type && stepData.entity_type !== 'light') {
// After step 1 (entity type selection): seed chart of accounts
if (currentStep === 1 && stepData.entity_type) {
try {
const { data: { user } } = await supabase.auth.getUser()
if (user) {
@@ -160,8 +146,8 @@ function OnboardingPageContent() {
}
}
// After step 3 (tax registration for EF/AB): create initial fiscal period (skip for light)
if (currentStep === 3 && !isLightMode) {
// After step 3 (tax registration): create initial fiscal period
if (currentStep === 3) {
try {
const { data: { user } } = await supabase.auth.getUser()
if (user) {
@@ -195,8 +181,8 @@ function OnboardingPageContent() {
}
}
if (nextStep > stepsTotal) {
await saveSettings({ onboarding_complete: true }, stepsTotal)
if (nextStep > totalSteps) {
await saveSettings({ onboarding_complete: true }, totalSteps)
router.push('/')
} else {
setCurrentStep(nextStep)
@@ -241,78 +227,7 @@ function OnboardingPageContent() {
const progressPercent = ((currentStep - 1) / (totalSteps - 1)) * 100
// Render light mode steps
const renderLightSteps = () => (
<>
{currentStep === 1 && (
<Step1EntityType
initialData={{ entity_type: settings.entity_type as EntityType }}
onNext={(data) => handleNext(data)}
isSaving={isSaving}
/>
)}
{currentStep === 2 && (
<Step2LightPersonalInfo
initialData={{
company_name: settings.company_name ?? undefined,
}}
onNext={(data) => handleNext(data)}
onBack={handleBack}
isSaving={isSaving}
/>
)}
{currentStep === 3 && (
<Step3LightTaxProfile
initialData={{
municipality_code: settings.municipality_code ?? undefined,
municipal_tax_rate: settings.municipal_tax_rate ?? undefined,
church_tax: settings.church_tax ?? undefined,
church_tax_rate: settings.church_tax_rate ?? undefined,
church_parish_code: settings.church_parish_code ?? undefined,
umbrella_provider: settings.umbrella_provider ?? undefined,
umbrella_fee_percent: settings.umbrella_fee_percent ?? undefined,
umbrella_pension_percent: settings.umbrella_pension_percent ?? undefined,
umbrella_fee_custom: settings.umbrella_fee_custom ?? undefined,
}}
onNext={(data) => handleNext(data as Partial<CompanySettings>)}
onBack={handleBack}
isSaving={isSaving}
/>
)}
{currentStep === 4 && (
<Step6ConnectBank
initialData={{
bank_name: settings.bank_name ?? undefined,
clearing_number: settings.clearing_number ?? undefined,
account_number: settings.account_number ?? undefined,
iban: settings.iban ?? undefined,
bic: settings.bic ?? undefined,
}}
onComplete={async (data) => {
if (data) {
await saveSettings({ ...data, onboarding_complete: true })
} else {
await saveSettings({ onboarding_complete: true })
}
toast({
title: 'Välkommen!',
description: 'Din profil är nu redo.',
})
router.push('/')
}}
onBack={handleBack}
onSkip={handleComplete}
isSaving={isSaving}
/>
)}
</>
)
// Render EF/AB mode steps
const renderEfAbSteps = () => (
const renderSteps = () => (
<>
{currentStep === 1 && (
<Step1EntityType
@@ -413,7 +328,7 @@ function OnboardingPageContent() {
{/* Content */}
<div className="max-w-2xl mx-auto px-4 py-8">
{isLight ? renderLightSteps() : renderEfAbSteps()}
{renderSteps()}
</div>
</div>
)
-65
View File
@@ -1,65 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
/**
* GET /api/briefings/[id]/download
* Get a signed URL to download a PDF briefing
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Get the briefing
const { data: briefing, error: fetchError } = await supabase
.from('briefings')
.select('briefing_type, content, filename')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError) {
if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Briefing not found' }, { status: 404 })
}
return NextResponse.json({ error: fetchError.message }, { status: 500 })
}
// Verify it's a PDF type
if (briefing.briefing_type !== 'pdf') {
return NextResponse.json(
{ error: 'Download is only available for PDF briefings' },
{ status: 400 }
)
}
if (!briefing.content) {
return NextResponse.json(
{ error: 'No file path found for this briefing' },
{ status: 404 }
)
}
// Create signed URL (valid for 1 hour)
const { data: signedUrl, error: signError } = await supabase.storage
.from('contracts')
.createSignedUrl(briefing.content, 3600, {
download: briefing.filename || 'briefing.pdf',
})
if (signError) {
return NextResponse.json({ error: signError.message }, { status: 500 })
}
return NextResponse.json({ url: signedUrl.signedUrl })
}
-159
View File
@@ -1,159 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { CreateBriefingInput } from '@/types'
/**
* GET /api/briefings/[id]
* Get a single briefing
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase
.from('briefings')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (error) {
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Briefing not found' }, { status: 404 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* PATCH /api/briefings/[id]
* Update a briefing
*/
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify briefing exists and belongs to user
const { data: existing, error: fetchError } = await supabase
.from('briefings')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError) {
if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Briefing not found' }, { status: 404 })
}
return NextResponse.json({ error: fetchError.message }, { status: 500 })
}
const body: Partial<CreateBriefingInput> = await request.json()
// Build update object
const updateData: Record<string, unknown> = {}
if (body.title !== undefined) updateData.title = body.title
if (body.content !== undefined) updateData.content = body.content
if (body.text_content !== undefined) updateData.text_content = body.text_content
if (body.notes !== undefined) updateData.notes = body.notes
// Only allow updating certain fields for PDF type
if (existing.briefing_type === 'pdf') {
if (body.filename !== undefined) updateData.filename = body.filename
if (body.file_size !== undefined) updateData.file_size = body.file_size
if (body.mime_type !== undefined) updateData.mime_type = body.mime_type
}
// Update the briefing
const { data, error } = await supabase
.from('briefings')
.update(updateData)
.eq('id', id)
.eq('user_id', user.id)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* DELETE /api/briefings/[id]
* Delete a briefing
*/
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Get the briefing to check if we need to delete a file
const { data: briefing, error: fetchError } = await supabase
.from('briefings')
.select('briefing_type, content')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError) {
if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Briefing not found' }, { status: 404 })
}
return NextResponse.json({ error: fetchError.message }, { status: 500 })
}
// If it's a PDF, delete the file from storage
if (briefing.briefing_type === 'pdf' && briefing.content) {
await supabase.storage.from('contracts').remove([briefing.content])
}
// Delete the briefing
const { error } = await supabase
.from('briefings')
.delete()
.eq('id', id)
.eq('user_id', user.id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ success: true })
}
-57
View File
@@ -1,57 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import Anthropic from '@anthropic-ai/sdk'
const anthropic = new Anthropic()
export async function POST(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { text } = await request.json()
if (!text || typeof text !== 'string') {
return NextResponse.json({ error: 'Text is required' }, { status: 400 })
}
try {
const message = await anthropic.messages.create({
model: 'claude-sonnet-4-5-20250929',
max_tokens: 1024,
messages: [
{
role: 'user',
content: `Du är en assistent som hjälper influencers. Sammanfatta följande mailkonversation/text till en strukturerad briefing.
Formatera sammanfattningen med dessa rubriker (hoppa över de som inte nämns):
- **Varumärke/Kund**: Vilken kund eller varumärke det gäller
- **Vad ska göras**: Innehåll/publiceringar som förväntas
- **Deadlines**: Datum och tidsramar
- **Belopp**: Ersättning om nämnt
- **Övriga detaljer**: Annat viktigt
Texten att sammanfatta:
${text}`,
},
],
})
const summary = message.content[0].type === 'text' ? message.content[0].text : ''
return NextResponse.json({ data: { summary } })
} catch (error) {
console.error('Briefing summarization error:', error)
return NextResponse.json(
{ error: 'Failed to summarize briefing' },
{ status: 500 }
)
}
}
+3 -26
View File
@@ -61,9 +61,9 @@ export async function GET(
const endStr = endDate.toISOString().split('T')[0]
// Fetch relevant data based on feed options
const [deadlinesResult, invoicesResult, campaignsResult, exclusivitiesResult] = await Promise.all([
const [deadlinesResult, invoicesResult] = await Promise.all([
// Deadlines
(feed.include_tax_deadlines || feed.include_campaigns)
feed.include_tax_deadlines
? supabase
.from('deadlines')
.select('*')
@@ -83,25 +83,6 @@ export async function GET(
.lte('due_date', endStr)
.order('due_date')
: { data: [] },
// Campaigns with deliverables
feed.include_campaigns
? supabase
.from('campaigns')
.select('*, deliverables(*)')
.eq('user_id', feed.user_id)
.in('status', ['active', 'contracted', 'delivered'])
: { data: [] },
// Exclusivities
feed.include_exclusivity
? supabase
.from('exclusivities')
.select('*, campaign:campaigns(name)')
.eq('user_id', feed.user_id)
.gte('end_date', startStr)
.lte('start_date', endStr)
: { data: [] },
])
try {
@@ -109,21 +90,17 @@ export async function GET(
{
deadlines: deadlinesResult.data || [],
invoices: invoicesResult.data || [],
campaigns: campaignsResult.data || [],
exclusivities: exclusivitiesResult.data || [],
},
{
includeTaxDeadlines: feed.include_tax_deadlines,
includeInvoices: feed.include_invoices,
includeCampaigns: feed.include_campaigns,
includeExclusivity: feed.include_exclusivity,
}
)
return new NextResponse(icsContent, {
headers: {
'Content-Type': 'text/calendar; charset=utf-8',
'Content-Disposition': 'attachment; filename="influencer-biz.ics"',
'Content-Disposition': 'attachment; filename="erp-base.ics"',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
+4 -6
View File
@@ -27,7 +27,7 @@ export async function GET() {
}
// Generate the feed URL
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.influencer-biz.se'
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.erp-base.se'
if (feed) {
return NextResponse.json({
@@ -79,8 +79,6 @@ export async function POST() {
is_active: true,
include_tax_deadlines: true,
include_invoices: true,
include_campaigns: true,
include_exclusivity: false, // Avstängt som standard
})
.select()
.single()
@@ -89,7 +87,7 @@ export async function POST() {
return NextResponse.json({ error: error.message }, { status: 500 })
}
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.influencer-biz.se'
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.erp-base.se'
return NextResponse.json({
data: {
@@ -126,7 +124,7 @@ export async function PUT(request: Request) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.influencer-biz.se'
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.erp-base.se'
return NextResponse.json({
data: {
@@ -166,7 +164,7 @@ export async function DELETE() {
return NextResponse.json({ error: error.message }, { status: 500 })
}
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.influencer-biz.se'
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.erp-base.se'
return NextResponse.json({
data: {
-146
View File
@@ -1,146 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { CreateBriefingInput, BriefingType } from '@/types'
/**
* GET /api/campaigns/[id]/briefings
* List all briefings for a campaign
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id: campaignId } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify campaign belongs to user
const { data: campaign, error: campaignError } = await supabase
.from('campaigns')
.select('id')
.eq('id', campaignId)
.eq('user_id', user.id)
.single()
if (campaignError || !campaign) {
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
}
// Fetch briefings
const { data, error } = await supabase
.from('briefings')
.select('*')
.eq('campaign_id', campaignId)
.eq('user_id', user.id)
.order('created_at', { ascending: false })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* POST /api/campaigns/[id]/briefings
* Create a new briefing for a campaign
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id: campaignId } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify campaign belongs to user
const { data: campaign, error: campaignError } = await supabase
.from('campaigns')
.select('id')
.eq('id', campaignId)
.eq('user_id', user.id)
.single()
if (campaignError || !campaign) {
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
}
const body: Omit<CreateBriefingInput, 'campaign_id'> = await request.json()
// Validate required fields
if (!body.title || !body.briefing_type) {
return NextResponse.json(
{ error: 'Title and briefing_type are required' },
{ status: 400 }
)
}
// Validate briefing type
const validTypes: BriefingType[] = ['pdf', 'link', 'text']
if (!validTypes.includes(body.briefing_type)) {
return NextResponse.json(
{ error: 'Invalid briefing_type. Must be pdf, link, or text' },
{ status: 400 }
)
}
// Validate type-specific content
if (body.briefing_type === 'text' && !body.text_content) {
return NextResponse.json(
{ error: 'text_content is required for text type briefings' },
{ status: 400 }
)
}
if (body.briefing_type === 'link' && !body.content) {
return NextResponse.json(
{ error: 'content (URL) is required for link type briefings' },
{ status: 400 }
)
}
if (body.briefing_type === 'pdf' && !body.content) {
return NextResponse.json(
{ error: 'content (file path) is required for pdf type briefings' },
{ status: 400 }
)
}
// Create the briefing
const { data, error } = await supabase
.from('briefings')
.insert({
campaign_id: campaignId,
user_id: user.id,
briefing_type: body.briefing_type,
title: body.title,
content: body.content || null,
text_content: body.text_content || null,
filename: body.filename || null,
file_size: body.file_size || null,
mime_type: body.mime_type || null,
notes: body.notes || null,
})
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data }, { status: 201 })
}
@@ -1,117 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
/**
* POST /api/campaigns/[id]/briefings/upload
* Upload a PDF briefing file
* Expects multipart/form-data with:
* - file: the PDF file
* - title: briefing title
* - notes: optional notes
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id: campaignId } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify campaign exists and belongs to user
const { data: campaign, error: campaignError } = await supabase
.from('campaigns')
.select('id')
.eq('id', campaignId)
.eq('user_id', user.id)
.single()
if (campaignError || !campaign) {
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
}
try {
const formData = await request.formData()
const file = formData.get('file') as File | null
const title = formData.get('title') as string | null
const notes = formData.get('notes') as string | null
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
if (!title) {
return NextResponse.json({ error: 'Title is required' }, { status: 400 })
}
// Validate file type (only PDF)
if (file.type !== 'application/pdf') {
return NextResponse.json(
{ error: 'Invalid file type. Only PDF files are allowed.' },
{ status: 400 }
)
}
// Max file size: 10MB
const maxSize = 10 * 1024 * 1024
if (file.size > maxSize) {
return NextResponse.json(
{ error: 'File too large. Max size: 10MB' },
{ status: 400 }
)
}
// Generate unique file path in contracts bucket (reusing existing bucket)
// Path: {user_id}/{campaign_id}/briefings/{timestamp}_{filename}
const timestamp = Date.now()
const safeFilename = file.name.replace(/[^a-zA-Z0-9.-]/g, '_')
const filePath = `${user.id}/${campaignId}/briefings/${timestamp}_${safeFilename}`
// Upload to Supabase Storage (using contracts bucket)
const { error: uploadError } = await supabase.storage
.from('contracts')
.upload(filePath, file, {
cacheControl: '3600',
upsert: false,
})
if (uploadError) {
console.error('Upload error:', uploadError)
return NextResponse.json({ error: 'Failed to upload file' }, { status: 500 })
}
// Create briefing record
const { data, error } = await supabase
.from('briefings')
.insert({
user_id: user.id,
campaign_id: campaignId,
briefing_type: 'pdf',
title: title.trim(),
content: filePath,
filename: file.name,
file_size: file.size,
mime_type: file.type,
notes: notes?.trim() || null,
})
.select()
.single()
if (error) {
// Try to clean up uploaded file
await supabase.storage.from('contracts').remove([filePath])
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data }, { status: 201 })
} catch (err) {
console.error('Briefing upload error:', err)
return NextResponse.json({ error: 'Failed to process upload' }, { status: 500 })
}
}
-185
View File
@@ -1,185 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
/**
* GET /api/campaigns/[id]/contracts
* List contracts for a campaign
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id: campaignId } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify campaign exists and belongs to user
const { data: campaign, error: campaignError } = await supabase
.from('campaigns')
.select('id')
.eq('id', campaignId)
.eq('user_id', user.id)
.single()
if (campaignError || !campaign) {
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
}
const { data, error } = await supabase
.from('contracts')
.select('*')
.eq('campaign_id', campaignId)
.order('uploaded_at', { ascending: false })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* POST /api/campaigns/[id]/contracts
* Upload a contract to a campaign
* Expects multipart/form-data with:
* - file: the contract file
* - signing_date: optional ISO date string
* - is_primary: optional boolean
* - notes: optional string
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id: campaignId } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify campaign exists and belongs to user
const { data: campaign, error: campaignError } = await supabase
.from('campaigns')
.select('id, name')
.eq('id', campaignId)
.eq('user_id', user.id)
.single()
if (campaignError || !campaign) {
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
}
try {
const formData = await request.formData()
const file = formData.get('file') as File | null
const signingDate = formData.get('signing_date') as string | null
const isPrimary = formData.get('is_primary') === 'true'
const notes = formData.get('notes') as string | null
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
// Validate file type (PDF, DOC, DOCX, images)
const allowedTypes = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'image/jpeg',
'image/png',
'image/webp'
]
if (!allowedTypes.includes(file.type)) {
return NextResponse.json({
error: 'Invalid file type. Allowed: PDF, DOC, DOCX, JPG, PNG, WEBP'
}, { status: 400 })
}
// Max file size: 10MB
const maxSize = 10 * 1024 * 1024
if (file.size > maxSize) {
return NextResponse.json({ error: 'File too large. Max size: 10MB' }, { status: 400 })
}
// Generate unique file path
const timestamp = Date.now()
const safeFilename = file.name.replace(/[^a-zA-Z0-9.-]/g, '_')
const filePath = `${user.id}/${campaignId}/${timestamp}_${safeFilename}`
// Upload to Supabase Storage
const { error: uploadError } = await supabase.storage
.from('contracts')
.upload(filePath, file, {
cacheControl: '3600',
upsert: false
})
if (uploadError) {
console.error('Upload error:', uploadError)
return NextResponse.json({ error: 'Failed to upload file' }, { status: 500 })
}
// If this is set as primary, unset other primary contracts
if (isPrimary) {
await supabase
.from('contracts')
.update({ is_primary: false })
.eq('campaign_id', campaignId)
.eq('is_primary', true)
}
// Create contract record
const { data, error } = await supabase
.from('contracts')
.insert({
user_id: user.id,
campaign_id: campaignId,
filename: file.name,
file_path: filePath,
file_size: file.size,
mime_type: file.type,
signing_date: signingDate || null,
is_primary: isPrimary,
notes: notes || null,
extraction_status: 'pending'
})
.select()
.single()
if (error) {
// Try to clean up uploaded file
await supabase.storage.from('contracts').remove([filePath])
return NextResponse.json({ error: error.message }, { status: 500 })
}
// If contract is signed and this is primary, update campaign status
if (isPrimary && signingDate) {
await supabase
.from('campaigns')
.update({
contract_signed_at: signingDate,
status: 'contracted'
})
.eq('id', campaignId)
.eq('status', 'negotiation')
}
return NextResponse.json({ data })
} catch (err) {
console.error('Contract upload error:', err)
return NextResponse.json({ error: 'Failed to process upload' }, { status: 500 })
}
}
@@ -1,126 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { CreateDeliverableInput } from '@/types'
/**
* GET /api/campaigns/[id]/deliverables
* List deliverables for a campaign
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id: campaignId } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify campaign exists and belongs to user
const { data: campaign, error: campaignError } = await supabase
.from('campaigns')
.select('id')
.eq('id', campaignId)
.eq('user_id', user.id)
.single()
if (campaignError || !campaign) {
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
}
const { data, error } = await supabase
.from('deliverables')
.select('*')
.eq('campaign_id', campaignId)
.order('due_date', { ascending: true, nullsFirst: false })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* POST /api/campaigns/[id]/deliverables
* Add a deliverable to a campaign
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id: campaignId } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify campaign exists and belongs to user
const { data: campaign, error: campaignError } = await supabase
.from('campaigns')
.select('id')
.eq('id', campaignId)
.eq('user_id', user.id)
.single()
if (campaignError || !campaign) {
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
}
const body: Omit<CreateDeliverableInput, 'campaign_id'> = await request.json()
// Validate required fields
if (!body.title || !body.deliverable_type || !body.platform) {
return NextResponse.json({ error: 'Missing required fields (title, deliverable_type, platform)' }, { status: 400 })
}
// Insert the deliverable
const { data, error } = await supabase
.from('deliverables')
.insert({
user_id: user.id,
campaign_id: campaignId,
title: body.title,
deliverable_type: body.deliverable_type,
platform: body.platform,
account_handle: body.account_handle || null,
quantity: body.quantity || 1,
description: body.description || null,
specifications: body.specifications || {},
due_date: body.due_date || null,
status: 'pending',
notes: body.notes || null,
})
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Optionally auto-generate a deadline for this deliverable
if (body.due_date) {
await supabase.from('deadlines').insert({
user_id: user.id,
campaign_id: campaignId,
deliverable_id: data.id,
title: `Leverans: ${body.title}`,
due_date: body.due_date,
deadline_type: 'delivery',
priority: 'important',
is_auto_generated: true,
})
}
return NextResponse.json({ data })
}
@@ -1,159 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { CreateExclusivityInput } from '@/types'
/**
* GET /api/campaigns/[id]/exclusivities
* List exclusivities for a campaign
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id: campaignId } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify campaign exists and belongs to user
const { data: campaign, error: campaignError } = await supabase
.from('campaigns')
.select('id')
.eq('id', campaignId)
.eq('user_id', user.id)
.single()
if (campaignError || !campaign) {
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
}
const { data, error } = await supabase
.from('exclusivities')
.select('*')
.eq('campaign_id', campaignId)
.order('start_date', { ascending: true })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* POST /api/campaigns/[id]/exclusivities
* Add an exclusivity to a campaign
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id: campaignId } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify campaign exists and belongs to user
const { data: campaign, error: campaignError } = await supabase
.from('campaigns')
.select('id, name')
.eq('id', campaignId)
.eq('user_id', user.id)
.single()
if (campaignError || !campaign) {
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
}
const body: Omit<CreateExclusivityInput, 'campaign_id'> = await request.json()
// Validate required fields
if (!body.categories || body.categories.length === 0 || !body.start_date || !body.end_date) {
return NextResponse.json({ error: 'Missing required fields (categories, start_date, end_date)' }, { status: 400 })
}
// Validate date range
if (new Date(body.end_date) < new Date(body.start_date)) {
return NextResponse.json({ error: 'End date must be after start date' }, { status: 400 })
}
// Check for conflicts with existing exclusivities
const { data: existingExclusivities } = await supabase
.from('exclusivities')
.select(`
*,
campaign:campaigns(id, name, customer_id)
`)
.eq('user_id', user.id)
.neq('campaign_id', campaignId)
.lte('start_date', body.end_date)
.gte('end_date', body.start_date)
const conflicts = []
if (existingExclusivities) {
for (const existing of existingExclusivities) {
const overlappingCategories = body.categories.filter(cat =>
existing.categories.some((existingCat: string) =>
existingCat.toLowerCase() === cat.toLowerCase()
)
)
if (overlappingCategories.length > 0) {
conflicts.push({
exclusivity_id: existing.id,
campaign_id: existing.campaign?.id,
campaign_name: existing.campaign?.name,
overlapping_categories: overlappingCategories,
overlap_start: body.start_date > existing.start_date ? body.start_date : existing.start_date,
overlap_end: body.end_date < existing.end_date ? body.end_date : existing.end_date
})
}
}
}
// Insert the exclusivity
const { data, error } = await supabase
.from('exclusivities')
.insert({
user_id: user.id,
campaign_id: campaignId,
categories: body.categories,
excluded_brands: body.excluded_brands || [],
start_date: body.start_date,
end_date: body.end_date,
start_calculation_type: body.start_calculation_type || 'absolute',
end_calculation_type: body.end_calculation_type || 'absolute',
start_reference: body.start_reference || null,
end_reference: body.end_reference || null,
start_offset_days: body.start_offset_days || null,
end_offset_days: body.end_offset_days || null,
notes: body.notes || null,
})
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Return with conflict warnings if any
return NextResponse.json({
data,
conflicts: conflicts.length > 0 ? conflicts : undefined,
warning: conflicts.length > 0
? `Varning: ${conflicts.length} överlappande exklusivitet(er) hittades`
: undefined
})
}
-204
View File
@@ -1,204 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { CreateCampaignInput, CampaignStatus } from '@/types'
/**
* GET /api/campaigns/[id]
* Get a single campaign with all related data
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase
.from('campaigns')
.select(`
*,
customer:customers!campaigns_customer_id_fkey(id, name, email, customer_category, customer_type),
end_customer:customers!campaigns_end_customer_id_fkey(id, name),
deliverables(
id, title, description, deliverable_type, platform, account_handle,
quantity, specifications, due_date, status, submitted_at, approved_at,
published_at, notes, created_at
),
exclusivities(
id, categories, excluded_brands, start_date, end_date,
start_calculation_type, end_calculation_type, notes, created_at
),
contracts(
id, filename, file_path, file_size, mime_type, signing_date,
is_primary, extraction_status, notes, uploaded_at
),
briefings(
id, briefing_type, title, content, text_content, filename,
file_size, mime_type, notes, created_at, updated_at
)
`)
.eq('id', id)
.eq('user_id', user.id)
.single()
if (error) {
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Also fetch related invoices
const { data: invoices } = await supabase
.from('invoices')
.select('id, invoice_number, invoice_date, due_date, status, total, currency, payment_status')
.eq('campaign_id', id)
.eq('user_id', user.id)
.order('invoice_date', { ascending: false })
return NextResponse.json({ data: { ...data, invoices: invoices || [] } })
}
/**
* PATCH /api/campaigns/[id]
* Update a campaign
*/
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: Partial<CreateCampaignInput> & { status?: CampaignStatus; contract_signed_at?: string } = await request.json()
// Verify campaign exists and belongs to user
const { data: existing, error: fetchError } = await supabase
.from('campaigns')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError) {
if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
}
return NextResponse.json({ error: fetchError.message }, { status: 500 })
}
// Validate customer if changed
if (body.customer_id && body.customer_id !== existing.customer_id) {
const { data: customer, error: customerError } = await supabase
.from('customers')
.select('id')
.eq('id', body.customer_id)
.eq('user_id', user.id)
.single()
if (customerError || !customer) {
return NextResponse.json({ error: 'Customer not found' }, { status: 404 })
}
}
// Build update object
const updateData: Record<string, unknown> = {}
if (body.name !== undefined) updateData.name = body.name
if (body.description !== undefined) updateData.description = body.description
if (body.customer_id !== undefined) updateData.customer_id = body.customer_id || null
if (body.end_customer_id !== undefined) updateData.end_customer_id = body.end_customer_id || null
if (body.campaign_type !== undefined) updateData.campaign_type = body.campaign_type
if (body.status !== undefined) updateData.status = body.status
if (body.total_value !== undefined) updateData.total_value = body.total_value
if (body.currency !== undefined) updateData.currency = body.currency
if (body.vat_included !== undefined) updateData.vat_included = body.vat_included
if (body.payment_terms !== undefined) updateData.payment_terms = body.payment_terms
if (body.billing_frequency !== undefined) updateData.billing_frequency = body.billing_frequency
if (body.brand_name !== undefined) updateData.brand_name = body.brand_name
if (body.start_date !== undefined) updateData.start_date = body.start_date
if (body.end_date !== undefined) updateData.end_date = body.end_date
if (body.publication_date !== undefined) updateData.publication_date = body.publication_date
if (body.draft_deadline !== undefined) updateData.draft_deadline = body.draft_deadline
if (body.contract_signed_at !== undefined) updateData.contract_signed_at = body.contract_signed_at
if (body.notes !== undefined) updateData.notes = body.notes
// Update the campaign
const { data, error } = await supabase
.from('campaigns')
.update(updateData)
.eq('id', id)
.eq('user_id', user.id)
.select(`
*,
customer:customers!campaigns_customer_id_fkey(id, name),
end_customer:customers!campaigns_end_customer_id_fkey(id, name)
`)
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* DELETE /api/campaigns/[id]
* Delete a campaign (cascades to deliverables, exclusivities, contracts)
*/
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// First, delete any contract files from storage
const { data: contracts } = await supabase
.from('contracts')
.select('file_path')
.eq('campaign_id', id)
if (contracts && contracts.length > 0) {
const filePaths = contracts.map(c => c.file_path)
await supabase.storage.from('contracts').remove(filePaths)
}
// Delete the campaign (cascades to related tables)
const { error } = await supabase
.from('campaigns')
.delete()
.eq('id', id)
.eq('user_id', user.id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ success: true })
}
-321
View File
@@ -1,321 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { checkExclusivityConflicts } from '@/lib/campaigns/exclusivity-checker'
import type {
ContractExtractionResult,
CreateCampaignInput,
CreateDeliverableInput,
CreateExclusivityInput,
Exclusivity,
} from '@/types'
interface CreateFromContractInput {
contractId: string
extraction: ContractExtractionResult
customerId: string | null
endCustomerId: string | null
brandName?: string | null
createNewCustomer?: {
name: string
org_number?: string
email?: string
customer_type: 'individual' | 'swedish_business' | 'eu_business' | 'non_eu_business'
}
createNewEndCustomer?: {
name: string
org_number?: string
email?: string
customer_type: 'individual' | 'swedish_business' | 'eu_business' | 'non_eu_business'
}
overrides?: Partial<CreateCampaignInput>
}
/**
* POST /api/campaigns/from-contract
* Create a campaign from extracted contract data
*/
export async function POST(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: CreateFromContractInput = await request.json()
const { contractId, extraction, overrides } = body
let { customerId, endCustomerId } = body
// Verify contract exists and belongs to user
const { data: contract, error: contractError } = await supabase
.from('contracts')
.select('*')
.eq('id', contractId)
.eq('user_id', user.id)
.single()
if (contractError) {
return NextResponse.json({ error: 'Contract not found' }, { status: 404 })
}
try {
// Create new customers if needed
if (body.createNewCustomer && !customerId) {
const { data: newCustomer, error: customerError } = await supabase
.from('customers')
.insert({
user_id: user.id,
name: body.createNewCustomer.name,
org_number: body.createNewCustomer.org_number,
email: body.createNewCustomer.email,
customer_type: body.createNewCustomer.customer_type,
country: 'Sweden',
default_payment_terms: extraction.financials.paymentTerms || 30,
})
.select()
.single()
if (customerError) {
throw new Error(`Failed to create customer: ${customerError.message}`)
}
customerId = newCustomer.id
}
if (body.createNewEndCustomer && !endCustomerId) {
const { data: newEndCustomer, error: endCustomerError } = await supabase
.from('customers')
.insert({
user_id: user.id,
name: body.createNewEndCustomer.name,
org_number: body.createNewEndCustomer.org_number,
email: body.createNewEndCustomer.email,
customer_type: body.createNewEndCustomer.customer_type,
country: 'Sweden',
default_payment_terms: 30,
})
.select()
.single()
if (endCustomerError) {
throw new Error(`Failed to create end customer: ${endCustomerError.message}`)
}
endCustomerId = newEndCustomer.id
}
// Create campaign
const campaignData: CreateCampaignInput = {
customer_id: customerId || undefined,
end_customer_id: endCustomerId || undefined,
name: extraction.campaignName || `Samarbete ${new Date().toLocaleDateString('sv-SE')}`,
brand_name: body.brandName || extraction.parties.brand?.name || undefined,
campaign_type: 'influencer',
total_value: extraction.financials.amount || undefined,
currency: extraction.financials.currency || 'SEK',
vat_included: extraction.financials.vatIncluded ?? false,
payment_terms: extraction.financials.paymentTerms || undefined,
billing_frequency: extraction.financials.billingFrequency || undefined,
start_date: extraction.period.startDate || undefined,
end_date: extraction.period.endDate || undefined,
publication_date: extraction.period.publicationDate || undefined,
draft_deadline: (extraction.period as Record<string, unknown>).draftDeadline as string || undefined,
...overrides,
}
const { data: campaign, error: campaignError } = await supabase
.from('campaigns')
.insert({
user_id: user.id,
...campaignData,
status: 'contracted',
contract_signed_at: extraction.signingDate || new Date().toISOString().split('T')[0],
})
.select()
.single()
if (campaignError) {
throw new Error(`Failed to create campaign: ${campaignError.message}`)
}
// Link contract to campaign
await supabase
.from('contracts')
.update({
campaign_id: campaign.id,
is_primary: true,
extraction_status: 'completed',
})
.eq('id', contractId)
// Create deliverables
const deliverables: { id: string }[] = []
for (const del of extraction.deliverables) {
const deliverableData: CreateDeliverableInput = {
campaign_id: campaign.id,
title: del.description || `${del.type} - ${del.platform || 'Okänd plattform'}`,
deliverable_type: del.type,
platform: del.platform || 'instagram',
account_handle: del.account || undefined,
quantity: del.quantity,
due_date: del.dueDate || undefined,
}
const { data: deliverable, error: deliverableError } = await supabase
.from('deliverables')
.insert({
user_id: user.id,
...deliverableData,
status: 'pending',
})
.select('id')
.single()
if (deliverableError) {
console.error('Failed to create deliverable:', deliverableError)
continue
}
deliverables.push(deliverable)
}
// Create exclusivity if present
let exclusivityConflicts: unknown[] = []
if (extraction.exclusivity.categories.length > 0) {
// Calculate exclusivity dates
const exclusivityStart = extraction.period.startDate ||
new Date().toISOString().split('T')[0]
let exclusivityEnd = extraction.period.endDate ||
new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
// Extend end date if post period specified
if (extraction.exclusivity.postPeriodDays) {
const endDate = new Date(exclusivityEnd)
endDate.setDate(endDate.getDate() + extraction.exclusivity.postPeriodDays)
exclusivityEnd = endDate.toISOString().split('T')[0]
}
// Check for conflicts
const { data: existingExclusivities } = await supabase
.from('exclusivities')
.select('*, campaign:campaigns(*)')
.eq('user_id', user.id)
.neq('campaign_id', campaign.id)
if (existingExclusivities) {
exclusivityConflicts = checkExclusivityConflicts(
{
categories: extraction.exclusivity.categories,
start_date: exclusivityStart,
end_date: exclusivityEnd,
} as Exclusivity,
existingExclusivities as Exclusivity[]
)
}
const exclusivityData: CreateExclusivityInput = {
campaign_id: campaign.id,
categories: extraction.exclusivity.categories,
excluded_brands: extraction.exclusivity.excludedBrands,
start_date: exclusivityStart,
end_date: exclusivityEnd,
start_calculation_type: 'absolute',
end_calculation_type: extraction.exclusivity.postReference ? 'relative' : 'absolute',
end_reference: extraction.exclusivity.postReference || undefined,
end_offset_days: extraction.exclusivity.postPeriodDays || undefined,
}
await supabase
.from('exclusivities')
.insert({
user_id: user.id,
...exclusivityData,
})
}
// Create deadlines
const createdDeadlines: unknown[] = []
for (const deadline of extraction.deadlines) {
let dueDate = deadline.absoluteDate
// Calculate relative dates if possible
if (!dueDate && deadline.isRelative && deadline.referenceEvent && deadline.offsetDays) {
let referenceDate: string | null = null
switch (deadline.referenceEvent) {
case 'publication':
referenceDate = extraction.period.publicationDate
break
case 'delivery':
referenceDate = extraction.period.endDate
break
case 'contract':
referenceDate = extraction.signingDate
break
}
if (referenceDate) {
const refDate = new Date(referenceDate)
refDate.setDate(refDate.getDate() + deadline.offsetDays)
dueDate = refDate.toISOString().split('T')[0]
}
}
// Skip if we still don't have a date
if (!dueDate) continue
const { data: createdDeadline, error: deadlineError } = await supabase
.from('deadlines')
.insert({
user_id: user.id,
title: deadline.description,
due_date: dueDate,
deadline_type: deadline.type,
priority: 'normal',
customer_id: customerId,
campaign_id: campaign.id,
is_auto_generated: true,
date_calculation_type: deadline.isRelative ? 'relative' : 'absolute',
reference_event: deadline.referenceEvent,
offset_days: deadline.offsetDays,
})
.select()
.single()
if (!deadlineError && createdDeadline) {
createdDeadlines.push(createdDeadline)
}
}
// Also create standard campaign deadlines
if (campaign.end_date) {
// Invoicing deadline (5 days after end)
const invoiceDate = new Date(campaign.end_date)
invoiceDate.setDate(invoiceDate.getDate() + 5)
await supabase.from('deadlines').insert({
user_id: user.id,
title: `Fakturera: ${campaign.name}`,
due_date: invoiceDate.toISOString().split('T')[0],
deadline_type: 'invoicing',
priority: 'important',
customer_id: customerId,
campaign_id: campaign.id,
is_auto_generated: true,
})
}
return NextResponse.json({
data: {
campaign,
deliverablesCreated: deliverables.length,
deadlinesCreated: createdDeadlines.length,
exclusivityConflicts,
},
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create campaign'
return NextResponse.json({ error: message }, { status: 500 })
}
}
-173
View File
@@ -1,173 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { CreateCampaignInput } from '@/types'
/**
* GET /api/campaigns
* List campaigns for the authenticated user
* Query params:
* - status: CampaignStatus (optional, comma-separated for multiple)
* - type: CampaignType (optional)
* - customer_id: string (optional)
* - from: ISO date string (optional, start_date >=)
* - to: ISO date string (optional, end_date <=)
* - limit: number (default: 50)
* - offset: number (default: 0)
*/
export async function GET(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Parse query params
const { searchParams } = new URL(request.url)
const status = searchParams.get('status')
const type = searchParams.get('type')
const customerId = searchParams.get('customer_id')
const from = searchParams.get('from')
const to = searchParams.get('to')
const limit = parseInt(searchParams.get('limit') || '50')
const offset = parseInt(searchParams.get('offset') || '0')
// Build query with relations
let query = supabase
.from('campaigns')
.select(`
*,
customer:customers!campaigns_customer_id_fkey(id, name, customer_category),
end_customer:customers!campaigns_end_customer_id_fkey(id, name),
deliverables(id, title, status, due_date, platform, deliverable_type),
exclusivities(id, categories, start_date, end_date),
contracts(id, filename, is_primary)
`, { count: 'exact' })
.eq('user_id', user.id)
// Apply filters
if (status) {
const statuses = status.split(',')
if (statuses.length === 1) {
query = query.eq('status', status)
} else {
query = query.in('status', statuses)
}
}
if (type) {
query = query.eq('campaign_type', type)
}
if (customerId) {
query = query.eq('customer_id', customerId)
}
if (from) {
query = query.gte('start_date', from)
}
if (to) {
query = query.lte('end_date', to)
}
const { data, error, count } = await query
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data, count })
}
/**
* POST /api/campaigns
* Create a new campaign
*/
export async function POST(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: CreateCampaignInput = await request.json()
// Validate required fields
if (!body.name) {
return NextResponse.json({ error: 'Campaign name is required' }, { status: 400 })
}
// Validate customer exists if provided
if (body.customer_id) {
const { data: customer, error: customerError } = await supabase
.from('customers')
.select('id')
.eq('id', body.customer_id)
.eq('user_id', user.id)
.single()
if (customerError || !customer) {
return NextResponse.json({ error: 'Customer not found' }, { status: 404 })
}
}
// Validate end_customer exists if provided
if (body.end_customer_id) {
const { data: endCustomer, error: endCustomerError } = await supabase
.from('customers')
.select('id')
.eq('id', body.end_customer_id)
.eq('user_id', user.id)
.single()
if (endCustomerError || !endCustomer) {
return NextResponse.json({ error: 'End customer not found' }, { status: 404 })
}
}
// Insert the campaign
const { data, error } = await supabase
.from('campaigns')
.insert({
user_id: user.id,
customer_id: body.customer_id || null,
end_customer_id: body.end_customer_id || null,
name: body.name,
description: body.description || null,
brand_name: body.brand_name || null,
campaign_type: body.campaign_type || 'influencer',
status: 'negotiation',
total_value: body.total_value || null,
currency: body.currency || 'SEK',
vat_included: body.vat_included || false,
payment_terms: body.payment_terms || null,
billing_frequency: body.billing_frequency || null,
start_date: body.start_date || null,
end_date: body.end_date || null,
publication_date: body.publication_date || null,
draft_deadline: body.draft_deadline || null,
notes: body.notes || null,
})
.select(`
*,
customer:customers!campaigns_customer_id_fkey(id, name),
end_customer:customers!campaigns_end_customer_id_fkey(id, name)
`)
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
-144
View File
@@ -1,144 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { PlatformType, DeliverableType, Deliverable } from '@/types'
interface WorkloadDay {
date: string
deliverables: Deliverable[]
totalDeliverables: number
byPlatform: Partial<Record<PlatformType, number>>
byType: Partial<Record<DeliverableType, number>>
workloadLevel: 'light' | 'normal' | 'heavy' | 'overloaded'
}
/**
* GET /api/campaigns/workload
* Get workload analysis for deliverables
* Query params:
* - from: ISO date string (default: today)
* - to: ISO date string (default: 30 days from now)
* - group_by: 'day' | 'week' (default: 'day')
*/
export async function GET(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const from = searchParams.get('from') || new Date().toISOString().split('T')[0]
const defaultTo = new Date()
defaultTo.setDate(defaultTo.getDate() + 30)
const to = searchParams.get('to') || defaultTo.toISOString().split('T')[0]
const groupBy = searchParams.get('group_by') || 'day'
// Fetch deliverables with due dates in range
const { data: deliverables, error } = await supabase
.from('deliverables')
.select(`
*,
campaign:campaigns(id, name, customer_id, customer:customers(id, name))
`)
.eq('user_id', user.id)
.gte('due_date', from)
.lte('due_date', to)
.not('status', 'in', '("approved","published")')
.order('due_date', { ascending: true })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Group deliverables by date
const workloadByDate: Record<string, WorkloadDay> = {}
for (const deliverable of deliverables || []) {
if (!deliverable.due_date) continue
let dateKey = deliverable.due_date
// If grouping by week, use the Monday of that week
if (groupBy === 'week') {
const date = new Date(deliverable.due_date)
const day = date.getDay()
const diff = date.getDate() - day + (day === 0 ? -6 : 1)
date.setDate(diff)
dateKey = date.toISOString().split('T')[0]
}
if (!workloadByDate[dateKey]) {
workloadByDate[dateKey] = {
date: dateKey,
deliverables: [],
totalDeliverables: 0,
byPlatform: {},
byType: {},
workloadLevel: 'light'
}
}
const day = workloadByDate[dateKey]
day.deliverables.push(deliverable)
day.totalDeliverables += deliverable.quantity || 1
// Count by platform
const platform = deliverable.platform as PlatformType
day.byPlatform[platform] = (day.byPlatform[platform] || 0) + (deliverable.quantity || 1)
// Count by type
const type = deliverable.deliverable_type as DeliverableType
day.byType[type] = (day.byType[type] || 0) + (deliverable.quantity || 1)
}
// Calculate workload levels
for (const day of Object.values(workloadByDate)) {
if (groupBy === 'week') {
// Weekly thresholds
if (day.totalDeliverables <= 3) day.workloadLevel = 'light'
else if (day.totalDeliverables <= 7) day.workloadLevel = 'normal'
else if (day.totalDeliverables <= 12) day.workloadLevel = 'heavy'
else day.workloadLevel = 'overloaded'
} else {
// Daily thresholds
if (day.totalDeliverables <= 1) day.workloadLevel = 'light'
else if (day.totalDeliverables <= 2) day.workloadLevel = 'normal'
else if (day.totalDeliverables <= 4) day.workloadLevel = 'heavy'
else day.workloadLevel = 'overloaded'
}
}
// Convert to sorted array
const workload = Object.values(workloadByDate).sort((a, b) =>
a.date.localeCompare(b.date)
)
// Calculate summary stats
const totalDeliverables = deliverables?.reduce((sum, d) => sum + (d.quantity || 1), 0) || 0
const heavyDays = workload.filter(d => d.workloadLevel === 'heavy').length
const overloadedDays = workload.filter(d => d.workloadLevel === 'overloaded').length
// Find busiest day/week
const busiestPeriod = workload.length > 0
? workload.reduce((max, day) => day.totalDeliverables > max.totalDeliverables ? day : max)
: null
return NextResponse.json({
workload,
summary: {
period: { from, to, group_by: groupBy },
total_deliverables: totalDeliverables,
total_periods: workload.length,
heavy_periods: heavyDays,
overloaded_periods: overloadedDays,
busiest_period: busiestPeriod ? {
date: busiestPeriod.date,
count: busiestPeriod.totalDeliverables
} : null
}
})
}
-55
View File
@@ -1,55 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
/**
* GET /api/contracts/[id]/download
* Download a contract file
* Returns a signed URL for direct download
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Get contract to verify ownership and get file path
const { data: contract, error: fetchError } = await supabase
.from('contracts')
.select('file_path, filename, mime_type')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError) {
if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Contract not found' }, { status: 404 })
}
return NextResponse.json({ error: fetchError.message }, { status: 500 })
}
// Create signed URL (valid for 1 hour)
const { data: signedUrl, error: signError } = await supabase.storage
.from('contracts')
.createSignedUrl(contract.file_path, 3600, {
download: contract.filename
})
if (signError || !signedUrl) {
return NextResponse.json({ error: 'Failed to generate download URL' }, { status: 500 })
}
return NextResponse.json({
url: signedUrl.signedUrl,
filename: contract.filename,
mime_type: contract.mime_type
})
}
-244
View File
@@ -1,244 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { pdfToBase64, getPDFInfo } from '@/lib/contracts/pdf-extractor'
import { analyzeContract } from '@/lib/contracts/contract-analyzer'
import { matchParties } from '@/lib/customers/customer-matcher'
import type { ContractExtractionResult, Customer } from '@/types'
// Rate limiting map (in production, use Redis or similar)
const extractionTimestamps = new Map<string, number[]>()
const RATE_LIMIT = 10 // Max extractions per minute
const RATE_WINDOW_MS = 60 * 1000 // 1 minute
/**
* POST /api/contracts/[id]/extract
* Extract and analyze contract content using AI
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
// Authenticate user
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Rate limiting
const now = Date.now()
const userTimestamps = extractionTimestamps.get(user.id) || []
const recentTimestamps = userTimestamps.filter((t) => now - t < RATE_WINDOW_MS)
if (recentTimestamps.length >= RATE_LIMIT) {
return NextResponse.json(
{ error: 'Rate limit exceeded. Please try again later.' },
{ status: 429 }
)
}
// Record this extraction attempt
recentTimestamps.push(now)
extractionTimestamps.set(user.id, recentTimestamps)
// Get contract
const { data: contract, error: fetchError } = await supabase
.from('contracts')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError) {
if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Contract not found' }, { status: 404 })
}
return NextResponse.json({ error: fetchError.message }, { status: 500 })
}
// Check if already extracted
if (contract.extraction_status === 'completed' && contract.extracted_data) {
// Return cached result
const customers = await fetchUserCustomers(supabase, user.id)
const extraction = contract.extracted_data as ContractExtractionResult
const matches = matchParties(
extraction.parties.brand,
extraction.parties.agency,
customers
)
return NextResponse.json({
data: {
extraction,
customerMatches: matches,
cached: true,
},
})
}
// Update status to processing
await supabase
.from('contracts')
.update({ extraction_status: 'processing' })
.eq('id', id)
try {
console.log('[Extract] Starting extraction for contract:', id)
console.log('[Extract] File path:', contract.file_path)
// Download file from storage
const { data: fileData, error: downloadError } = await supabase.storage
.from('contracts')
.download(contract.file_path)
if (downloadError) {
console.error('[Extract] Download error:', downloadError)
throw new Error(`Failed to download contract: ${downloadError.message}`)
}
console.log('[Extract] File downloaded, size:', fileData.size)
// Check file type
if (contract.mime_type !== 'application/pdf') {
throw new Error('Only PDF files are supported for extraction')
}
// Convert PDF to base64 for Claude
console.log('[Extract] Converting PDF to base64...')
const buffer = Buffer.from(await fileData.arrayBuffer())
const pdfInfo = getPDFInfo(buffer)
if (!pdfInfo.isValidSize) {
throw new Error(`PDF is too large (${pdfInfo.sizeMB.toFixed(1)}MB). Maximum size is 32MB.`)
}
const pdfBase64 = pdfToBase64(buffer)
console.log('[Extract] PDF converted, size:', pdfInfo.sizeMB.toFixed(2), 'MB')
// Analyze with Claude AI (sending PDF directly)
console.log('[Extract] Analyzing with Claude AI...')
const extraction = await analyzeContract(pdfBase64)
console.log('[Extract] Analysis complete')
// Get customers for matching
const customers = await fetchUserCustomers(supabase, user.id)
const matches = matchParties(
extraction.parties.brand,
extraction.parties.agency,
customers
)
// Save extraction result
await supabase
.from('contracts')
.update({
extracted_data: extraction as unknown as Record<string, unknown>,
extraction_status: 'completed',
})
.eq('id', id)
return NextResponse.json({
data: {
extraction,
customerMatches: matches,
cached: false,
},
})
} catch (error) {
console.error('[Extract] Error:', error)
// Update status to failed
await supabase
.from('contracts')
.update({
extraction_status: 'failed',
extracted_data: {
error: error instanceof Error ? error.message : 'Unknown error',
},
})
.eq('id', id)
const message = error instanceof Error ? error.message : 'Extraction failed'
console.error('[Extract] Returning error:', message)
return NextResponse.json({ error: message }, { status: 500 })
}
}
/**
* GET /api/contracts/[id]/extract
* Get extraction status and result
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data: contract, error } = await supabase
.from('contracts')
.select('extraction_status, extracted_data')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (error) {
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Contract not found' }, { status: 404 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
// If completed, also return customer matches
if (contract.extraction_status === 'completed' && contract.extracted_data) {
const customers = await fetchUserCustomers(supabase, user.id)
const extraction = contract.extracted_data as ContractExtractionResult
const matches = matchParties(
extraction.parties.brand,
extraction.parties.agency,
customers
)
return NextResponse.json({
data: {
status: contract.extraction_status,
extraction: contract.extracted_data,
customerMatches: matches,
},
})
}
return NextResponse.json({
data: {
status: contract.extraction_status,
extraction: contract.extracted_data,
},
})
}
// Helper to fetch user's customers
async function fetchUserCustomers(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string
): Promise<Customer[]> {
const { data } = await supabase
.from('customers')
.select('*')
.eq('user_id', userId)
.order('name')
return data || []
}
-163
View File
@@ -1,163 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
/**
* GET /api/contracts/[id]
* Get a single contract
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase
.from('contracts')
.select('*, campaign:campaigns(id, name)')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (error) {
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Contract not found' }, { status: 404 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* PATCH /api/contracts/[id]
* Update contract metadata
*/
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: {
signing_date?: string
is_primary?: boolean
notes?: string
} = await request.json()
// Verify contract exists and belongs to user
const { data: existing, error: fetchError } = await supabase
.from('contracts')
.select('*, campaign_id')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError) {
if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Contract not found' }, { status: 404 })
}
return NextResponse.json({ error: fetchError.message }, { status: 500 })
}
// If setting as primary, unset other primary contracts
if (body.is_primary === true) {
await supabase
.from('contracts')
.update({ is_primary: false })
.eq('campaign_id', existing.campaign_id)
.eq('is_primary', true)
.neq('id', id)
}
// Build update object
const updateData: Record<string, unknown> = {}
if (body.signing_date !== undefined) updateData.signing_date = body.signing_date
if (body.is_primary !== undefined) updateData.is_primary = body.is_primary
if (body.notes !== undefined) updateData.notes = body.notes
// Update the contract
const { data, error } = await supabase
.from('contracts')
.update(updateData)
.eq('id', id)
.eq('user_id', user.id)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* DELETE /api/contracts/[id]
* Delete a contract and its file
*/
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Get contract to find file path
const { data: contract, error: fetchError } = await supabase
.from('contracts')
.select('file_path')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError) {
if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Contract not found' }, { status: 404 })
}
return NextResponse.json({ error: fetchError.message }, { status: 500 })
}
// Delete file from storage
if (contract.file_path) {
await supabase.storage.from('contracts').remove([contract.file_path])
}
// Delete contract record
const { error } = await supabase
.from('contracts')
.delete()
.eq('id', id)
.eq('user_id', user.id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ success: true })
}
+1 -10
View File
@@ -31,18 +31,10 @@ export async function GET(
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Fetch related campaigns
const { data: campaigns } = await supabase
.from('campaigns')
.select('id, name, status, total_value, currency, publication_date, brand_name')
.eq('customer_id', id)
.eq('user_id', user.id)
.order('created_at', { ascending: false })
// Fetch related invoices
const { data: invoices } = await supabase
.from('invoices')
.select('id, invoice_number, invoice_date, due_date, status, total, currency, payment_status')
.select('id, invoice_number, invoice_date, due_date, status, total, currency')
.eq('customer_id', id)
.eq('user_id', user.id)
.order('invoice_date', { ascending: false })
@@ -50,7 +42,6 @@ export async function GET(
return NextResponse.json({
data: {
...data,
campaigns: campaigns || [],
invoices: invoices || [],
},
})
-153
View File
@@ -1,153 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { CreateDeliverableInput } from '@/types'
/**
* GET /api/deliverables/[id]
* Get a single deliverable
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase
.from('deliverables')
.select('*, campaign:campaigns(id, name, customer_id)')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (error) {
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Deliverable not found' }, { status: 404 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* PATCH /api/deliverables/[id]
* Update a deliverable
*/
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: Partial<Omit<CreateDeliverableInput, 'campaign_id'>> = await request.json()
// Verify deliverable exists and belongs to user
const { data: existing, error: fetchError } = await supabase
.from('deliverables')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError) {
if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Deliverable not found' }, { status: 404 })
}
return NextResponse.json({ error: fetchError.message }, { status: 500 })
}
// Build update object
const updateData: Record<string, unknown> = {}
if (body.title !== undefined) updateData.title = body.title
if (body.deliverable_type !== undefined) updateData.deliverable_type = body.deliverable_type
if (body.platform !== undefined) updateData.platform = body.platform
if (body.account_handle !== undefined) updateData.account_handle = body.account_handle
if (body.quantity !== undefined) updateData.quantity = body.quantity
if (body.description !== undefined) updateData.description = body.description
if (body.specifications !== undefined) updateData.specifications = body.specifications
if (body.due_date !== undefined) updateData.due_date = body.due_date
if (body.notes !== undefined) updateData.notes = body.notes
// Update the deliverable
const { data, error } = await supabase
.from('deliverables')
.update(updateData)
.eq('id', id)
.eq('user_id', user.id)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Update auto-generated deadline if due_date changed
if (body.due_date !== undefined) {
await supabase
.from('deadlines')
.update({ due_date: body.due_date })
.eq('deliverable_id', id)
.eq('is_auto_generated', true)
}
return NextResponse.json({ data })
}
/**
* DELETE /api/deliverables/[id]
* Delete a deliverable
*/
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Delete auto-generated deadlines first
await supabase
.from('deadlines')
.delete()
.eq('deliverable_id', id)
.eq('is_auto_generated', true)
// Delete the deliverable
const { error } = await supabase
.from('deliverables')
.delete()
.eq('id', id)
.eq('user_id', user.id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ success: true })
}
-126
View File
@@ -1,126 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { DeliverableStatus } from '@/types'
const VALID_STATUSES: DeliverableStatus[] = [
'pending',
'in_progress',
'submitted',
'revision',
'approved',
'published'
]
/**
* PATCH /api/deliverables/[id]/status
* Update deliverable status with automatic timestamp tracking
*/
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: { status: DeliverableStatus } = await request.json()
// Validate status
if (!body.status || !VALID_STATUSES.includes(body.status)) {
return NextResponse.json({
error: `Invalid status. Must be one of: ${VALID_STATUSES.join(', ')}`
}, { status: 400 })
}
// Verify deliverable exists and belongs to user
const { data: existing, error: fetchError } = await supabase
.from('deliverables')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError) {
if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Deliverable not found' }, { status: 404 })
}
return NextResponse.json({ error: fetchError.message }, { status: 500 })
}
// Build update object with automatic timestamps
const now = new Date().toISOString()
const updateData: Record<string, unknown> = {
status: body.status
}
// Set appropriate timestamp based on status
if (body.status === 'submitted' && !existing.submitted_at) {
updateData.submitted_at = now
} else if (body.status === 'approved' && !existing.approved_at) {
updateData.approved_at = now
} else if (body.status === 'published' && !existing.published_at) {
updateData.published_at = now
}
// Update the deliverable
const { data, error } = await supabase
.from('deliverables')
.update(updateData)
.eq('id', id)
.eq('user_id', user.id)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// If status is now 'approved' or 'published', mark related deadline as completed
if (body.status === 'approved' || body.status === 'published') {
await supabase
.from('deadlines')
.update({
is_completed: true,
completed_at: now
})
.eq('deliverable_id', id)
.eq('is_completed', false)
}
// Check if all deliverables are completed - if so, maybe update campaign status
if (body.status === 'published' || body.status === 'approved') {
const { data: campaign } = await supabase
.from('campaigns')
.select('id, status')
.eq('id', existing.campaign_id)
.single()
if (campaign && campaign.status === 'active') {
// Check if all deliverables are done
const { data: allDeliverables } = await supabase
.from('deliverables')
.select('status')
.eq('campaign_id', existing.campaign_id)
const allDone = allDeliverables?.every(d =>
d.status === 'approved' || d.status === 'published'
)
if (allDone) {
await supabase
.from('campaigns')
.update({ status: 'delivered' })
.eq('id', existing.campaign_id)
}
}
}
return NextResponse.json({ data })
}
-145
View File
@@ -1,145 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { CreateExclusivityInput } from '@/types'
/**
* GET /api/exclusivities/[id]
* Get a single exclusivity
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase
.from('exclusivities')
.select('*, campaign:campaigns(id, name, customer_id)')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (error) {
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Exclusivity not found' }, { status: 404 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* PATCH /api/exclusivities/[id]
* Update an exclusivity
*/
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: Partial<Omit<CreateExclusivityInput, 'campaign_id'>> = await request.json()
// Verify exclusivity exists and belongs to user
const { data: existing, error: fetchError } = await supabase
.from('exclusivities')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError) {
if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Exclusivity not found' }, { status: 404 })
}
return NextResponse.json({ error: fetchError.message }, { status: 500 })
}
// Build update object
const updateData: Record<string, unknown> = {}
if (body.categories !== undefined) updateData.categories = body.categories
if (body.excluded_brands !== undefined) updateData.excluded_brands = body.excluded_brands
if (body.start_date !== undefined) updateData.start_date = body.start_date
if (body.end_date !== undefined) updateData.end_date = body.end_date
if (body.start_calculation_type !== undefined) updateData.start_calculation_type = body.start_calculation_type
if (body.end_calculation_type !== undefined) updateData.end_calculation_type = body.end_calculation_type
if (body.start_reference !== undefined) updateData.start_reference = body.start_reference
if (body.end_reference !== undefined) updateData.end_reference = body.end_reference
if (body.start_offset_days !== undefined) updateData.start_offset_days = body.start_offset_days
if (body.end_offset_days !== undefined) updateData.end_offset_days = body.end_offset_days
if (body.notes !== undefined) updateData.notes = body.notes
// Validate date range if dates are being updated
const startDate = body.start_date ?? existing.start_date
const endDate = body.end_date ?? existing.end_date
if (new Date(endDate) < new Date(startDate)) {
return NextResponse.json({ error: 'End date must be after start date' }, { status: 400 })
}
// Update the exclusivity
const { data, error } = await supabase
.from('exclusivities')
.update(updateData)
.eq('id', id)
.eq('user_id', user.id)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* DELETE /api/exclusivities/[id]
* Delete an exclusivity
*/
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { error } = await supabase
.from('exclusivities')
.delete()
.eq('id', id)
.eq('user_id', user.id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ success: true })
}
-102
View File
@@ -1,102 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
/**
* GET /api/exclusivities/conflicts
* Check for exclusivity conflicts
* Query params:
* - categories: comma-separated list of categories to check
* - start_date: ISO date string
* - end_date: ISO date string
* - exclude_campaign_id: campaign to exclude from check (optional)
*/
export async function GET(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const categoriesParam = searchParams.get('categories')
const startDate = searchParams.get('start_date')
const endDate = searchParams.get('end_date')
const excludeCampaignId = searchParams.get('exclude_campaign_id')
if (!categoriesParam || !startDate || !endDate) {
return NextResponse.json({
error: 'Missing required parameters: categories, start_date, end_date'
}, { status: 400 })
}
const categories = categoriesParam.split(',').map(c => c.trim().toLowerCase())
// Find overlapping exclusivities
let query = supabase
.from('exclusivities')
.select(`
*,
campaign:campaigns(id, name, customer_id, customer:customers(id, name))
`)
.eq('user_id', user.id)
.lte('start_date', endDate)
.gte('end_date', startDate)
if (excludeCampaignId) {
query = query.neq('campaign_id', excludeCampaignId)
}
const { data: existingExclusivities, error } = await query
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Find conflicts
const conflicts = []
if (existingExclusivities) {
for (const existing of existingExclusivities) {
const overlappingCategories = categories.filter(cat =>
existing.categories.some((existingCat: string) =>
existingCat.toLowerCase() === cat
)
)
if (overlappingCategories.length > 0) {
// Calculate exact overlap period
const overlapStart = startDate > existing.start_date ? startDate : existing.start_date
const overlapEnd = endDate < existing.end_date ? endDate : existing.end_date
conflicts.push({
exclusivity_id: existing.id,
campaign_id: existing.campaign?.id,
campaign_name: existing.campaign?.name,
customer_name: existing.campaign?.customer?.name,
categories: existing.categories,
overlapping_categories: overlappingCategories,
exclusivity_start: existing.start_date,
exclusivity_end: existing.end_date,
overlap_start: overlapStart,
overlap_end: overlapEnd,
overlap_days: Math.ceil(
(new Date(overlapEnd).getTime() - new Date(overlapStart).getTime()) / (1000 * 60 * 60 * 24)
) + 1
})
}
}
}
return NextResponse.json({
has_conflicts: conflicts.length > 0,
conflicts,
checked: {
categories,
start_date: startDate,
end_date: endDate
}
})
}
-168
View File
@@ -1,168 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { classifyGift } from '@/lib/benefits/gift-classifier'
import { createGiftJournalEntry } from '@/lib/benefits/gift-booking'
import type { CreateGiftInput, GiftInput, Gift } from '@/types'
/**
* GET /api/gifts/[id]
* Get a single gift by ID
*/
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase
.from('gifts')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (error) {
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Gift not found' }, { status: 404 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* PUT /api/gifts/[id]
* Update a gift with re-classification
*/
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: Partial<CreateGiftInput> = await request.json()
// First, get existing gift to merge with updates
const { data: existing, error: fetchError } = await supabase
.from('gifts')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError) {
if (fetchError.code === 'PGRST116') {
return NextResponse.json({ error: 'Gift not found' }, { status: 404 })
}
return NextResponse.json({ error: fetchError.message }, { status: 500 })
}
// Merge existing with updates
const merged = {
date: body.date ?? existing.date,
brand_name: body.brand_name ?? existing.brand_name,
description: body.description ?? existing.description,
estimated_value: body.estimated_value ?? existing.estimated_value,
has_motprestation: body.has_motprestation ?? existing.has_motprestation,
used_in_business: body.used_in_business ?? existing.used_in_business,
used_privately: body.used_privately ?? existing.used_privately,
is_simple_promo: body.is_simple_promo ?? existing.is_simple_promo,
}
// Re-classify with updated values
const classificationInput: GiftInput = {
estimatedValue: merged.estimated_value,
hasMotprestation: merged.has_motprestation,
usedInBusiness: merged.used_in_business,
usedPrivately: merged.used_privately,
isSimplePromoItem: merged.is_simple_promo,
}
const classification = classifyGift(classificationInput)
// Update the gift
const { data, error } = await supabase
.from('gifts')
.update({
...merged,
classification,
})
.eq('id', id)
.eq('user_id', user.id)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Handle journal entry update
// If classification changed to/from taxable, we may need to create/update entry
const wasBookable = existing.classification?.taxable && existing.journal_entry_id
const isBookable = classification.taxable
if (isBookable && !existing.journal_entry_id) {
// Need to create a new journal entry
try {
const journalEntry = await createGiftJournalEntry(user.id, data as Gift)
if (journalEntry) {
await supabase
.from('gifts')
.update({ journal_entry_id: journalEntry.id })
.eq('id', id)
data.journal_entry_id = journalEntry.id
}
} catch (bookingError) {
console.error('Failed to create gift journal entry:', bookingError)
return NextResponse.json({
data,
warning: 'Gåvan uppdaterades men bokföring kunde inte skapas.',
})
}
} else if (isBookable && existing.journal_entry_id) {
// Classification changed but still taxable - add warning that old entry may need reversal
return NextResponse.json({
data,
warning: 'Klassificeringen ändrades. Den befintliga verifikationen kan behöva makuleras manuellt.',
})
}
return NextResponse.json({ data })
}
/**
* DELETE /api/gifts/[id]
* Delete a gift
*/
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const supabase = await createClient()
const { id } = await params
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { error } = await supabase.from('gifts').delete().eq('id', id).eq('user_id', user.id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ success: true })
}
-51
View File
@@ -1,51 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { estimateProductValue } from '@/lib/receipts/receipt-analyzer'
/**
* POST /api/gifts/estimate
* Lightweight endpoint: accepts an image, returns AI price estimate.
* Does NOT upload to storage, classify, or write to DB.
*/
export async function POST(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const formData = await request.formData()
const imageFile = formData.get('image') as File | null
if (!imageFile) {
return NextResponse.json({ error: 'Image is required' }, { status: 400 })
}
const validTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
if (!validTypes.includes(imageFile.type)) {
return NextResponse.json(
{ error: 'Invalid file type. Supported: JPEG, PNG, WebP, GIF' },
{ status: 400 }
)
}
const arrayBuffer = await imageFile.arrayBuffer()
const base64 = Buffer.from(arrayBuffer).toString('base64')
const mimeType = imageFile.type as 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif'
const estimation = await estimateProductValue(base64, mimeType)
return NextResponse.json({ data: estimation })
} catch (error) {
console.error('Gift estimation error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Estimation failed' },
{ status: 500 }
)
}
}
-170
View File
@@ -1,170 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { classifyGift, classifyGiftForEntity } from '@/lib/benefits/gift-classifier'
import { createGiftJournalEntry } from '@/lib/benefits/gift-booking'
import { calculateGiftVirtualTaxDebt } from '@/lib/tax/light-calculator'
import type { CreateGiftInput, GiftInput, Gift, EntityType } from '@/types'
/**
* GET /api/gifts
* List gifts for the authenticated user
* Query params: year (optional, defaults to current year)
*/
export async function GET(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Parse query params
const { searchParams } = new URL(request.url)
const year = searchParams.get('year') || new Date().getFullYear().toString()
// Build date range for the year
const startDate = `${year}-01-01`
const endDate = `${year}-12-31`
const { data, error } = await supabase
.from('gifts')
.select('*')
.eq('user_id', user.id)
.gte('date', startDate)
.lte('date', endDate)
.order('date', { ascending: false })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* POST /api/gifts
* Create a new gift with auto-classification
*/
export async function POST(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: CreateGiftInput = await request.json()
// Validate required fields
if (!body.date || !body.brand_name || !body.description || body.estimated_value === undefined) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
}
// Fetch entity type and tax settings
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type, municipal_tax_rate, church_tax, church_tax_rate')
.eq('user_id', user.id)
.single()
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
// Build classification input
const classificationInput: GiftInput = {
estimatedValue: body.estimated_value,
hasMotprestation: body.has_motprestation,
usedInBusiness: body.used_in_business,
usedPrivately: body.used_privately,
isSimplePromoItem: body.is_simple_promo || false,
}
// Classify the gift using entity-type-aware classifier
const classification = classifyGiftForEntity(classificationInput, entityType)
// Insert the gift with classification
const { data, error } = await supabase
.from('gifts')
.insert({
user_id: user.id,
date: body.date,
brand_name: body.brand_name,
description: body.description,
estimated_value: body.estimated_value,
has_motprestation: body.has_motprestation,
used_in_business: body.used_in_business,
used_privately: body.used_privately,
is_simple_promo: body.is_simple_promo || false,
classification,
})
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
if (entityType === 'light') {
// Light mode: create shadow_ledger_entry instead of journal entry
if (classification.taxable && data) {
try {
const municipalRate = Number(settings?.municipal_tax_rate) || 0.3238
const churchRate = settings?.church_tax ? (Number(settings?.church_tax_rate) || 0.01) : 0
const virtualTaxDebt = calculateGiftVirtualTaxDebt(
body.estimated_value,
municipalRate,
churchRate
)
await supabase
.from('shadow_ledger_entries')
.insert({
user_id: user.id,
date: body.date,
type: 'gift',
source: 'manual',
gross_amount: body.estimated_value,
net_amount: body.estimated_value,
description: `Gåva: ${body.description} (${body.brand_name})`,
gift_id: data.id,
virtual_tax_debt: virtualTaxDebt,
})
} catch (err) {
console.error('Failed to create shadow ledger entry for gift:', err)
}
}
} else {
// EF/AB mode: create journal entry for taxable gifts
let journalEntryId: string | null = null
if (classification.taxable && data) {
try {
const journalEntry = await createGiftJournalEntry(user.id, data as Gift)
if (journalEntry) {
journalEntryId = journalEntry.id
// Update gift with journal entry reference
await supabase
.from('gifts')
.update({ journal_entry_id: journalEntryId })
.eq('id', data.id)
// Update the returned data
data.journal_entry_id = journalEntryId
}
} catch (bookingError) {
console.error('Failed to create gift journal entry:', bookingError)
return NextResponse.json({
data,
warning: 'Gåvan sparades men bokföring kunde inte skapas. Kontrollera att räkenskapsår finns.',
})
}
}
}
return NextResponse.json({ data })
}
-81
View File
@@ -1,81 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { Gift, GiftSummary } from '@/types'
/**
* GET /api/gifts/summary
* Get gift summary for a year (used in dashboard and reports)
* Query params: year (optional, defaults to current year)
*/
export async function GET(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Parse query params
const { searchParams } = new URL(request.url)
const yearParam = searchParams.get('year')
const year = yearParam ? parseInt(yearParam) : new Date().getFullYear()
// Build date range for the year
const startDate = `${year}-01-01`
const endDate = `${year}-12-31`
const { data: gifts, error } = await supabase
.from('gifts')
.select('estimated_value, classification')
.eq('user_id', user.id)
.gte('date', startDate)
.lte('date', endDate)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Calculate summary
const summary: GiftSummary = {
year,
total_count: gifts.length,
total_value: 0,
taxable_count: 0,
taxable_value: 0,
tax_free_count: 0,
tax_free_value: 0,
deductible_count: 0,
deductible_value: 0,
}
for (const gift of gifts as Pick<Gift, 'estimated_value' | 'classification'>[]) {
const value = Number(gift.estimated_value)
const classification = gift.classification
summary.total_value += value
if (classification?.taxable) {
summary.taxable_count++
summary.taxable_value += value
} else {
summary.tax_free_count++
summary.tax_free_value += value
}
if (classification?.deductibleAsExpense) {
summary.deductible_count++
summary.deductible_value += value
}
}
// Round values to 2 decimal places
summary.total_value = Math.round(summary.total_value * 100) / 100
summary.taxable_value = Math.round(summary.taxable_value * 100) / 100
summary.tax_free_value = Math.round(summary.tax_free_value * 100) / 100
summary.deductible_value = Math.round(summary.deductible_value * 100) / 100
return NextResponse.json({ data: summary })
}
+3 -11
View File
@@ -3,7 +3,6 @@ import { NextResponse } from 'next/server'
import {
sendTaxDeadlineNotifications,
sendInvoiceNotifications,
sendCampaignNotifications,
} from '@/lib/push/notification-scheduler'
/**
@@ -37,16 +36,13 @@ export async function GET(request: Request) {
try {
// Send all notification types in parallel
const [taxResult, invoiceResult, campaignResult] = await Promise.all([
const [taxResult, invoiceResult] = await Promise.all([
sendTaxDeadlineNotifications(supabase),
sendInvoiceNotifications(supabase),
sendCampaignNotifications(supabase),
])
const totalSent =
taxResult.sent + invoiceResult.sent + campaignResult.sent
const totalSkipped =
taxResult.skipped + invoiceResult.skipped + campaignResult.skipped
const totalSent = taxResult.sent + invoiceResult.sent
const totalSkipped = taxResult.skipped + invoiceResult.skipped
console.log(
`Push notification cron completed: ${totalSent} sent, ${totalSkipped} skipped`
@@ -57,9 +53,6 @@ export async function GET(request: Request) {
console.log(
` Invoice: ${invoiceResult.sent} sent, ${invoiceResult.skipped} skipped`
)
console.log(
` Campaign: ${campaignResult.sent} sent, ${campaignResult.skipped} skipped`
)
return NextResponse.json({
success: true,
@@ -68,7 +61,6 @@ export async function GET(request: Request) {
details: {
taxDeadlines: taxResult,
invoices: invoiceResult,
campaigns: campaignResult,
},
})
} catch (error) {
-1
View File
@@ -81,7 +81,6 @@ export async function POST(request: Request) {
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',
-156
View File
@@ -1,156 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { estimateProductValue } from '@/lib/receipts/receipt-analyzer'
import { classifyGift } from '@/lib/benefits/gift-classifier'
import type { GiftInput } from '@/types'
/**
* POST /api/receipts/product
* Register a product/gift without a receipt
* Uses AI to estimate value, then routes to gift classification
*/
export async function POST(request: Request) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const formData = await request.formData()
const imageFile = formData.get('image') as File | null
const manualValue = formData.get('estimated_value') as string | null
const brandName = formData.get('brand_name') as string | null
const description = formData.get('description') as string | null
// Gift classification inputs
const hasMotprestation = formData.get('has_motprestation') === 'true'
const usedInBusiness = formData.get('used_in_business') === 'true'
const usedPrivately = formData.get('used_privately') === 'true'
const isSimplePromo = formData.get('is_simple_promo') === 'true'
let estimatedValue = manualValue ? parseFloat(manualValue) : null
let productDescription = description || ''
let productBrand = brandName || null
let imageUrl: string | null = null
// If image provided, analyze and estimate value
if (imageFile) {
const validTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
if (!validTypes.includes(imageFile.type)) {
return NextResponse.json(
{ error: 'Invalid file type. Supported: JPEG, PNG, WebP, GIF' },
{ status: 400 }
)
}
// Convert to base64
const arrayBuffer = await imageFile.arrayBuffer()
const base64 = Buffer.from(arrayBuffer).toString('base64')
// Upload image to storage
const ext = imageFile.type.split('/')[1]
const filename = `${user.id}/products/${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`
const { error: uploadError } = await supabase.storage
.from('receipts')
.upload(filename, arrayBuffer, {
contentType: imageFile.type,
cacheControl: '3600',
})
if (uploadError) {
console.error('Storage upload error:', uploadError)
} else {
const { data: urlData } = supabase.storage.from('receipts').getPublicUrl(filename)
imageUrl = urlData.publicUrl
}
// Estimate value if not manually provided
if (!estimatedValue) {
try {
const mimeType = imageFile.type as 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif'
const estimation = await estimateProductValue(base64, mimeType)
estimatedValue = estimation.estimatedValue
if (!productDescription) {
productDescription = estimation.description
}
if (!productBrand && estimation.brand) {
productBrand = estimation.brand
}
} catch (error) {
console.error('Value estimation error:', error)
// Continue without AI estimation
}
}
}
// Validate required data
if (!estimatedValue || estimatedValue <= 0) {
return NextResponse.json(
{ error: 'Estimated value is required' },
{ status: 400 }
)
}
if (!productDescription) {
return NextResponse.json(
{ error: 'Product description is required' },
{ status: 400 }
)
}
// Classify the gift
const giftInput: GiftInput = {
estimatedValue,
hasMotprestation,
usedInBusiness,
usedPrivately,
isSimplePromoItem: isSimplePromo,
}
const classification = classifyGift(giftInput)
// Create gift record
const { data: gift, error: giftError } = await supabase
.from('gifts')
.insert({
user_id: user.id,
date: new Date().toISOString().split('T')[0],
brand_name: productBrand || 'Okänt varumärke',
description: productDescription,
estimated_value: estimatedValue,
has_motprestation: hasMotprestation,
used_in_business: usedInBusiness,
used_privately: usedPrivately,
is_simple_promo: isSimplePromo,
classification,
})
.select()
.single()
if (giftError) {
console.error('Gift insert error:', giftError)
return NextResponse.json({ error: 'Failed to create gift record' }, { status: 500 })
}
return NextResponse.json({
data: {
gift,
classification,
imageUrl,
},
})
} catch (error) {
console.error('Product registration error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Registration failed' },
{ status: 500 }
)
}
}
+1 -1
View File
@@ -33,7 +33,7 @@ export async function GET(request: Request) {
fiscal_period_id: periodId,
company_name: company.company_name || 'Unknown',
org_number: company.org_number,
program_name: 'InfluencerBiz',
program_name: 'ERPBase',
})
// Return as downloadable file
+2 -2
View File
@@ -53,8 +53,8 @@ export async function PUT(request: Request) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Check if tax-relevant fields changed and regenerate deadlines (skip for light mode)
if (data.entity_type !== 'light' && oldSettings && didTaxFieldsChange(oldSettings, data)) {
// Check if tax-relevant fields changed and regenerate deadlines
if (oldSettings && didTaxFieldsChange(oldSettings, data)) {
try {
await regenerateTaxDeadlinesForUser(supabase, user.id, {
entity_type: data.entity_type,
-85
View File
@@ -1,85 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase
.from('shadow_ledger_entries')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 404 })
}
return NextResponse.json({ data })
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
// Remove fields that shouldn't be updated directly
const { id: _id, user_id: _uid, created_at: _ca, updated_at: _ua, ...updates } = body
const { data, error } = await supabase
.from('shadow_ledger_entries')
.update(updates)
.eq('id', id)
.eq('user_id', user.id)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { error } = await supabase
.from('shadow_ledger_entries')
.delete()
.eq('id', id)
.eq('user_id', user.id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ success: true })
}
-121
View File
@@ -1,121 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { calculateGiftVirtualTaxDebt } from '@/lib/tax/light-calculator'
import type { CreateShadowLedgerEntryInput } from '@/types'
/**
* GET /api/shadow-ledger
* List shadow ledger entries for the authenticated user
* Query params: year (optional, defaults to current year)
*/
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const year = searchParams.get('year') || new Date().getFullYear().toString()
const startDate = `${year}-01-01`
const endDate = `${year}-12-31`
const { data, error } = await supabase
.from('shadow_ledger_entries')
.select('*')
.eq('user_id', user.id)
.gte('date', startDate)
.lte('date', endDate)
.order('date', { ascending: false })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
/**
* POST /api/shadow-ledger
* Create a new shadow ledger entry
*/
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: CreateShadowLedgerEntryInput = await request.json()
if (!body.date || !body.type || body.gross_amount === undefined || body.net_amount === undefined) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
}
// Fetch settings for umbrella config and tax rates
const { data: settings } = await supabase
.from('company_settings')
.select('umbrella_provider, umbrella_fee_percent, umbrella_pension_percent, municipal_tax_rate, church_tax, church_tax_rate')
.eq('user_id', user.id)
.single()
// For payout type: auto-populate service_fee and pension_deduction from settings if not provided
let serviceFee = body.service_fee ?? 0
let pensionDeduction = body.pension_deduction ?? 0
let provider = body.provider ?? null
if (body.type === 'payout' && settings) {
if (!body.provider && settings.umbrella_provider) {
provider = settings.umbrella_provider
}
if (body.service_fee === undefined && settings.umbrella_fee_percent) {
serviceFee = Math.round(body.gross_amount * (Number(settings.umbrella_fee_percent) / 100) * 100) / 100
}
if (body.pension_deduction === undefined && settings.umbrella_pension_percent) {
pensionDeduction = Math.round(body.gross_amount * (Number(settings.umbrella_pension_percent) / 100) * 100) / 100
}
}
// Calculate virtual tax debt for gift entries
let virtualTaxDebt = 0
if (body.type === 'gift' && settings) {
const municipalRate = Number(settings.municipal_tax_rate) || 0.3238
const churchRate = settings.church_tax ? (Number(settings.church_tax_rate) || 0.01) : 0
virtualTaxDebt = calculateGiftVirtualTaxDebt(body.gross_amount, municipalRate, churchRate)
}
const { data, error } = await supabase
.from('shadow_ledger_entries')
.insert({
user_id: user.id,
date: body.date,
type: body.type,
source: body.source || 'manual',
provider,
gross_amount: body.gross_amount,
platform_fee: body.platform_fee ?? 0,
service_fee: serviceFee,
pension_deduction: pensionDeduction,
social_fees: body.social_fees ?? 0,
income_tax_withheld: body.income_tax_withheld ?? 0,
net_amount: body.net_amount,
currency: body.currency || 'SEK',
description: body.description || null,
bank_transaction_id: body.bank_transaction_id || null,
gift_id: body.gift_id || null,
campaign_id: body.campaign_id || null,
metadata: body.metadata || {},
virtual_tax_debt: virtualTaxDebt,
})
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
-85
View File
@@ -1,85 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { ShadowLedgerSummary, ShadowLedgerEntryType } from '@/types'
/**
* GET /api/shadow-ledger/summary
* Aggregated summary of shadow ledger entries for a year
*/
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const year = searchParams.get('year') || new Date().getFullYear().toString()
const startDate = `${year}-01-01`
const endDate = `${year}-12-31`
const { data: entries, error } = await supabase
.from('shadow_ledger_entries')
.select('type, gross_amount, net_amount, service_fee, income_tax_withheld, pension_deduction, social_fees, platform_fee, virtual_tax_debt')
.eq('user_id', user.id)
.gte('date', startDate)
.lte('date', endDate)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
const types: ShadowLedgerEntryType[] = ['payout', 'gift', 'expense', 'hobby_income', 'hobby_expense']
const byType = {} as Record<ShadowLedgerEntryType, { count: number; gross: number; net: number }>
for (const t of types) {
byType[t] = { count: 0, gross: 0, net: 0 }
}
let totalGross = 0
let totalNet = 0
let totalFees = 0
let totalTaxWithheld = 0
let totalPension = 0
let totalSocialFees = 0
let totalPlatformFees = 0
let virtualTaxDebt = 0
for (const entry of entries || []) {
const type = entry.type as ShadowLedgerEntryType
const gross = Number(entry.gross_amount)
const net = Number(entry.net_amount)
totalGross += gross
totalNet += net
totalFees += Number(entry.service_fee) || 0
totalTaxWithheld += Number(entry.income_tax_withheld) || 0
totalPension += Number(entry.pension_deduction) || 0
totalSocialFees += Number(entry.social_fees) || 0
totalPlatformFees += Number(entry.platform_fee) || 0
virtualTaxDebt += Number(entry.virtual_tax_debt) || 0
if (byType[type]) {
byType[type].count++
byType[type].gross += gross
byType[type].net += net
}
}
const summary: ShadowLedgerSummary = {
year: parseInt(year),
total_gross: Math.round(totalGross * 100) / 100,
total_net: Math.round(totalNet * 100) / 100,
total_fees: Math.round(totalFees * 100) / 100,
total_tax_withheld: Math.round(totalTaxWithheld * 100) / 100,
total_pension: Math.round(totalPension * 100) / 100,
total_social_fees: Math.round(totalSocialFees * 100) / 100,
total_platform_fees: Math.round(totalPlatformFees * 100) / 100,
virtual_tax_debt: Math.round(virtualTaxDebt * 100) / 100,
entry_count: (entries || []).length,
by_type: byType,
}
return NextResponse.json({ data: summary })
}
-49
View File
@@ -1,49 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
// Get user's TikTok accounts (exclude encrypted tokens)
const { data: accounts, error } = await supabase
.from('tiktok_accounts')
.select(`
id,
tiktok_user_id,
username,
display_name,
avatar_url,
token_expires_at,
refresh_token_expires_at,
status,
last_synced_at,
last_stats_sync_at,
last_video_sync_at,
last_error,
error_count,
created_at,
updated_at
`)
.eq('user_id', user.id)
.order('created_at', { ascending: false })
if (error) {
throw error
}
return NextResponse.json({ accounts: accounts || [] })
} catch (error) {
console.error('Error fetching TikTok accounts:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to fetch accounts' },
{ status: 500 }
)
}
}
-117
View File
@@ -1,117 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { validateOAuthState, exchangeCodeForTokens, calculateTokenExpiration } from '@/lib/tiktok/oauth'
import { encryptTokens } from '@/lib/tiktok/encryption'
import { getUserInfo } from '@/lib/tiktok/api'
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const code = searchParams.get('code')
const state = searchParams.get('state')
const error = searchParams.get('error')
const errorDescription = searchParams.get('error_description')
const redirectUrl = `${process.env.NEXT_PUBLIC_APP_URL}/settings`
// Handle OAuth errors
if (error) {
console.error('TikTok OAuth error:', error, errorDescription)
return NextResponse.redirect(
`${redirectUrl}?tiktok_error=${encodeURIComponent(errorDescription || error)}`
)
}
// Validate required parameters
if (!code || !state) {
return NextResponse.redirect(
`${redirectUrl}?tiktok_error=${encodeURIComponent('Missing authorization code or state')}`
)
}
// Validate state (CSRF protection) and get PKCE code verifier
const stateData = await validateOAuthState(state)
if (!stateData) {
return NextResponse.redirect(
`${redirectUrl}?tiktok_error=${encodeURIComponent('Invalid or expired state parameter')}`
)
}
const { userId, codeVerifier } = stateData
const supabase = await createClient()
try {
// Exchange code for tokens (with PKCE verifier)
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/tiktok/callback`
const tokens = await exchangeCodeForTokens(code, redirectUri, codeVerifier)
// Encrypt tokens
const encryptedTokens = encryptTokens(tokens.access_token, tokens.refresh_token)
// Calculate expiration dates
const { tokenExpiresAt, refreshTokenExpiresAt } = calculateTokenExpiration(tokens)
// Get user info from TikTok
const userInfo = await getUserInfo({
accessToken: tokens.access_token,
userId,
})
// Check if account already exists (maybe in revoked/expired state)
const { data: existingAccount } = await supabase
.from('tiktok_accounts')
.select('id')
.eq('user_id', userId)
.eq('tiktok_user_id', tokens.open_id)
.single()
if (existingAccount) {
// Update existing account
const { error: updateError } = await supabase
.from('tiktok_accounts')
.update({
username: userInfo.username,
display_name: userInfo.display_name,
avatar_url: userInfo.avatar_url,
...encryptedTokens,
token_expires_at: tokenExpiresAt.toISOString(),
refresh_token_expires_at: refreshTokenExpiresAt.toISOString(),
status: 'active',
error_count: 0,
last_error: null,
})
.eq('id', existingAccount.id)
if (updateError) {
throw updateError
}
} else {
// Create new account
const { error: insertError } = await supabase
.from('tiktok_accounts')
.insert({
user_id: userId,
tiktok_user_id: tokens.open_id,
username: userInfo.username,
display_name: userInfo.display_name,
avatar_url: userInfo.avatar_url,
...encryptedTokens,
token_expires_at: tokenExpiresAt.toISOString(),
refresh_token_expires_at: refreshTokenExpiresAt.toISOString(),
status: 'active',
})
if (insertError) {
throw insertError
}
}
return NextResponse.redirect(`${redirectUrl}?tiktok_connected=true`)
} catch (error) {
console.error('TikTok callback error:', error)
return NextResponse.redirect(
`${redirectUrl}?tiktok_error=${encodeURIComponent(
error instanceof Error ? error.message : 'Failed to connect TikTok account'
)}`
)
}
}
-47
View File
@@ -1,47 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateOAuthState, getAuthorizationUrl } from '@/lib/tiktok/oauth'
export async function POST() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
// Check if user already has an active TikTok account
const { data: existingAccount } = await supabase
.from('tiktok_accounts')
.select('id, status')
.eq('user_id', user.id)
.eq('status', 'active')
.single()
if (existingAccount) {
return NextResponse.json(
{ error: 'TikTok account already connected' },
{ status: 400 }
)
}
// Generate OAuth state for CSRF protection and PKCE
const { state, codeChallenge } = await generateOAuthState(user.id)
// Get redirect URL
const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/tiktok/callback`
// Generate authorization URL with PKCE
const authorizationUrl = getAuthorizationUrl(redirectUri, state, codeChallenge)
return NextResponse.json({ authorization_url: authorizationUrl })
} catch (error) {
console.error('TikTok connect error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Connection failed' },
{ status: 500 }
)
}
}
-62
View File
@@ -1,62 +0,0 @@
import { NextResponse } from 'next/server'
import { syncAllAccounts, refreshExpiringTokens } from '@/lib/tiktok/sync'
// Verify cron secret for security
function verifyCronSecret(request: Request): boolean {
const authHeader = request.headers.get('authorization')
const cronSecret = process.env.CRON_SECRET
if (!cronSecret) {
console.error('CRON_SECRET not configured')
return false
}
if (!authHeader) {
return false
}
// Support both "Bearer <token>" and just "<token>" formats
const token = authHeader.startsWith('Bearer ')
? authHeader.substring(7)
: authHeader
return token === cronSecret
}
export async function GET(request: Request) {
// Verify cron authentication
if (!verifyCronSecret(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
console.log('Starting TikTok cron job...')
// First, refresh tokens that are expiring soon
const tokensRefreshed = await refreshExpiringTokens()
console.log(`Refreshed ${tokensRefreshed} expiring tokens`)
// Then sync all active accounts
const syncResults = await syncAllAccounts()
console.log(`Sync completed: ${syncResults.successful}/${syncResults.total} successful, ${syncResults.failed} failed`)
return NextResponse.json({
success: true,
tokens_refreshed: tokensRefreshed,
accounts_total: syncResults.total,
accounts_successful: syncResults.successful,
accounts_failed: syncResults.failed,
})
} catch (error) {
console.error('TikTok cron job error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Cron job failed' },
{ status: 500 }
)
}
}
// Also support POST for manual triggering via dashboard
export async function POST(request: Request) {
return GET(request)
}
-61
View File
@@ -1,61 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { revokeToken } from '@/lib/tiktok/oauth'
import { decryptToken } from '@/lib/tiktok/encryption'
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { account_id } = await request.json()
if (!account_id) {
return NextResponse.json({ error: 'account_id is required' }, { status: 400 })
}
try {
// Get account with encrypted token
const { data: account, error: fetchError } = await supabase
.from('tiktok_accounts')
.select('id, user_id, access_token_encrypted')
.eq('id', account_id)
.eq('user_id', user.id)
.single()
if (fetchError || !account) {
return NextResponse.json({ error: 'Account not found' }, { status: 404 })
}
// Try to revoke token with TikTok (best effort)
try {
const accessToken = decryptToken(account.access_token_encrypted)
await revokeToken(accessToken)
} catch (revokeError) {
// Log but don't fail - we'll still mark as revoked locally
console.error('Failed to revoke TikTok token:', revokeError)
}
// Update account status to revoked
const { error: updateError } = await supabase
.from('tiktok_accounts')
.update({ status: 'revoked' })
.eq('id', account_id)
if (updateError) {
throw updateError
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('TikTok disconnect error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Disconnect failed' },
{ status: 500 }
)
}
}
-60
View File
@@ -1,60 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getStatsSummary, calculateFollowerGrowth, getFollowerHistory } from '@/lib/tiktok/metrics'
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const accountId = searchParams.get('account_id')
const period = searchParams.get('period') as '7d' | '30d' | '90d' | '1y' | null
try {
// If no account_id, get summary for user's active account
if (!accountId) {
const summary = await getStatsSummary(user.id)
if (!summary) {
return NextResponse.json({ error: 'No connected TikTok account found' }, { status: 404 })
}
return NextResponse.json({ summary })
}
// Verify account belongs to user
const { data: account, error: fetchError } = await supabase
.from('tiktok_accounts')
.select('id')
.eq('id', accountId)
.eq('user_id', user.id)
.single()
if (fetchError || !account) {
return NextResponse.json({ error: 'Account not found' }, { status: 404 })
}
// Get growth data for specified period
if (period) {
const growth = await calculateFollowerGrowth(accountId, period)
return NextResponse.json({ growth })
}
// Get follower history (default 30 days)
const days = parseInt(searchParams.get('days') || '30', 10)
const history = await getFollowerHistory(accountId, days)
return NextResponse.json({ history })
} catch (error) {
console.error('Error fetching TikTok stats:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to fetch stats' },
{ status: 500 }
)
}
}
-78
View File
@@ -1,78 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { syncAccount } from '@/lib/tiktok/sync'
import type { TikTokAccount, TikTokSyncType } from '@/types'
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { account_id, sync_type = 'full' } = await request.json()
if (!account_id) {
return NextResponse.json({ error: 'account_id is required' }, { status: 400 })
}
// Validate sync_type
const validSyncTypes: TikTokSyncType[] = ['stats', 'videos', 'full']
if (!validSyncTypes.includes(sync_type)) {
return NextResponse.json(
{ error: 'Invalid sync_type. Must be one of: stats, videos, full' },
{ status: 400 }
)
}
try {
// Get account
const { data: account, error: fetchError } = await supabase
.from('tiktok_accounts')
.select('*')
.eq('id', account_id)
.eq('user_id', user.id)
.single()
if (fetchError || !account) {
return NextResponse.json({ error: 'Account not found' }, { status: 404 })
}
// Check if account is in a valid state for syncing
if (account.status === 'revoked') {
return NextResponse.json(
{ error: 'Account has been disconnected' },
{ status: 400 }
)
}
// Perform sync
const result = await syncAccount(account as TikTokAccount, sync_type)
if (!result.success) {
return NextResponse.json(
{
error: result.error || 'Sync failed',
errorCode: result.errorCode,
partial: result.statsSynced || result.videosSynced > 0,
},
{ status: 500 }
)
}
return NextResponse.json({
success: true,
stats_synced: result.statsSynced,
videos_synced: result.videosSynced,
new_videos: result.newVideos,
})
} catch (error) {
console.error('TikTok sync error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Sync failed' },
{ status: 500 }
)
}
}
-153
View File
@@ -1,153 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
interface RouteContext {
params: Promise<{ id: string }>
}
export async function POST(request: Request, context: RouteContext) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: videoId } = await context.params
const { campaign_id, deliverable_id } = await request.json()
if (!campaign_id && !deliverable_id) {
return NextResponse.json(
{ error: 'campaign_id or deliverable_id is required' },
{ status: 400 }
)
}
try {
// Verify video belongs to user
const { data: video, error: fetchError } = await supabase
.from('tiktok_videos')
.select('id, user_id')
.eq('id', videoId)
.eq('user_id', user.id)
.single()
if (fetchError || !video) {
return NextResponse.json({ error: 'Video not found' }, { status: 404 })
}
// Verify campaign belongs to user (if provided)
if (campaign_id) {
const { data: campaign, error: campaignError } = await supabase
.from('campaigns')
.select('id')
.eq('id', campaign_id)
.eq('user_id', user.id)
.single()
if (campaignError || !campaign) {
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
}
}
// Verify deliverable belongs to user (if provided)
if (deliverable_id) {
const { data: deliverable, error: deliverableError } = await supabase
.from('deliverables')
.select('id, campaign_id')
.eq('id', deliverable_id)
.eq('user_id', user.id)
.single()
if (deliverableError || !deliverable) {
return NextResponse.json({ error: 'Deliverable not found' }, { status: 404 })
}
// If deliverable is provided but campaign_id is not, use deliverable's campaign
if (!campaign_id && deliverable.campaign_id) {
const updateData = {
campaign_id: deliverable.campaign_id,
deliverable_id,
}
const { error: updateError } = await supabase
.from('tiktok_videos')
.update(updateData)
.eq('id', videoId)
if (updateError) {
throw updateError
}
return NextResponse.json({ success: true, ...updateData })
}
}
// Update video with campaign and/or deliverable link
const updateData: { campaign_id?: string; deliverable_id?: string } = {}
if (campaign_id) updateData.campaign_id = campaign_id
if (deliverable_id) updateData.deliverable_id = deliverable_id
const { error: updateError } = await supabase
.from('tiktok_videos')
.update(updateData)
.eq('id', videoId)
if (updateError) {
throw updateError
}
return NextResponse.json({ success: true, ...updateData })
} catch (error) {
console.error('Error linking video:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to link video' },
{ status: 500 }
)
}
}
export async function DELETE(request: Request, context: RouteContext) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: videoId } = await context.params
try {
// Verify video belongs to user
const { data: video, error: fetchError } = await supabase
.from('tiktok_videos')
.select('id, user_id')
.eq('id', videoId)
.eq('user_id', user.id)
.single()
if (fetchError || !video) {
return NextResponse.json({ error: 'Video not found' }, { status: 404 })
}
// Remove campaign and deliverable links
const { error: updateError } = await supabase
.from('tiktok_videos')
.update({ campaign_id: null, deliverable_id: null })
.eq('id', videoId)
if (updateError) {
throw updateError
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error unlinking video:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to unlink video' },
{ status: 500 }
)
}
}
-63
View File
@@ -1,63 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const accountId = searchParams.get('account_id')
const campaignId = searchParams.get('campaign_id')
const limit = parseInt(searchParams.get('limit') || '20', 10)
const offset = parseInt(searchParams.get('offset') || '0', 10)
const unlinkedOnly = searchParams.get('unlinked_only') === 'true'
try {
let query = supabase
.from('tiktok_videos')
.select(`
*,
campaign:campaigns(id, name),
deliverable:deliverables(id, title)
`, { count: 'exact' })
.eq('user_id', user.id)
.order('published_at', { ascending: false })
.range(offset, offset + limit - 1)
if (accountId) {
query = query.eq('tiktok_account_id', accountId)
}
if (campaignId) {
query = query.eq('campaign_id', campaignId)
}
if (unlinkedOnly) {
query = query.is('campaign_id', null)
}
const { data: videos, error, count } = await query
if (error) {
throw error
}
return NextResponse.json({
videos: videos || [],
total: count || 0,
limit,
offset,
})
} catch (error) {
console.error('Error fetching TikTok videos:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to fetch videos' },
{ status: 500 }
)
}
}
@@ -132,33 +132,6 @@ export async function POST(
? (category || 'uncategorized')
: 'private'
// Light mode: skip journal entry creation, just update category
if (entityType === 'light') {
const { error: updateError } = await supabase
.from('transactions')
.update({
is_business,
category: finalCategory,
})
.eq('id', id)
if (updateError) {
console.error('Failed to update transaction:', updateError)
return NextResponse.json(
{ error: 'Failed to update transaction' },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
journal_entry_created: false,
journal_entry_id: null,
journal_entry_error: null,
category: finalCategory,
})
}
// Build mapping result from category
const mappingResult = buildMappingResultFromCategory(
finalCategory,
+3 -3
View File
@@ -22,13 +22,13 @@ const fraunces = Fraunces({
});
export const metadata: Metadata = {
title: "Influencer Assistant",
description: "Ekonomihantering för svenska influencers",
title: "ERP Base",
description: "Ekonomihantering",
manifest: "/manifest.json",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "Influencer Assistant",
title: "ERP Base",
},
};
+1 -57
View File
@@ -2,7 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import DashboardNav from '@/components/dashboard/DashboardNav'
import DashboardContent from '@/components/dashboard/DashboardContent'
import type { Gift, GiftSummary, Deadline, Campaign, ReceiptQueueSummary } from '@/types'
import type { Deadline, ReceiptQueueSummary } from '@/types'
export default async function RootPage() {
const supabase = await createClient()
@@ -121,25 +121,6 @@ export default async function RootPage() {
.or(`due_date.lt.${today},due_date.lte.${nextWeek}`)
.order('due_date', { ascending: true })
// Fetch active campaigns with deliverables
const { data: campaigns } = await supabase
.from('campaigns')
.select(`
*,
customer:customers!campaigns_customer_id_fkey(id, name),
deliverables(*)
`)
.eq('user_id', user.id)
.in('status', ['negotiation', 'contracted', 'active'])
.order('created_at', { ascending: false })
// Fetch gift summary for current year
const { data: gifts } = await supabase
.from('gifts')
.select('estimated_value, classification')
.eq('user_id', user.id)
.gte('date', startOfYear.split('T')[0])
// Fetch receipt queue summary
const { count: pendingReviewCount } = await supabase
.from('receipts')
@@ -193,41 +174,6 @@ export default async function RootPage() {
streak_count: streakCount,
}
let giftSummary: GiftSummary | null = null
if (gifts && gifts.length > 0) {
giftSummary = {
year: new Date().getFullYear(),
total_count: gifts.length,
total_value: 0,
taxable_count: 0,
taxable_value: 0,
tax_free_count: 0,
tax_free_value: 0,
deductible_count: 0,
deductible_value: 0,
}
for (const gift of gifts as Pick<Gift, 'estimated_value' | 'classification'>[]) {
const value = Number(gift.estimated_value)
const classification = gift.classification
giftSummary.total_value += value
if (classification?.taxable) {
giftSummary.taxable_count++
giftSummary.taxable_value += value
} else {
giftSummary.tax_free_count++
giftSummary.tax_free_value += value
}
if (classification?.deductibleAsExpense) {
giftSummary.deductible_count++
giftSummary.deductible_value += value
}
}
}
return (
<div className="min-h-screen bg-background">
<DashboardNav companyName={settings.company_name || 'Min verksamhet'} entityType={settings.entity_type || 'enskild_firma'} />
@@ -247,9 +193,7 @@ export default async function RootPage() {
overdueInvoicesCount: overdueCount,
bankBalance,
mileageEntries: mileageEntries || [],
giftSummary,
deadlines: (deadlines || []) as Deadline[],
campaigns: (campaigns || []) as Campaign[],
receiptQueue,
}}
/>
-290
View File
@@ -1,290 +0,0 @@
'use client'
import { useState, useMemo } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
import { classifyGift, TAX_FREE_PROMO_THRESHOLD, getBookingTypeDisplayText } from '@/lib/benefits/gift-classifier'
import { formatCurrency } from '@/lib/utils'
import { Gift, CheckCircle, XCircle, AlertCircle, HelpCircle, AlertTriangle } from 'lucide-react'
import type { CreateGiftInput, GiftInput, GiftClassification } from '@/types'
interface GiftFormProps {
onSubmit: (data: CreateGiftInput) => Promise<void>
initialData?: Partial<CreateGiftInput & { returned?: boolean }>
isLoading?: boolean
isLightMode?: boolean
}
export default function GiftForm({ onSubmit, initialData, isLoading, isLightMode }: GiftFormProps) {
const [formData, setFormData] = useState<CreateGiftInput>({
date: initialData?.date || new Date().toISOString().split('T')[0],
brand_name: initialData?.brand_name || '',
description: initialData?.description || '',
estimated_value: initialData?.estimated_value || 0,
has_motprestation: initialData?.has_motprestation || false,
used_in_business: initialData?.used_in_business || false,
used_privately: initialData?.used_privately || false,
is_simple_promo: initialData?.is_simple_promo || false,
})
const [returned, setReturned] = useState(initialData?.returned || false)
const [valueOverridden, setValueOverridden] = useState(false)
// Compute classification from form data (derived state, no need for useEffect)
const classification = useMemo<GiftClassification | null>(() => {
if (formData.estimated_value > 0) {
const input: GiftInput = {
estimatedValue: formData.estimated_value,
hasMotprestation: formData.has_motprestation,
usedInBusiness: formData.used_in_business,
usedPrivately: formData.used_privately,
isSimplePromoItem: formData.is_simple_promo || false,
}
return classifyGift(input)
}
return null
}, [formData.estimated_value, formData.has_motprestation, formData.used_in_business, formData.used_privately, formData.is_simple_promo])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
await onSubmit({ ...formData, returned } as CreateGiftInput & { returned?: boolean })
}
const updateField = <K extends keyof CreateGiftInput>(field: K, value: CreateGiftInput[K]) => {
setFormData((prev) => ({ ...prev, [field]: value }))
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Gift className="h-5 w-5" />
Produktinformation
</CardTitle>
<CardDescription>Ange information om gåvan eller produkten du fått</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="date">Datum</Label>
<Input
id="date"
type="date"
value={formData.date}
onChange={(e) => updateField('date', e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="brand_name">Varumärke/Företag</Label>
<Input
id="brand_name"
placeholder="t.ex. Daniel Wellington"
value={formData.brand_name}
onChange={(e) => updateField('brand_name', e.target.value)}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">Beskrivning</Label>
<Textarea
id="description"
placeholder="t.ex. Klocka, modell Classic Petite"
value={formData.description}
onChange={(e) => updateField('description', e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="estimated_value">Uppskattat marknadsvärde (SEK)</Label>
<Input
id="estimated_value"
type="number"
min="0"
step="1"
placeholder="0"
value={formData.estimated_value || ''}
onChange={(e) => updateField('estimated_value', parseFloat(e.target.value) || 0)}
required
/>
<p className="text-xs text-muted-foreground">
Skattefritt om enklare reklamgåva under {formatCurrency(TAX_FREE_PROMO_THRESHOLD)}
</p>
</div>
<div className="flex items-center space-x-2">
<Switch
id="is_simple_promo"
checked={formData.is_simple_promo || false}
onCheckedChange={(checked) => updateField('is_simple_promo', checked)}
/>
<Label htmlFor="is_simple_promo" className="flex items-center gap-2">
Enklare reklamgåva
<HelpCircle className="h-4 w-4 text-muted-foreground" />
</Label>
</div>
<p className="text-xs text-muted-foreground ml-12">
T.ex. penna, mugg, t-shirt med företagslogo - inte exklusiva produkter
</p>
{/* Returned toggle */}
<div className="flex items-center space-x-2">
<Switch
id="returned"
checked={returned}
onCheckedChange={setReturned}
/>
<Label htmlFor="returned">Returnerad</Label>
</div>
{returned && (
<p className="text-xs text-success ml-12">
Returnerade gåvor räknas inte som skattepliktig inkomst.
</p>
)}
{/* Value override warning */}
{valueOverridden && (
<div className="flex items-start gap-2 p-3 bg-warning/10 border border-warning/30 rounded-lg">
<AlertTriangle className="h-4 w-4 text-warning mt-0.5 flex-shrink-0" />
<p className="text-xs text-warning">
Skatteverket kräver marknadsvärde vid mottagning (inkl. moms). Lägre värdering ökar revisionsrisken.
</p>
</div>
)}
</CardContent>
</Card>
{/* Decision Tree Questions */}
<Card>
<CardHeader>
<CardTitle>Skatteklassificering</CardTitle>
<CardDescription>Svara 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 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 att nämna/visa produkten
</p>
</div>
{/* Question 2: Business use - hidden for light mode */}
{!isLightMode && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="used_in_business" className="font-medium">
Använder du produkten i din content-produktion?
</Label>
<Switch
id="used_in_business"
checked={formData.used_in_business}
onCheckedChange={(checked) => updateField('used_in_business', checked)}
/>
</div>
<p className="text-sm text-muted-foreground">T.ex. som rekvisita, utrustning, eller i bakgrunden</p>
</div>
)}
{/* Question 3: Private use */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="used_privately" className="font-medium">
Använder du produkten privat?
</Label>
<Switch
id="used_privately"
checked={formData.used_privately}
onCheckedChange={(checked) => updateField('used_privately', checked)}
/>
</div>
<p className="text-sm text-muted-foreground">T.ex. bär klockan dagligen, använder sminket privat</p>
</div>
</CardContent>
</Card>
{/* Classification Preview */}
{classification && (
<Card
className={
classification.taxable
? classification.deductibleAsExpense
? 'border-warning/50 bg-warning/5'
: 'border-destructive/50 bg-destructive/5'
: 'border-success/50 bg-success/5'
}
>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{classification.taxable ? (
classification.deductibleAsExpense ? (
<AlertCircle className="h-5 w-5 text-warning" />
) : (
<XCircle className="h-5 w-5 text-destructive" />
)
) : (
<CheckCircle className="h-5 w-5 text-success" />
)}
Klassificering
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
<Badge variant={classification.taxable ? 'destructive' : 'default'}>
{classification.taxable ? 'Skattepliktig' : 'Skattefri'}
</Badge>
{classification.deductibleAsExpense && <Badge variant="outline">Avdragsgill</Badge>}
<Badge variant="secondary">{getBookingTypeDisplayText(classification.bookingType)}</Badge>
</div>
<div className="text-sm">
<p className="font-medium">Förklaring:</p>
<p className="text-muted-foreground">{classification.reasoning}</p>
</div>
{classification.taxable && (
<div className="text-sm border-t pt-4">
<p className="font-medium">Skatteeffekt:</p>
<p className="text-muted-foreground">
{isLightMode ? (
<>Gåvoskatt: ca {formatCurrency(classification.marketValue * 0.32)} ({Math.round(32)}% av marknadsvärde {formatCurrency(classification.marketValue)}). Skatten ingår inte i din paraplyföretags hantering.</>
) : (
<>
Marknadsvärdet {formatCurrency(classification.marketValue)} läggs till din beskattningsbara inkomst.
{classification.deductibleAsExpense && ' Eftersom produkten endast används i verksamheten är den avdragsgill som kostnad.'}
</>
)}
</p>
</div>
)}
</CardContent>
</Card>
)}
{/* Submit */}
<div className="flex justify-end gap-2">
<Button type="submit" disabled={isLoading || !formData.brand_name || !formData.description}>
{isLoading ? 'Sparar...' : initialData ? 'Uppdatera' : 'Spara gåva'}
</Button>
</div>
</form>
)
}
-198
View File
@@ -1,198 +0,0 @@
'use client'
import { useState } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { formatCurrency } from '@/lib/utils'
import { Gift, Pencil, Trash2, Calendar, Building } from 'lucide-react'
import type { Gift as GiftType } from '@/types'
interface GiftListProps {
gifts: GiftType[]
onEdit: (gift: GiftType) => void
onDelete: (id: string) => Promise<void>
isDeleting?: boolean
}
type FilterType = 'all' | 'taxable' | 'tax_free' | 'deductible'
export default function GiftList({ gifts, onEdit, onDelete, isDeleting }: GiftListProps) {
const [filter, setFilter] = useState<FilterType>('all')
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [giftToDelete, setGiftToDelete] = useState<GiftType | null>(null)
// Filter gifts based on selection
const filteredGifts = gifts.filter((gift) => {
if (filter === 'all') return true
if (filter === 'taxable') return gift.classification?.taxable
if (filter === 'tax_free') return !gift.classification?.taxable
if (filter === 'deductible') return gift.classification?.deductibleAsExpense
return true
})
const handleDeleteClick = (gift: GiftType) => {
setGiftToDelete(gift)
setDeleteDialogOpen(true)
}
const handleConfirmDelete = async () => {
if (giftToDelete) {
await onDelete(giftToDelete.id)
setDeleteDialogOpen(false)
setGiftToDelete(null)
}
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('sv-SE', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
if (gifts.length === 0) {
return (
<Card>
<CardContent className="pt-6">
<div className="text-center py-8">
<Gift className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<h3 className="text-lg font-medium">Inga gåvor registrerade</h3>
<p className="text-sm text-muted-foreground mt-1">
Lägg till gåvor och produkter du fått för att hålla koll 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 att du vill ta bort &quot;{giftToDelete?.description}&quot; från{' '}
{giftToDelete?.brand_name}? Detta kan inte ångras.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
Avbryt
</Button>
<Button variant="destructive" onClick={handleConfirmDelete} disabled={isDeleting}>
{isDeleting ? 'Tar bort...' : 'Ta bort'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
-55
View File
@@ -1,55 +0,0 @@
'use client'
import Link from 'next/link'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { formatCurrency } from '@/lib/utils'
import { Gift, TrendingUp } from 'lucide-react'
import type { GiftSummary } from '@/types'
interface GiftSummaryCardProps {
summary: GiftSummary | null
}
export default function GiftSummaryCard({ summary }: GiftSummaryCardProps) {
// Don't show card if no gifts
if (!summary || summary.total_count === 0) {
return null
}
return (
<Card>
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<Gift className="h-5 w-5 flex-shrink-0 text-primary" />
<div>
<p className="font-medium">Gåvor & Förmåner</p>
<p className="text-sm text-muted-foreground">
{summary.total_count} produkter · {formatCurrency(summary.total_value)} totalt
</p>
{summary.taxable_count > 0 && (
<div className="flex items-center gap-2 mt-1">
<TrendingUp className="h-3 w-3 text-destructive" />
<span className="text-xs text-destructive">
{formatCurrency(summary.taxable_value)} skattepliktig inkomst
</span>
</div>
)}
{summary.deductible_count > 0 && (
<p className="text-xs text-muted-foreground mt-1">
{formatCurrency(summary.deductible_value)} avdragsgill kostnad
</p>
)}
</div>
</div>
<Link href="/gifts">
<Button variant="outline" size="sm">
Visa
</Button>
</Link>
</div>
</CardContent>
</Card>
)
}
-5
View File
@@ -102,12 +102,7 @@ export function DeadlineForm({
notes: formData.notes || null,
is_completed: initialData?.is_completed || false,
completed_at: initialData?.completed_at || null,
campaign_id: null,
deliverable_id: null,
is_auto_generated: false,
date_calculation_type: null,
reference_event: null,
offset_days: null,
// New tax deadline fields with defaults for user-created deadlines
tax_deadline_type: null,
tax_period: null,
-478
View File
@@ -1,478 +0,0 @@
'use client'
import { useState, useRef } from 'react'
import { Briefing, BriefingType, BRIEFING_TYPE_LABELS } from '@/types'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Progress } from '@/components/ui/progress'
import { useToast } from '@/components/ui/use-toast'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Upload, FileText, X, Link2, AlignLeft, Loader2, Sparkles } from 'lucide-react'
import { cn } from '@/lib/utils'
interface BriefingFormProps {
campaignId: string
briefing?: Briefing | null
onSuccess: () => void
onCancel: () => void
}
export function BriefingForm({ campaignId, briefing, onSuccess, onCancel }: BriefingFormProps) {
const { toast } = useToast()
const fileInputRef = useRef<HTMLInputElement>(null)
const isEditing = !!briefing
// Form state
const [briefingType, setBriefingType] = useState<BriefingType>(briefing?.briefing_type || 'link')
const [title, setTitle] = useState(briefing?.title || '')
const [linkUrl, setLinkUrl] = useState(briefing?.briefing_type === 'link' ? briefing?.content || '' : '')
const [textContent, setTextContent] = useState(briefing?.text_content || '')
const [notes, setNotes] = useState(briefing?.notes || '')
// File upload state
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
// AI summary state
const [aiSummary, setAiSummary] = useState('')
const [isSummarizing, setIsSummarizing] = useState(false)
const handleSummarize = async () => {
if (!textContent.trim()) {
toast({
title: 'Text saknas',
description: 'Ange text att sammanfatta',
variant: 'destructive',
})
return
}
setIsSummarizing(true)
try {
const response = await fetch('/api/briefings/summarize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: textContent }),
})
if (!response.ok) {
throw new Error('Summarization failed')
}
const { data } = await response.json()
setAiSummary(data.summary)
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte sammanfatta texten',
variant: 'destructive',
})
} finally {
setIsSummarizing(false)
}
}
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
// Validate file type (only PDF for briefings)
if (file.type !== 'application/pdf') {
toast({
title: 'Ogiltig filtyp',
description: 'Endast PDF-filer ar tillåtna',
variant: 'destructive',
})
return
}
// Check file size (10MB max)
if (file.size > 10 * 1024 * 1024) {
toast({
title: 'Filen ar for stor',
description: 'Max filstorlek ar 10MB',
variant: 'destructive',
})
return
}
setSelectedFile(file)
// Auto-fill title from filename if empty
if (!title) {
setTitle(file.name.replace(/\.pdf$/i, ''))
}
}
}
const clearFileSelection = () => {
setSelectedFile(null)
if (fileInputRef.current) fileInputRef.current.value = ''
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// Validation
if (!title.trim()) {
toast({
title: 'Titel saknas',
description: 'Ange en titel for briefingen',
variant: 'destructive',
})
return
}
if (briefingType === 'link' && !linkUrl.trim()) {
toast({
title: 'Lank saknas',
description: 'Ange en URL for briefingen',
variant: 'destructive',
})
return
}
if (briefingType === 'text' && !textContent.trim()) {
toast({
title: 'Text saknas',
description: 'Ange textinnehall for briefingen',
variant: 'destructive',
})
return
}
if (briefingType === 'pdf' && !selectedFile && !isEditing) {
toast({
title: 'Fil saknas',
description: 'Valj en PDF-fil att ladda upp',
variant: 'destructive',
})
return
}
setIsSubmitting(true)
setUploadProgress(0)
try {
if (isEditing) {
// Update existing briefing
const updateData: Record<string, unknown> = {
title: title.trim(),
notes: notes.trim() || null,
}
if (briefingType === 'link') {
updateData.content = linkUrl.trim()
} else if (briefingType === 'text') {
updateData.text_content = textContent.trim()
}
const response = await fetch(`/api/briefings/${briefing.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updateData),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Update failed')
}
toast({
title: 'Briefing uppdaterad',
description: title,
})
} else if (briefingType === 'pdf' && selectedFile) {
// Upload PDF file
const formData = new FormData()
formData.append('file', selectedFile)
formData.append('title', title.trim())
if (notes.trim()) formData.append('notes', notes.trim())
// Simulate progress
const progressInterval = setInterval(() => {
setUploadProgress(prev => Math.min(prev + 10, 90))
}, 200)
const response = await fetch(`/api/campaigns/${campaignId}/briefings/upload`, {
method: 'POST',
body: formData,
})
clearInterval(progressInterval)
setUploadProgress(100)
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Upload failed')
}
toast({
title: 'Briefing uppladdad',
description: title,
})
} else {
// Create link or text briefing
const createData = {
briefing_type: briefingType,
title: title.trim(),
content: briefingType === 'link' ? linkUrl.trim() : null,
text_content: briefingType === 'text' ? textContent.trim() : null,
notes: notes.trim() || null,
}
const response = await fetch(`/api/campaigns/${campaignId}/briefings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(createData),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Create failed')
}
toast({
title: 'Briefing tillagd',
description: title,
})
}
onSuccess()
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Något gick fel',
variant: 'destructive',
})
} finally {
setIsSubmitting(false)
setUploadProgress(0)
}
}
const getTypeIcon = (type: BriefingType) => {
switch (type) {
case 'pdf':
return <FileText className="h-4 w-4" />
case 'link':
return <Link2 className="h-4 w-4" />
case 'text':
return <AlignLeft className="h-4 w-4" />
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Type selector - only show when creating new */}
{!isEditing && (
<div>
<Label>Typ av briefing</Label>
<div className="grid grid-cols-3 gap-2 mt-1.5">
{(['link', 'text', 'pdf'] as BriefingType[]).map(type => (
<button
key={type}
type="button"
onClick={() => setBriefingType(type)}
className={cn(
'flex items-center justify-center gap-2 p-3 border rounded-lg transition-colors',
briefingType === type
? 'border-primary bg-primary/10 text-primary'
: 'border-muted hover:border-primary/50 hover:bg-muted/50'
)}
>
{getTypeIcon(type)}
<span className="text-sm font-medium">{BRIEFING_TYPE_LABELS[type]}</span>
</button>
))}
</div>
</div>
)}
{/* Title */}
<div>
<Label htmlFor="title">Titel *</Label>
<Input
id="title"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="T.ex. Kampanjbrief Q1 2025"
disabled={isSubmitting}
/>
</div>
{/* Type-specific content */}
{briefingType === 'link' && (
<div>
<Label htmlFor="link">URL *</Label>
<Input
id="link"
type="url"
value={linkUrl}
onChange={e => setLinkUrl(e.target.value)}
placeholder="https://docs.google.com/... eller https://www.canva.com/..."
disabled={isSubmitting}
/>
<p className="text-xs text-muted-foreground mt-1">
Lankar till Google Docs, Canva, Dropbox, etc.
</p>
</div>
)}
{briefingType === 'text' && (
<div className="space-y-3">
<div>
<Label htmlFor="text">Textinnehall *</Label>
<Textarea
id="text"
value={textContent}
onChange={e => setTextContent(e.target.value)}
placeholder="Klistra in text fran e-post eller annat..."
rows={8}
disabled={isSubmitting}
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleSummarize}
disabled={isSummarizing || !textContent.trim()}
>
{isSummarizing ? (
<>
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
Sammanfattar...
</>
) : (
<>
<Sparkles className="h-4 w-4 mr-1" />
Sammanfatta med AI
</>
)}
</Button>
{aiSummary && (
<div className="p-4 border rounded-lg bg-muted/30">
<div className="flex items-center justify-between mb-2">
<Label className="text-sm font-medium">AI-sammanfattning</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setAiSummary('')}
>
<X className="h-3 w-3" />
</Button>
</div>
<div className="text-sm whitespace-pre-wrap text-muted-foreground">
{aiSummary}
</div>
</div>
)}
</div>
)}
{briefingType === 'pdf' && !isEditing && (
<div>
<Label>PDF-fil *</Label>
<input
ref={fileInputRef}
type="file"
accept=".pdf"
onChange={handleFileSelect}
className="hidden"
/>
{selectedFile ? (
<div className="flex items-center gap-3 p-3 border rounded-lg bg-muted/50 mt-1.5">
<FileText className="h-8 w-8 text-red-500" />
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{selectedFile.name}</p>
<p className="text-sm text-muted-foreground">
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={clearFileSelection}
disabled={isSubmitting}
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="w-full p-6 border-2 border-dashed rounded-lg hover:border-primary hover:bg-muted/50 transition-colors mt-1.5"
>
<div className="flex flex-col items-center gap-2">
<Upload className="h-8 w-8 text-muted-foreground" />
<p className="font-medium">Klicka for att valja PDF-fil</p>
<p className="text-sm text-muted-foreground">Max 10MB</p>
</div>
</button>
)}
</div>
)}
{/* Notes */}
<div>
<Label htmlFor="notes">Anteckningar (valfritt)</Label>
<Textarea
id="notes"
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder="Egna anteckningar om briefingen..."
rows={2}
disabled={isSubmitting}
/>
</div>
{/* Upload progress */}
{isSubmitting && briefingType === 'pdf' && uploadProgress > 0 && (
<div className="space-y-2">
<Progress value={uploadProgress} />
<p className="text-sm text-muted-foreground text-center">
Laddar upp... {uploadProgress}%
</p>
</div>
)}
{/* Actions */}
<div className="flex gap-2 justify-end">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
>
Avbryt
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{isEditing ? 'Sparar...' : 'Laddar upp...'}
</>
) : isEditing ? (
'Spara andringar'
) : (
'Lagg till briefing'
)}
</Button>
</div>
</form>
)
}
-359
View File
@@ -1,359 +0,0 @@
'use client'
import { useState, useRef } from 'react'
import { Briefing, BriefingType, BRIEFING_TYPE_LABELS } from '@/types'
import { BriefingForm } from './BriefingForm'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import {
FileText,
Link2,
AlignLeft,
Download,
Trash2,
Edit2,
Plus,
ChevronUp,
ExternalLink,
ChevronDown,
Loader2,
} from 'lucide-react'
import { cn } from '@/lib/utils'
interface BriefingListProps {
campaignId: string
briefings: Briefing[]
onUpdate: () => void
}
export function BriefingList({ campaignId, briefings, onUpdate }: BriefingListProps) {
const { toast } = useToast()
const [showForm, setShowForm] = useState(false)
const [editingBriefing, setEditingBriefing] = useState<Briefing | null>(null)
const [downloadingId, setDownloadingId] = useState<string | null>(null)
const [expandedTextId, setExpandedTextId] = useState<string | null>(null)
const handleDownload = async (briefing: Briefing) => {
if (briefing.briefing_type !== 'pdf') return
setDownloadingId(briefing.id)
try {
const response = await fetch(`/api/briefings/${briefing.id}/download`)
if (!response.ok) {
throw new Error('Download failed')
}
const data = await response.json()
window.open(data.url, '_blank')
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte ladda ner filen',
variant: 'destructive',
})
} finally {
setDownloadingId(null)
}
}
const handleDelete = async (briefing: Briefing) => {
if (!confirm(`Ta bort "${briefing.title}"?`)) return
try {
const response = await fetch(`/api/briefings/${briefing.id}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Delete failed')
}
toast({
title: 'Briefing borttagen',
description: briefing.title,
})
onUpdate()
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte ta bort briefingen',
variant: 'destructive',
})
}
}
const handleOpenLink = (briefing: Briefing) => {
if (briefing.briefing_type !== 'link' || !briefing.content) return
window.open(briefing.content, '_blank', 'noopener,noreferrer')
}
const handleEdit = (briefing: Briefing) => {
setEditingBriefing(briefing)
setShowForm(true)
}
const handleFormClose = () => {
setShowForm(false)
setEditingBriefing(null)
}
const handleFormSuccess = () => {
handleFormClose()
onUpdate()
}
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('sv-SE', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
const formatFileSize = (bytes: number | null) => {
if (!bytes) return ''
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
const getTypeIcon = (type: BriefingType) => {
switch (type) {
case 'pdf':
return <FileText className="h-5 w-5 text-red-500" />
case 'link':
return <Link2 className="h-5 w-5 text-blue-500" />
case 'text':
return <AlignLeft className="h-5 w-5 text-green-500" />
}
}
const getTypeBadgeColor = (type: BriefingType) => {
switch (type) {
case 'pdf':
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
case 'link':
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
case 'text':
return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
}
}
const truncateUrl = (url: string, maxLength: number = 40) => {
if (url.length <= maxLength) return url
return url.substring(0, maxLength) + '...'
}
// Sort by created date (newest first)
const sortedBriefings = [...briefings].sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium">
Briefing
<span className="text-muted-foreground ml-2">
({briefings.length})
</span>
</h3>
<Button
size="sm"
variant={showForm ? 'secondary' : 'default'}
onClick={() => {
if (showForm) {
handleFormClose()
} else {
setShowForm(true)
}
}}
>
{showForm ? (
<>
<ChevronUp className="h-4 w-4 mr-1" />
Stang
</>
) : (
<>
<Plus className="h-4 w-4 mr-1" />
Lagg till
</>
)}
</Button>
</div>
{/* Form section */}
{showForm && (
<div className="p-4 border rounded-lg bg-muted/30">
<BriefingForm
campaignId={campaignId}
briefing={editingBriefing}
onSuccess={handleFormSuccess}
onCancel={handleFormClose}
/>
</div>
)}
{/* Briefing list */}
{sortedBriefings.length > 0 ? (
<div className="space-y-2">
{sortedBriefings.map(briefing => (
<div
key={briefing.id}
className="p-3 border rounded-lg hover:bg-muted/50 transition-colors"
>
<div className="flex items-start gap-3">
<div className="mt-0.5">
{getTypeIcon(briefing.briefing_type)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium">{briefing.title}</p>
<Badge
variant="secondary"
className={cn('text-xs', getTypeBadgeColor(briefing.briefing_type))}
>
{BRIEFING_TYPE_LABELS[briefing.briefing_type]}
</Badge>
</div>
{/* Type-specific content display */}
{briefing.briefing_type === 'pdf' && briefing.filename && (
<p className="text-sm text-muted-foreground mt-1">
{briefing.filename}
{briefing.file_size && (
<span className="ml-2">({formatFileSize(briefing.file_size)})</span>
)}
</p>
)}
{briefing.briefing_type === 'link' && briefing.content && (
<button
onClick={() => handleOpenLink(briefing)}
className="text-sm text-blue-600 hover:underline flex items-center gap-1 mt-1"
>
{truncateUrl(briefing.content)}
<ExternalLink className="h-3 w-3" />
</button>
)}
{briefing.briefing_type === 'text' && briefing.text_content && (
<div className="mt-2">
<div
className={cn(
'text-sm text-muted-foreground bg-muted/50 rounded p-2 whitespace-pre-wrap',
expandedTextId !== briefing.id && 'max-h-20 overflow-hidden'
)}
>
{briefing.text_content}
</div>
{briefing.text_content.length > 200 && (
<button
onClick={() =>
setExpandedTextId(
expandedTextId === briefing.id ? null : briefing.id
)
}
className="text-xs text-primary hover:underline mt-1 flex items-center gap-1"
>
{expandedTextId === briefing.id ? (
<>
<ChevronUp className="h-3 w-3" />
Visa mindre
</>
) : (
<>
<ChevronDown className="h-3 w-3" />
Visa mer
</>
)}
</button>
)}
</div>
)}
{briefing.notes && (
<p className="text-sm text-muted-foreground italic mt-1">
{briefing.notes}
</p>
)}
<p className="text-xs text-muted-foreground mt-1">
Tillagd: {formatDate(briefing.created_at)}
</p>
</div>
<div className="flex gap-1 shrink-0">
{briefing.briefing_type === 'pdf' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleDownload(briefing)}
disabled={downloadingId === briefing.id}
title="Ladda ner"
>
{downloadingId === briefing.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
</Button>
)}
{briefing.briefing_type === 'link' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenLink(briefing)}
title="Oppna lank"
>
<ExternalLink className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(briefing)}
title="Redigera"
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(briefing)}
className="text-destructive hover:text-destructive"
title="Ta bort"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
))}
</div>
) : !showForm ? (
<div className="text-center py-8 text-muted-foreground">
<FileText className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>Ingen briefing tillagd</p>
<p className="text-sm mt-1">
Lagg till PDF-dokument, lankar eller text fran uppdragsgivaren
</p>
<Button
variant="outline"
size="sm"
className="mt-3"
onClick={() => setShowForm(true)}
>
<Plus className="h-4 w-4 mr-1" />
Lagg till briefing
</Button>
</div>
) : null}
</div>
)
}
-175
View File
@@ -1,175 +0,0 @@
'use client'
import { Campaign, CAMPAIGN_TYPE_LABELS } from '@/types'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { CampaignStatusBadge } from './CampaignStatusBadge'
import { cn } from '@/lib/utils'
import {
Building2,
Calendar,
FileText,
Package,
ChevronRight,
Banknote,
Clock,
AlertCircle,
} from 'lucide-react'
import Link from 'next/link'
interface CampaignCardProps {
campaign: Campaign
onClick?: () => void
}
export function CampaignCard({ campaign, onClick }: CampaignCardProps) {
const formatCurrency = (amount: number | null) => {
if (!amount) return '-'
return new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency: campaign.currency || 'SEK',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount)
}
const formatDate = (date: string | null) => {
if (!date) return '-'
return new Date(date).toLocaleDateString('sv-SE', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
// Deliverable progress
const totalDeliverables = campaign.deliverables?.length || 0
const completedDeliverables = campaign.deliverables?.filter(d =>
['approved', 'published'].includes(d.status)
).length || 0
const pendingDeliverables = totalDeliverables - completedDeliverables
const progressPercent = totalDeliverables > 0 ? Math.round((completedDeliverables / totalDeliverables) * 100) : 0
// Find next due date
const nextDueDate = campaign.deliverables
?.filter(d => d.due_date && !['approved', 'published'].includes(d.status))
.sort((a, b) => new Date(a.due_date!).getTime() - new Date(b.due_date!).getTime())[0]?.due_date
// Check if next deadline is soon (within 3 days)
const isDeadlineSoon = nextDueDate && (() => {
const daysUntil = Math.round((new Date(nextDueDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
return daysUntil <= 3 && daysUntil >= 0
})()
// Check for active exclusivities
const hasActiveExclusivity = campaign.exclusivities?.some(e => {
const today = new Date().toISOString().split('T')[0]
return e.start_date <= today && e.end_date >= today
})
// Customer initial for avatar
const customerInitial = campaign.customer?.name?.charAt(0)?.toUpperCase() || '?'
return (
<Link href={`/campaigns/${campaign.id}`}>
<Card
className={cn(
'hover-lift cursor-pointer',
campaign.status === 'cancelled' && 'opacity-60'
)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-3 flex-1 min-w-0">
{/* Customer avatar */}
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-sm font-medium text-primary">
{customerInitial}
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-lg truncate">{campaign.name || campaign.customer?.name || 'Namnlöst samarbete'}</CardTitle>
{campaign.customer && (
<p className="text-sm text-muted-foreground mt-0.5 truncate">
{campaign.customer.name}
</p>
)}
</div>
</div>
<div className="flex flex-col items-end gap-1">
<CampaignStatusBadge status={campaign.status} />
<Badge variant="secondary" className="text-xs">
{CAMPAIGN_TYPE_LABELS[campaign.campaign_type]}
</Badge>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
{/* Deliverable progress bar */}
{totalDeliverables > 0 && (
<div className="mb-4">
<div className="flex items-center justify-between text-xs mb-1.5">
<span className="text-muted-foreground">Innehåll</span>
<span className="font-medium">{completedDeliverables}/{totalDeliverables} klara</span>
</div>
<Progress value={progressPercent} className="h-1.5" />
</div>
)}
<div className="grid grid-cols-2 gap-3 text-sm">
{/* Value */}
<div className="flex items-center gap-2 text-muted-foreground">
<Banknote className="h-4 w-4" />
<span className="font-medium text-foreground tabular-nums">
{formatCurrency(campaign.total_value)}
</span>
</div>
{/* Date range */}
<div className="flex items-center gap-2 text-muted-foreground">
<Calendar className="h-4 w-4" />
<span>
{campaign.start_date ? formatDate(campaign.start_date) : '-'}
</span>
</div>
{/* Next deadline - more prominent if soon */}
{nextDueDate && (
<div className={cn(
"flex items-center gap-2",
isDeadlineSoon ? "text-warning-foreground" : "text-muted-foreground"
)}>
{isDeadlineSoon ? (
<AlertCircle className="h-4 w-4" />
) : (
<Clock className="h-4 w-4" />
)}
<span className={isDeadlineSoon ? 'font-medium' : ''}>
{formatDate(nextDueDate)}
</span>
</div>
)}
{/* Contracts */}
{campaign.contracts && campaign.contracts.length > 0 && (
<div className="flex items-center gap-2 text-muted-foreground">
<FileText className="h-4 w-4" />
<span>{campaign.contracts.length} avtal</span>
</div>
)}
{/* Exclusivity indicator */}
{hasActiveExclusivity && (
<Badge variant="outline" className="text-xs w-fit">
Aktiv exklusivitet
</Badge>
)}
</div>
<div className="flex justify-end mt-4">
<ChevronRight className="h-5 w-5 text-muted-foreground" />
</div>
</CardContent>
</Card>
</Link>
)
}
-514
View File
@@ -1,514 +0,0 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import {
Campaign,
CAMPAIGN_STATUS_LABELS,
CAMPAIGN_TYPE_LABELS,
BILLING_FREQUENCY_LABELS,
CampaignStatus
} from '@/types'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
import { CampaignStatusBadge } from './CampaignStatusBadge'
import { DeliverableList } from './DeliverableList'
import { ExclusivityList } from './ExclusivityList'
import { ContractList } from './ContractList'
import { BriefingList } from './BriefingList'
import { CampaignInvoiceSummary } from './CampaignInvoiceSummary'
import { cn } from '@/lib/utils'
import {
Building2,
Calendar,
Banknote,
Edit2,
Trash2,
ArrowLeft,
ChevronRight,
Clock,
FileText,
Package,
Shield,
Receipt,
BookOpen,
Check,
Circle,
} from 'lucide-react'
import Link from 'next/link'
interface CampaignDetailProps {
campaign: Campaign
onUpdate: () => void
onEdit: () => void
}
const STATUS_TRANSITIONS: Record<CampaignStatus, CampaignStatus[]> = {
negotiation: ['contracted', 'cancelled'],
contracted: ['active', 'cancelled'],
active: ['delivered', 'cancelled'],
delivered: ['invoiced', 'active'],
invoiced: ['completed', 'delivered'],
completed: ['invoiced'],
cancelled: ['negotiation'],
}
const LIFECYCLE_STEPS: { status: CampaignStatus; label: string }[] = [
{ status: 'negotiation', label: 'Förhandling' },
{ status: 'contracted', label: 'Avtalat' },
{ status: 'active', label: 'Aktivt' },
{ status: 'delivered', label: 'Levererat' },
{ status: 'invoiced', label: 'Fakturerat' },
{ status: 'completed', label: 'Klart' },
]
function getStepState(stepStatus: CampaignStatus, currentStatus: CampaignStatus): 'completed' | 'current' | 'upcoming' {
const stepIndex = LIFECYCLE_STEPS.findIndex(s => s.status === stepStatus)
const currentIndex = LIFECYCLE_STEPS.findIndex(s => s.status === currentStatus)
if (currentStatus === 'cancelled') return 'upcoming'
if (stepIndex < currentIndex) return 'completed'
if (stepIndex === currentIndex) return 'current'
return 'upcoming'
}
export function CampaignDetail({ campaign, onUpdate, onEdit }: CampaignDetailProps) {
const router = useRouter()
const { toast } = useToast()
const [isUpdating, setIsUpdating] = useState(false)
const formatCurrency = (amount: number | null) => {
if (!amount) return '-'
return new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency: campaign.currency || 'SEK',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount)
}
const formatDate = (date: string | null) => {
if (!date) return '-'
return new Date(date).toLocaleDateString('sv-SE', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
const handleStatusChange = async (newStatus: CampaignStatus) => {
setIsUpdating(true)
try {
const response = await fetch(`/api/campaigns/${campaign.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus }),
})
if (!response.ok) {
throw new Error('Failed to update status')
}
toast({
title: 'Status uppdaterad',
description: CAMPAIGN_STATUS_LABELS[newStatus],
})
onUpdate()
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte uppdatera status',
variant: 'destructive',
})
} finally {
setIsUpdating(false)
}
}
const handleDelete = async () => {
if (!confirm(`Ta bort samarbetet "${campaign.name}"? Detta kan inte ångras.`)) return
try {
const response = await fetch(`/api/campaigns/${campaign.id}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Failed to delete')
}
toast({
title: 'Samarbete borttaget',
description: campaign.name,
})
router.push('/campaigns')
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte ta bort samarbetet',
variant: 'destructive',
})
}
}
const availableTransitions = STATUS_TRANSITIONS[campaign.status] || []
// Count items
const deliverableCount = campaign.deliverables?.length || 0
const exclusivityCount = campaign.exclusivities?.length || 0
const contractCount = campaign.contracts?.length || 0
const invoiceCount = campaign.invoices?.length || 0
const briefingCount = campaign.briefings?.length || 0
// Deliverable progress
const completedDeliverables = campaign.deliverables?.filter(d =>
['approved', 'published'].includes(d.status)
).length || 0
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<Link
href="/campaigns"
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 mb-2"
>
<ArrowLeft className="h-4 w-4" />
Tillbaka till samarbeten
</Link>
<h1 className="text-3xl font-bold tracking-tight">{campaign.name}</h1>
{(campaign.customer || campaign.brand_name) && (
<p className="text-muted-foreground flex items-center gap-1 mt-1">
{campaign.brand_name && (
<span className="flex items-center gap-1">
{campaign.brand_name}
</span>
)}
{campaign.brand_name && campaign.customer && (
<ChevronRight className="h-4 w-4" />
)}
{campaign.customer && (
<span className="flex items-center gap-1">
<Building2 className="h-4 w-4" />
{campaign.customer.name}
</span>
)}
</p>
)}
</div>
<div className="flex items-center gap-2">
{/* Prominent "Create invoice" button when status is delivered */}
{campaign.status === 'delivered' && (
<Button
onClick={() => router.push(`/invoices/new?campaign_id=${campaign.id}&customer_id=${campaign.customer_id}`)}
className="bg-accent hover:bg-accent/90 text-accent-foreground"
>
<Receipt className="h-4 w-4 mr-1" />
Skapa faktura
</Button>
)}
<Button variant="outline" size="sm" onClick={onEdit}>
<Edit2 className="h-4 w-4 mr-1" />
Redigera
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDelete}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4 mr-1" />
Ta bort
</Button>
</div>
</div>
{/* Status and quick info */}
<div className="flex flex-wrap items-center gap-3">
<CampaignStatusBadge status={campaign.status} />
<Badge variant="secondary">{CAMPAIGN_TYPE_LABELS[campaign.campaign_type]}</Badge>
{availableTransitions.length > 0 && (
<Select
value=""
onValueChange={(v) => handleStatusChange(v as CampaignStatus)}
disabled={isUpdating}
>
<SelectTrigger className="w-[180px] h-8 text-sm">
<SelectValue placeholder="Ändra status..." />
</SelectTrigger>
<SelectContent>
{availableTransitions.map(status => (
<SelectItem key={status} value={status}>
{CAMPAIGN_STATUS_LABELS[status]}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Campaign lifecycle timeline */}
{campaign.status !== 'cancelled' && (
<Card>
<CardContent className="py-4">
<div className="flex items-center justify-between">
{LIFECYCLE_STEPS.map((step, index) => {
const state = getStepState(step.status, campaign.status)
return (
<div key={step.status} className="flex items-center flex-1">
<div className="flex flex-col items-center">
<div className={cn(
"w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium transition-colors",
state === 'completed' && "bg-success text-success-foreground",
state === 'current' && "bg-primary text-primary-foreground ring-4 ring-primary/20",
state === 'upcoming' && "bg-muted text-muted-foreground"
)}>
{state === 'completed' ? (
<Check className="h-4 w-4" />
) : (
<Circle className="h-3 w-3" />
)}
</div>
<span className={cn(
"text-[10px] mt-1.5 text-center leading-tight whitespace-nowrap",
state === 'current' ? "text-foreground font-medium" : "text-muted-foreground"
)}>
{step.label}
</span>
</div>
{index < LIFECYCLE_STEPS.length - 1 && (
<div className={cn(
"flex-1 h-0.5 mx-1",
state === 'completed' ? "bg-success" : "bg-muted"
)} />
)}
</div>
)
})}
</div>
</CardContent>
</Card>
)}
{/* Overview cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card className="hover-lift">
<CardContent className="pt-4">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Banknote className="h-4 w-4" />
<span className="text-sm">Arvode</span>
</div>
<p className="text-2xl font-bold tabular-nums">{formatCurrency(campaign.total_value)}</p>
{campaign.vat_included && (
<p className="text-xs text-muted-foreground">inkl. moms</p>
)}
</CardContent>
</Card>
<Card className="hover-lift">
<CardContent className="pt-4">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Calendar className="h-4 w-4" />
<span className="text-sm">Publiceringsdatum</span>
</div>
<p className="font-medium">
{campaign.publication_date ? formatDate(campaign.publication_date) : 'Ej satt'}
</p>
{campaign.draft_deadline && (
<p className="text-sm text-muted-foreground">
Utkast: {formatDate(campaign.draft_deadline)}
</p>
)}
</CardContent>
</Card>
<Card className="hover-lift">
<CardContent className="pt-4">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Package className="h-4 w-4" />
<span className="text-sm">Innehåll</span>
</div>
<p className="font-medium">
{deliverableCount > 0 ? (
<span>{completedDeliverables}/{deliverableCount} klara</span>
) : (
<span className="text-muted-foreground">Inga</span>
)}
</p>
{deliverableCount > 0 && (
<div className="mt-2 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-success rounded-full transition-all"
style={{ width: `${Math.round((completedDeliverables / deliverableCount) * 100)}%` }}
/>
</div>
)}
</CardContent>
</Card>
<Card className="hover-lift">
<CardContent className="pt-4">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<FileText className="h-4 w-4" />
<span className="text-sm">Avtal</span>
</div>
<p className="font-medium">
{campaign.contract_signed_at
? formatDate(campaign.contract_signed_at)
: 'Ej signerat'}
</p>
{contractCount > 0 && (
<p className="text-sm text-muted-foreground">
{contractCount} dokument
</p>
)}
</CardContent>
</Card>
</div>
{/* Description */}
{campaign.description && (
<Card>
<CardContent className="pt-4">
<p className="text-muted-foreground whitespace-pre-wrap text-balance">
{campaign.description}
</p>
</CardContent>
</Card>
)}
{/* Tabs for different sections */}
<Tabs defaultValue="deliverables" className="space-y-4">
<TabsList>
<TabsTrigger value="deliverables" className="gap-2">
<Package className="h-4 w-4" />
Innehåll
{deliverableCount > 0 && (
<Badge variant="secondary" className="ml-1">{deliverableCount}</Badge>
)}
</TabsTrigger>
<TabsTrigger value="exclusivities" className="gap-2">
<Shield className="h-4 w-4" />
Exklusivitet
{exclusivityCount > 0 && (
<Badge variant="secondary" className="ml-1">{exclusivityCount}</Badge>
)}
</TabsTrigger>
<TabsTrigger value="briefings" className="gap-2">
<BookOpen className="h-4 w-4" />
Briefing
{briefingCount > 0 && (
<Badge variant="secondary" className="ml-1">{briefingCount}</Badge>
)}
</TabsTrigger>
<TabsTrigger value="contracts" className="gap-2">
<FileText className="h-4 w-4" />
Avtal
{contractCount > 0 && (
<Badge variant="secondary" className="ml-1">{contractCount}</Badge>
)}
</TabsTrigger>
<TabsTrigger value="invoices" className="gap-2">
<Receipt className="h-4 w-4" />
Fakturor
{invoiceCount > 0 && (
<Badge variant="secondary" className="ml-1">{invoiceCount}</Badge>
)}
</TabsTrigger>
</TabsList>
<TabsContent value="deliverables">
<Card>
<CardContent className="pt-6">
<DeliverableList
campaignId={campaign.id}
deliverables={campaign.deliverables || []}
onUpdate={onUpdate}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="exclusivities">
<Card>
<CardContent className="pt-6">
<ExclusivityList
campaignId={campaign.id}
exclusivities={campaign.exclusivities || []}
onUpdate={onUpdate}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="briefings">
<Card>
<CardContent className="pt-6">
<BriefingList
campaignId={campaign.id}
briefings={campaign.briefings || []}
onUpdate={onUpdate}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="contracts">
<Card>
<CardContent className="pt-6">
<ContractList
campaignId={campaign.id}
contracts={campaign.contracts || []}
onUpdate={onUpdate}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="invoices">
<Card>
<CardContent className="pt-6">
<CampaignInvoiceSummary
campaignId={campaign.id}
invoices={campaign.invoices || []}
totalValue={campaign.total_value}
currency={campaign.currency}
onCreateInvoice={() => {
router.push(`/invoices/new?campaign_id=${campaign.id}&customer_id=${campaign.customer_id}`)
}}
/>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Notes */}
{campaign.notes && (
<Card>
<CardHeader>
<CardTitle className="text-base">Anteckningar</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground whitespace-pre-wrap">
{campaign.notes}
</p>
</CardContent>
</Card>
)}
</div>
)
}
-392
View File
@@ -1,392 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { useToast } from '@/components/ui/use-toast'
import type {
Campaign,
Customer,
CampaignType,
BillingFrequency,
CreateCampaignInput
} from '@/types'
import {
CAMPAIGN_TYPE_LABELS,
BILLING_FREQUENCY_LABELS,
} from '@/types'
interface CampaignFormProps {
open: boolean
onOpenChange: (open: boolean) => void
initialData?: Partial<Campaign>
customers: Customer[]
onSuccess?: (campaign: Campaign) => void
}
const CURRENCIES = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']
export function CampaignForm({
open,
onOpenChange,
initialData,
customers,
onSuccess
}: CampaignFormProps) {
const router = useRouter()
const { toast } = useToast()
const [isLoading, setIsLoading] = useState(false)
const [formData, setFormData] = useState<CreateCampaignInput>({
name: '',
description: '',
customer_id: '',
brand_name: '',
campaign_type: 'influencer',
total_value: undefined,
currency: 'SEK',
vat_included: false,
payment_terms: 30,
billing_frequency: undefined,
publication_date: '',
draft_deadline: '',
notes: '',
})
useEffect(() => {
if (open) {
setFormData({
name: initialData?.name || '',
description: initialData?.description || '',
customer_id: initialData?.customer_id || '',
brand_name: initialData?.brand_name || '',
campaign_type: initialData?.campaign_type || 'influencer',
total_value: initialData?.total_value || undefined,
currency: initialData?.currency || 'SEK',
vat_included: initialData?.vat_included || false,
payment_terms: initialData?.payment_terms || 30,
billing_frequency: initialData?.billing_frequency || undefined,
publication_date: initialData?.publication_date || '',
draft_deadline: initialData?.draft_deadline || '',
notes: initialData?.notes || '',
})
}
}, [open, initialData])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!formData.name) {
toast({ title: 'Namn krävs', variant: 'destructive' })
return
}
setIsLoading(true)
try {
const url = initialData?.id
? `/api/campaigns/${initialData.id}`
: '/api/campaigns'
const method = initialData?.id ? 'PATCH' : 'POST'
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...formData,
customer_id: formData.customer_id || null,
brand_name: formData.brand_name || null,
total_value: formData.total_value || null,
payment_terms: formData.payment_terms || null,
billing_frequency: formData.billing_frequency || null,
publication_date: formData.publication_date || null,
draft_deadline: formData.draft_deadline || null,
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to save campaign')
}
const { data } = await response.json()
toast({
title: initialData?.id ? 'Samarbete uppdaterat' : 'Samarbete skapat',
description: formData.name,
})
onOpenChange(false)
if (onSuccess) {
onSuccess(data)
} else if (!initialData?.id) {
router.push(`/campaigns/${data.id}`)
}
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Något gick fel',
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{initialData?.id ? 'Redigera samarbete' : 'Nytt samarbete'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Info */}
<div className="space-y-4">
<div>
<Label htmlFor="name">Namn samarbete *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="T.ex. Sommarkampanj 2025"
required
/>
</div>
<div>
<Label htmlFor="description">Beskrivning</Label>
<Textarea
id="description"
value={formData.description || ''}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Kort beskrivning av kampanjen..."
rows={2}
/>
</div>
<div>
<Label htmlFor="brand_name">Varumärke</Label>
<Input
id="brand_name"
value={formData.brand_name || ''}
onChange={(e) => setFormData({ ...formData, brand_name: e.target.value })}
placeholder="Varumärket du skapar innehåll för"
/>
<p className="text-xs text-muted-foreground mt-1">
Varumärket som samarbetet gäller
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="campaign_type">Typ</Label>
<Select
value={formData.campaign_type}
onValueChange={(v) => setFormData({ ...formData, campaign_type: v as CampaignType })}
>
<SelectTrigger>
<SelectValue placeholder="Välj typ" />
</SelectTrigger>
<SelectContent>
{Object.entries(CAMPAIGN_TYPE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="customer_id">Byrå / Uppdragsgivare</Label>
<Select
value={formData.customer_id || ''}
onValueChange={(v) => setFormData({
...formData,
customer_id: v,
})}
>
<SelectTrigger>
<SelectValue placeholder="Välj byrå/uppdragsgivare" />
</SelectTrigger>
<SelectContent>
{customers.map((customer) => (
<SelectItem key={customer.id} value={customer.id}>
{customer.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1">
Den som faktureras (byrå eller direktkund)
</p>
</div>
</div>
</div>
{/* Financial */}
<div className="space-y-4 pt-4 border-t">
<h4 className="font-medium">Ekonomi</h4>
<div className="grid grid-cols-3 gap-4">
<div>
<Label htmlFor="total_value">Arvode</Label>
<Input
id="total_value"
type="number"
value={formData.total_value || ''}
onChange={(e) => setFormData({
...formData,
total_value: e.target.value ? parseFloat(e.target.value) : undefined
})}
placeholder="0"
/>
</div>
<div>
<Label htmlFor="currency">Valuta</Label>
<Select
value={formData.currency}
onValueChange={(v) => setFormData({ ...formData, currency: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CURRENCIES.map((c) => (
<SelectItem key={c} value={c}>{c}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end gap-2">
<div className="flex items-center gap-2">
<Switch
id="vat_included"
checked={formData.vat_included}
onCheckedChange={(v) => setFormData({ ...formData, vat_included: v })}
/>
<Label htmlFor="vat_included" className="font-normal">Inkl. moms</Label>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="payment_terms">Betalningsvillkor (dagar)</Label>
<Input
id="payment_terms"
type="number"
value={formData.payment_terms || ''}
onChange={(e) => setFormData({
...formData,
payment_terms: e.target.value ? parseInt(e.target.value) : undefined
})}
placeholder="30"
/>
</div>
<div>
<Label htmlFor="billing_frequency">Faktureringsmodell</Label>
<Select
value={formData.billing_frequency || ''}
onValueChange={(v) => setFormData({
...formData,
billing_frequency: v as BillingFrequency || undefined
})}
>
<SelectTrigger>
<SelectValue placeholder="Välj..." />
</SelectTrigger>
<SelectContent>
{Object.entries(BILLING_FREQUENCY_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Dates */}
<div className="space-y-4 pt-4 border-t">
<h4 className="font-medium">Datum</h4>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="publication_date">Publiceringsdatum</Label>
<Input
id="publication_date"
type="date"
value={formData.publication_date || ''}
onChange={(e) => setFormData({ ...formData, publication_date: e.target.value })}
/>
<p className="text-xs text-muted-foreground mt-1">
När innehållet ska publiceras
</p>
</div>
<div>
<Label htmlFor="draft_deadline">Utkastdeadline</Label>
<Input
id="draft_deadline"
type="date"
value={formData.draft_deadline || ''}
onChange={(e) => setFormData({ ...formData, draft_deadline: e.target.value })}
/>
<p className="text-xs text-muted-foreground mt-1">
När utkast ska skickas (valfritt)
</p>
</div>
</div>
</div>
{/* Notes */}
<div className="pt-4 border-t">
<Label htmlFor="notes">Anteckningar</Label>
<Textarea
id="notes"
value={formData.notes || ''}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
placeholder="Interna anteckningar..."
rows={3}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Avbryt
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Sparar...' : initialData?.id ? 'Spara' : 'Skapa samarbete'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -1,195 +0,0 @@
'use client'
import { Invoice, PAYMENT_STATUS_LABELS } from '@/types'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import {
FileText,
Plus,
ExternalLink,
CheckCircle,
Clock,
AlertTriangle
} from 'lucide-react'
import Link from 'next/link'
import { cn } from '@/lib/utils'
interface CampaignInvoiceSummaryProps {
campaignId: string
invoices: Invoice[]
totalValue: number | null
currency: string
onCreateInvoice?: () => void
}
export function CampaignInvoiceSummary({
campaignId,
invoices,
totalValue,
currency,
onCreateInvoice
}: CampaignInvoiceSummaryProps) {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency: currency || 'SEK',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount)
}
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('sv-SE', {
day: 'numeric',
month: 'short',
})
}
// Calculate totals
const totalInvoiced = invoices.reduce((sum, inv) => sum + inv.total, 0)
const totalPaid = invoices
.filter(inv => inv.status === 'paid')
.reduce((sum, inv) => sum + inv.total, 0)
const invoicedPercentage = totalValue ? (totalInvoiced / totalValue) * 100 : 0
const paidPercentage = totalValue ? (totalPaid / totalValue) * 100 : 0
// Group by status
const paidInvoices = invoices.filter(inv => inv.status === 'paid')
const sentInvoices = invoices.filter(inv => inv.status === 'sent')
const overdueInvoices = invoices.filter(inv => inv.status === 'overdue')
const draftInvoices = invoices.filter(inv => inv.status === 'draft')
const getStatusIcon = (status: string) => {
switch (status) {
case 'paid':
return <CheckCircle className="h-4 w-4 text-success" />
case 'overdue':
return <AlertTriangle className="h-4 w-4 text-destructive" />
default:
return <Clock className="h-4 w-4 text-muted-foreground" />
}
}
const getStatusBadge = (status: string) => {
switch (status) {
case 'paid':
return <Badge variant="outline" className="bg-green-100 text-green-700 border-green-200">Betald</Badge>
case 'sent':
return <Badge variant="outline" className="bg-blue-100 text-blue-700 border-blue-200">Skickad</Badge>
case 'overdue':
return <Badge variant="destructive">Förfallen</Badge>
case 'draft':
return <Badge variant="secondary">Utkast</Badge>
default:
return <Badge variant="outline">{status}</Badge>
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium">
Fakturor
<span className="text-muted-foreground ml-2">
({invoices.length})
</span>
</h3>
{onCreateInvoice && (
<Button size="sm" onClick={onCreateInvoice}>
<Plus className="h-4 w-4 mr-1" />
Skapa faktura
</Button>
)}
</div>
{/* Progress summary */}
{totalValue && totalValue > 0 && (
<Card>
<CardContent className="pt-4">
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Fakturerat</span>
<span className="font-medium">
{formatCurrency(totalInvoiced)} / {formatCurrency(totalValue)}
</span>
</div>
<Progress value={invoicedPercentage} className="h-2" />
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Betalt</span>
<span className="font-medium text-success">
{formatCurrency(totalPaid)}
</span>
</div>
<Progress value={paidPercentage} className="h-2 bg-muted [&>div]:bg-success" />
{totalValue > totalInvoiced && (
<p className="text-sm text-muted-foreground">
Kvar att fakturera: {formatCurrency(totalValue - totalInvoiced)}
</p>
)}
</div>
</CardContent>
</Card>
)}
{/* Invoice list */}
{invoices.length > 0 ? (
<div className="space-y-2">
{invoices
.sort((a, b) => new Date(b.invoice_date).getTime() - new Date(a.invoice_date).getTime())
.map(invoice => (
<Link key={invoice.id} href={`/invoices/${invoice.id}`}>
<div className={cn(
'flex items-center gap-3 p-3 border rounded-lg hover:border-primary/50 transition-colors',
invoice.status === 'overdue' && 'border-destructive/50 bg-destructive/5'
)}>
{getStatusIcon(invoice.status)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium">{invoice.invoice_number}</span>
{getStatusBadge(invoice.status)}
</div>
<p className="text-sm text-muted-foreground">
{formatDate(invoice.invoice_date)}
{invoice.due_date && ` • Förfaller: ${formatDate(invoice.due_date)}`}
</p>
</div>
<div className="text-right">
<p className="font-medium">{formatCurrency(invoice.total)}</p>
{invoice.payment_status && (
<p className="text-xs text-muted-foreground">
{PAYMENT_STATUS_LABELS[invoice.payment_status]}
</p>
)}
</div>
<ExternalLink className="h-4 w-4 text-muted-foreground" />
</div>
</Link>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<FileText className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>Inga fakturor ännu</p>
{onCreateInvoice && (
<Button
variant="outline"
size="sm"
className="mt-2"
onClick={onCreateInvoice}
>
<Plus className="h-4 w-4 mr-1" />
Skapa första fakturan
</Button>
)}
</div>
)}
</div>
)
}
-201
View File
@@ -1,201 +0,0 @@
'use client'
import { useState } from 'react'
import { Campaign, CampaignStatus, CampaignType, CAMPAIGN_STATUS_LABELS, CAMPAIGN_TYPE_LABELS } from '@/types'
import { CampaignCard } from './CampaignCard'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Search, X } from 'lucide-react'
import { EmptyCampaigns } from '@/components/ui/empty-state'
interface CampaignListProps {
campaigns: Campaign[]
loading?: boolean
}
const STATUS_OPTIONS: { value: CampaignStatus | 'all'; label: string }[] = [
{ value: 'all', label: 'Alla statusar' },
{ value: 'negotiation', label: CAMPAIGN_STATUS_LABELS.negotiation },
{ value: 'contracted', label: CAMPAIGN_STATUS_LABELS.contracted },
{ value: 'active', label: CAMPAIGN_STATUS_LABELS.active },
{ value: 'delivered', label: CAMPAIGN_STATUS_LABELS.delivered },
{ value: 'invoiced', label: CAMPAIGN_STATUS_LABELS.invoiced },
{ value: 'completed', label: CAMPAIGN_STATUS_LABELS.completed },
{ value: 'cancelled', label: CAMPAIGN_STATUS_LABELS.cancelled },
]
const TYPE_OPTIONS: { value: CampaignType | 'all'; label: string }[] = [
{ value: 'all', label: 'Alla typer' },
{ value: 'influencer', label: CAMPAIGN_TYPE_LABELS.influencer },
{ value: 'ugc', label: CAMPAIGN_TYPE_LABELS.ugc },
{ value: 'ambassador', label: CAMPAIGN_TYPE_LABELS.ambassador },
]
export function CampaignList({ campaigns, loading }: CampaignListProps) {
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<CampaignStatus | 'all'>('all')
const [typeFilter, setTypeFilter] = useState<CampaignType | 'all'>('all')
// Filter campaigns
const filteredCampaigns = campaigns.filter(campaign => {
// Search filter
if (search) {
const searchLower = search.toLowerCase()
const matchesName = campaign.name.toLowerCase().includes(searchLower)
const matchesCustomer = campaign.customer?.name.toLowerCase().includes(searchLower)
const matchesDescription = campaign.description?.toLowerCase().includes(searchLower)
if (!matchesName && !matchesCustomer && !matchesDescription) {
return false
}
}
// Status filter
if (statusFilter !== 'all' && campaign.status !== statusFilter) {
return false
}
// Type filter
if (typeFilter !== 'all' && campaign.campaign_type !== typeFilter) {
return false
}
return true
})
// Group by status for active view
const activeStatuses: CampaignStatus[] = ['active', 'contracted', 'negotiation', 'delivered', 'invoiced']
const activeCampaigns = filteredCampaigns.filter(c => activeStatuses.includes(c.status))
const completedCampaigns = filteredCampaigns.filter(c => c.status === 'completed')
const cancelledCampaigns = filteredCampaigns.filter(c => c.status === 'cancelled')
const hasFilters = search || statusFilter !== 'all' || typeFilter !== 'all'
const clearFilters = () => {
setSearch('')
setStatusFilter('all')
setTypeFilter('all')
}
if (loading) {
return (
<div className="space-y-4">
{[1, 2, 3].map(i => (
<div key={i} className="h-40 bg-muted animate-pulse rounded-lg" />
))}
</div>
)
}
return (
<div className="space-y-6">
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Sök samarbeten..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as CampaignStatus | 'all')}>
<SelectTrigger className="w-full sm:w-[160px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={typeFilter} onValueChange={(v) => setTypeFilter(v as CampaignType | 'all')}>
<SelectTrigger className="w-full sm:w-[160px]">
<SelectValue placeholder="Typ" />
</SelectTrigger>
<SelectContent>
{TYPE_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{hasFilters && (
<Button variant="ghost" size="icon" onClick={clearFilters}>
<X className="h-4 w-4" />
</Button>
)}
</div>
{/* Results */}
{filteredCampaigns.length === 0 ? (
hasFilters ? (
<div className="text-center py-12">
<p className="text-muted-foreground">
Inga samarbeten matchar filtren
</p>
<Button variant="outline" className="mt-4" onClick={clearFilters}>
Rensa filter
</Button>
</div>
) : (
<EmptyCampaigns />
)
) : (
<div className="space-y-8">
{/* Active campaigns */}
{activeCampaigns.length > 0 && (
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-3">
Aktiva ({activeCampaigns.length})
</h3>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{activeCampaigns.map(campaign => (
<CampaignCard key={campaign.id} campaign={campaign} />
))}
</div>
</div>
)}
{/* Completed campaigns */}
{completedCampaigns.length > 0 && (
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-3">
Avslutade ({completedCampaigns.length})
</h3>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{completedCampaigns.map(campaign => (
<CampaignCard key={campaign.id} campaign={campaign} />
))}
</div>
</div>
)}
{/* Cancelled campaigns */}
{cancelledCampaigns.length > 0 && (
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-3">
Avbrutna ({cancelledCampaigns.length})
</h3>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{cancelledCampaigns.map(campaign => (
<CampaignCard key={campaign.id} campaign={campaign} />
))}
</div>
</div>
)}
</div>
)}
</div>
)
}
@@ -1,37 +0,0 @@
'use client'
import { Badge } from '@/components/ui/badge'
import type { CampaignStatus } from '@/types'
import { CAMPAIGN_STATUS_LABELS } from '@/types'
import { cn } from '@/lib/utils'
const STATUS_STYLES: Record<CampaignStatus, { className: string; variant: 'default' | 'secondary' | 'outline' | 'destructive' }> = {
negotiation: { className: 'bg-blue-100 text-blue-700 border-blue-200', variant: 'outline' },
contracted: { className: 'bg-purple-100 text-purple-700 border-purple-200', variant: 'outline' },
active: { className: 'bg-green-100 text-green-700 border-green-200', variant: 'outline' },
delivered: { className: 'bg-yellow-100 text-yellow-700 border-yellow-200', variant: 'outline' },
invoiced: { className: 'bg-orange-100 text-orange-700 border-orange-200', variant: 'outline' },
completed: { className: 'bg-emerald-100 text-emerald-700 border-emerald-200', variant: 'outline' },
cancelled: { className: 'bg-gray-100 text-gray-500 border-gray-200', variant: 'outline' },
}
interface CampaignStatusBadgeProps {
status: CampaignStatus
size?: 'sm' | 'default'
}
export function CampaignStatusBadge({ status, size = 'default' }: CampaignStatusBadgeProps) {
const style = STATUS_STYLES[status]
return (
<Badge
variant={style.variant}
className={cn(
style.className,
size === 'sm' && 'text-xs px-1.5 py-0'
)}
>
{CAMPAIGN_STATUS_LABELS[status]}
</Badge>
)
}
-159
View File
@@ -1,159 +0,0 @@
'use client'
import Link from 'next/link'
import { Campaign, Deliverable, CAMPAIGN_STATUS_LABELS } from '@/types'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { CampaignStatusBadge } from './CampaignStatusBadge'
import {
Megaphone,
ArrowRight,
Clock,
Package,
AlertTriangle,
Plus
} from 'lucide-react'
import { cn } from '@/lib/utils'
interface CampaignsWidgetProps {
campaigns: Campaign[]
className?: string
}
export function CampaignsWidget({ campaigns, className }: CampaignsWidgetProps) {
const today = new Date().toISOString().split('T')[0]
// Get active campaigns
const activeCampaigns = campaigns.filter(c =>
['active', 'contracted', 'negotiation'].includes(c.status)
)
// Find upcoming deliverables (next 7 days)
const next7Days = new Date()
next7Days.setDate(next7Days.getDate() + 7)
const next7DaysStr = next7Days.toISOString().split('T')[0]
const upcomingDeliverables: (Deliverable & { campaignName: string })[] = []
for (const campaign of activeCampaigns) {
for (const deliverable of campaign.deliverables || []) {
if (
deliverable.due_date &&
deliverable.due_date >= today &&
deliverable.due_date <= next7DaysStr &&
!['approved', 'published'].includes(deliverable.status)
) {
upcomingDeliverables.push({
...deliverable,
campaignName: campaign.name
})
}
}
}
// Sort by due date
upcomingDeliverables.sort((a, b) =>
new Date(a.due_date!).getTime() - new Date(b.due_date!).getTime()
)
// Find overdue deliverables
const overdueCount = activeCampaigns.reduce((count, campaign) => {
return count + (campaign.deliverables?.filter(d =>
d.due_date &&
d.due_date < today &&
!['approved', 'published'].includes(d.status)
).length || 0)
}, 0)
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('sv-SE', {
weekday: 'short',
day: 'numeric',
month: 'short',
})
}
if (activeCampaigns.length === 0) {
return null
}
return (
<Card className={className}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<Megaphone className="h-4 w-4" />
Samarbeten
</CardTitle>
<Link href="/campaigns">
<Button variant="ghost" size="sm" className="text-xs">
Visa alla
<ArrowRight className="h-3 w-3 ml-1" />
</Button>
</Link>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Active campaigns summary */}
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Aktiva samarbeten</span>
<span className="font-medium">{activeCampaigns.length}</span>
</div>
{/* Overdue warning */}
{overdueCount > 0 && (
<div className="flex items-center gap-2 p-2 bg-destructive/10 rounded-lg">
<AlertTriangle className="h-4 w-4 text-destructive" />
<span className="text-sm text-destructive">
{overdueCount} försenat innehåll
</span>
</div>
)}
{/* Upcoming deliverables */}
{upcomingDeliverables.length > 0 && (
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-2">
Kommande denna vecka
</p>
<div className="space-y-2">
{upcomingDeliverables.slice(0, 3).map(deliverable => (
<div
key={deliverable.id}
className="flex items-center gap-2 p-2 rounded-lg border bg-card"
>
<Package className="h-4 w-4 text-muted-foreground" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{deliverable.title}
</p>
<p className="text-xs text-muted-foreground">
{deliverable.campaignName}
</p>
</div>
<div className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatDate(deliverable.due_date!)}
</div>
</div>
))}
{upcomingDeliverables.length > 3 && (
<p className="text-xs text-muted-foreground text-center">
+{upcomingDeliverables.length - 3} till
</p>
)}
</div>
</div>
)}
{/* Quick add button */}
<Link href="/campaigns/new" className="block">
<Button variant="outline" size="sm" className="w-full">
<Plus className="h-4 w-4 mr-1" />
Nytt samarbete
</Button>
</Link>
</CardContent>
</Card>
)
}
-326
View File
@@ -1,326 +0,0 @@
'use client'
import { useState } from 'react'
import { Contract, ExtractionStatus } from '@/types'
import { ContractUpload } from './ContractUpload'
import { ExtractionStatusBadge } from '@/components/contracts/ExtractionStatusBadge'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import {
FileText,
Download,
Trash2,
Star,
Calendar,
Plus,
ChevronDown,
ChevronUp,
Sparkles,
Loader2,
} from 'lucide-react'
import { cn } from '@/lib/utils'
interface ContractListProps {
campaignId: string
contracts: Contract[]
onUpdate: () => void
}
export function ContractList({ campaignId, contracts, onUpdate }: ContractListProps) {
const { toast } = useToast()
const [showUpload, setShowUpload] = useState(false)
const [downloadingId, setDownloadingId] = useState<string | null>(null)
const [extractingId, setExtractingId] = useState<string | null>(null)
const handleDownload = async (contract: Contract) => {
setDownloadingId(contract.id)
try {
const response = await fetch(`/api/contracts/${contract.id}/download`)
if (!response.ok) {
throw new Error('Download failed')
}
const data = await response.json()
// Open download in new tab
window.open(data.url, '_blank')
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte ladda ner filen',
variant: 'destructive',
})
} finally {
setDownloadingId(null)
}
}
const handleDelete = async (contract: Contract) => {
if (!confirm(`Ta bort "${contract.filename}"?`)) return
try {
const response = await fetch(`/api/contracts/${contract.id}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Delete failed')
}
toast({
title: 'Avtal borttaget',
description: contract.filename,
})
onUpdate()
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte ta bort avtalet',
variant: 'destructive',
})
}
}
const handleSetPrimary = async (contract: Contract) => {
try {
const response = await fetch(`/api/contracts/${contract.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_primary: true }),
})
if (!response.ok) {
throw new Error('Update failed')
}
toast({
title: 'Huvudavtal uppdaterat',
})
onUpdate()
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte uppdatera',
variant: 'destructive',
})
}
}
const handleExtract = async (contract: Contract) => {
if (contract.mime_type !== 'application/pdf') {
toast({
title: 'Fel',
description: 'Endast PDF-filer kan analyseras',
variant: 'destructive',
})
return
}
setExtractingId(contract.id)
try {
const response = await fetch(`/api/contracts/${contract.id}/extract`, {
method: 'POST',
})
const result = await response.json()
if (!response.ok) {
throw new Error(result.error || 'Extraction failed')
}
toast({
title: 'Analys klar',
description: 'Avtalsinformationen har extraherats',
})
onUpdate()
} catch (error) {
toast({
title: 'Fel vid analys',
description: error instanceof Error ? error.message : 'Kunde inte analysera avtalet',
variant: 'destructive',
})
} finally {
setExtractingId(null)
}
}
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('sv-SE', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
const formatFileSize = (bytes: number | null) => {
if (!bytes) return ''
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
const getFileIcon = (mimeType: string | null) => {
if (mimeType?.startsWith('image/')) return '🖼️'
if (mimeType === 'application/pdf') return '📄'
if (mimeType?.includes('word')) return '📝'
return '📎'
}
// Sort: primary first, then by upload date
const sortedContracts = [...contracts].sort((a, b) => {
if (a.is_primary && !b.is_primary) return -1
if (!a.is_primary && b.is_primary) return 1
return new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime()
})
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium">
Avtal
<span className="text-muted-foreground ml-2">
({contracts.length})
</span>
</h3>
<Button
size="sm"
variant={showUpload ? 'secondary' : 'default'}
onClick={() => setShowUpload(!showUpload)}
>
{showUpload ? (
<>
<ChevronUp className="h-4 w-4 mr-1" />
Stäng
</>
) : (
<>
<Plus className="h-4 w-4 mr-1" />
Ladda upp
</>
)}
</Button>
</div>
{/* Upload section */}
{showUpload && (
<div className="p-4 border rounded-lg bg-muted/30">
<ContractUpload
campaignId={campaignId}
onSuccess={() => {
setShowUpload(false)
onUpdate()
}}
/>
</div>
)}
{/* Contract list */}
{sortedContracts.length > 0 ? (
<div className="space-y-2">
{sortedContracts.map(contract => (
<div
key={contract.id}
className={cn(
'flex items-center gap-3 p-3 border rounded-lg',
contract.is_primary && 'border-primary/50 bg-primary/5'
)}
>
<div className="text-2xl">
{getFileIcon(contract.mime_type)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium truncate">{contract.filename}</p>
{contract.is_primary && (
<Badge variant="default" className="text-xs">
<Star className="h-3 w-3 mr-1" />
Huvudavtal
</Badge>
)}
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span>{formatFileSize(contract.file_size)}</span>
{contract.signing_date && (
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
Signerat: {formatDate(contract.signing_date)}
</span>
)}
<span>Uppladdat: {formatDate(contract.uploaded_at)}</span>
{contract.extraction_status && contract.extraction_status !== 'pending' && (
<ExtractionStatusBadge status={contract.extraction_status as ExtractionStatus} />
)}
</div>
</div>
<div className="flex gap-1">
{contract.mime_type === 'application/pdf' &&
contract.extraction_status !== 'completed' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleExtract(contract)}
disabled={extractingId === contract.id}
title="Analysera med AI"
>
{extractingId === contract.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
</Button>
)}
{!contract.is_primary && (
<Button
variant="ghost"
size="sm"
onClick={() => handleSetPrimary(contract)}
title="Sätt som huvudavtal"
>
<Star className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => handleDownload(contract)}
disabled={downloadingId === contract.id}
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(contract)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
) : !showUpload ? (
<div className="text-center py-8 text-muted-foreground">
<FileText className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>Inga avtal uppladdade</p>
<Button
variant="outline"
size="sm"
className="mt-2"
onClick={() => setShowUpload(true)}
>
<Plus className="h-4 w-4 mr-1" />
Ladda upp avtal
</Button>
</div>
) : null}
</div>
)
}
-214
View File
@@ -1,214 +0,0 @@
'use client'
import { useState, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Progress } from '@/components/ui/progress'
import { useToast } from '@/components/ui/use-toast'
import { Upload, FileText, X } from 'lucide-react'
interface ContractUploadProps {
campaignId: string
onSuccess?: () => void
}
export function ContractUpload({ campaignId, onSuccess }: ContractUploadProps) {
const { toast } = useToast()
const fileInputRef = useRef<HTMLInputElement>(null)
const [isUploading, setIsUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [signingDate, setSigningDate] = useState('')
const [isPrimary, setIsPrimary] = useState(true)
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
// Validate file type
const allowedTypes = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'image/jpeg',
'image/png',
'image/webp'
]
if (!allowedTypes.includes(file.type)) {
toast({
title: 'Ogiltig filtyp',
description: 'Tillåtna format: PDF, DOC, DOCX, JPG, PNG, WEBP',
variant: 'destructive',
})
return
}
// Check file size (10MB max)
if (file.size > 10 * 1024 * 1024) {
toast({
title: 'Filen är för stor',
description: 'Max filstorlek är 10MB',
variant: 'destructive',
})
return
}
setSelectedFile(file)
}
}
const handleUpload = async () => {
if (!selectedFile) return
setIsUploading(true)
setUploadProgress(0)
try {
const formData = new FormData()
formData.append('file', selectedFile)
if (signingDate) formData.append('signing_date', signingDate)
formData.append('is_primary', isPrimary.toString())
// Simulate progress for better UX
const progressInterval = setInterval(() => {
setUploadProgress(prev => Math.min(prev + 10, 90))
}, 200)
const response = await fetch(`/api/campaigns/${campaignId}/contracts`, {
method: 'POST',
body: formData,
})
clearInterval(progressInterval)
setUploadProgress(100)
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Upload failed')
}
toast({
title: 'Avtal uppladdat',
description: selectedFile.name,
})
// Reset form
setSelectedFile(null)
setSigningDate('')
setIsPrimary(true)
if (fileInputRef.current) fileInputRef.current.value = ''
onSuccess?.()
} catch (error) {
toast({
title: 'Uppladdning misslyckades',
description: error instanceof Error ? error.message : 'Något gick fel',
variant: 'destructive',
})
} finally {
setIsUploading(false)
setUploadProgress(0)
}
}
const clearSelection = () => {
setSelectedFile(null)
if (fileInputRef.current) fileInputRef.current.value = ''
}
return (
<div className="space-y-4">
{/* File input */}
<div>
<input
ref={fileInputRef}
type="file"
accept=".pdf,.doc,.docx,.jpg,.jpeg,.png,.webp"
onChange={handleFileSelect}
className="hidden"
/>
{selectedFile ? (
<div className="flex items-center gap-3 p-3 border rounded-lg bg-muted/50">
<FileText className="h-8 w-8 text-primary" />
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{selectedFile.name}</p>
<p className="text-sm text-muted-foreground">
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={clearSelection}
disabled={isUploading}
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<button
onClick={() => fileInputRef.current?.click()}
className="w-full p-8 border-2 border-dashed rounded-lg hover:border-primary hover:bg-muted/50 transition-colors"
>
<div className="flex flex-col items-center gap-2">
<Upload className="h-8 w-8 text-muted-foreground" />
<p className="font-medium">Klicka för att välja fil</p>
<p className="text-sm text-muted-foreground">
PDF, DOC, DOCX, JPG, PNG (max 10MB)
</p>
</div>
</button>
)}
</div>
{/* Options */}
{selectedFile && (
<>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="signing_date">Signeringsdatum</Label>
<Input
id="signing_date"
type="date"
value={signingDate}
onChange={(e) => setSigningDate(e.target.value)}
/>
</div>
<div className="flex items-end gap-2 pb-2">
<Switch
id="is_primary"
checked={isPrimary}
onCheckedChange={setIsPrimary}
/>
<Label htmlFor="is_primary" className="font-normal">
Huvudavtal
</Label>
</div>
</div>
{/* Upload progress */}
{isUploading && (
<div className="space-y-2">
<Progress value={uploadProgress} />
<p className="text-sm text-muted-foreground text-center">
Laddar upp... {uploadProgress}%
</p>
</div>
)}
{/* Upload button */}
<Button
onClick={handleUpload}
disabled={isUploading}
className="w-full"
>
{isUploading ? 'Laddar upp...' : 'Ladda upp avtal'}
</Button>
</>
)}
</div>
)
}
-247
View File
@@ -1,247 +0,0 @@
'use client'
import { Deliverable, DELIVERABLE_STATUS_LABELS, DELIVERABLE_TYPE_LABELS, PLATFORM_LABELS } from '@/types'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import {
CheckCircle,
Circle,
Clock,
Edit2,
Trash2,
ExternalLink,
RotateCcw,
Send
} from 'lucide-react'
const STATUS_STYLES: Record<string, string> = {
pending: 'bg-gray-100 text-gray-700 border-gray-200',
in_progress: 'bg-blue-100 text-blue-700 border-blue-200',
submitted: 'bg-purple-100 text-purple-700 border-purple-200',
revision: 'bg-orange-100 text-orange-700 border-orange-200',
approved: 'bg-green-100 text-green-700 border-green-200',
published: 'bg-emerald-100 text-emerald-700 border-emerald-200',
}
interface DeliverableCardProps {
deliverable: Deliverable
onStatusChange?: (deliverable: Deliverable, newStatus: string) => void
onEdit?: (deliverable: Deliverable) => void
onDelete?: (deliverable: Deliverable) => void
compact?: boolean
}
export function DeliverableCard({
deliverable,
onStatusChange,
onEdit,
onDelete,
compact = false
}: DeliverableCardProps) {
const isCompleted = deliverable.status === 'approved' || deliverable.status === 'published'
const isOverdue = deliverable.due_date && new Date(deliverable.due_date) < new Date() && !isCompleted
const formatDate = (date: string | null) => {
if (!date) return null
return new Date(date).toLocaleDateString('sv-SE', {
day: 'numeric',
month: 'short',
})
}
// Next logical status based on current
const getNextStatus = () => {
switch (deliverable.status) {
case 'pending': return 'in_progress'
case 'in_progress': return 'submitted'
case 'submitted': return 'approved'
case 'revision': return 'submitted'
case 'approved': return 'published'
default: return null
}
}
const nextStatus = getNextStatus()
if (compact) {
return (
<div
className={cn(
'flex items-center gap-2 p-2 rounded-lg border',
isOverdue ? 'border-destructive/50 bg-destructive/5' : 'border-border'
)}
>
<button
onClick={() => nextStatus && onStatusChange?.(deliverable, nextStatus)}
className="flex-shrink-0"
disabled={!onStatusChange || !nextStatus}
>
{isCompleted ? (
<CheckCircle className="h-4 w-4 text-success" />
) : (
<Circle className="h-4 w-4 text-muted-foreground" />
)}
</button>
<div className="flex-1 min-w-0">
<p className={cn(
'text-sm font-medium truncate',
isCompleted && 'line-through text-muted-foreground'
)}>
{deliverable.title}
</p>
</div>
<Badge variant="outline" className="text-xs">
{PLATFORM_LABELS[deliverable.platform]}
</Badge>
</div>
)
}
return (
<div
className={cn(
'p-4 rounded-lg border',
isOverdue ? 'border-destructive/50 bg-destructive/5' : 'border-border'
)}
>
<div className="flex items-start gap-3">
<button
onClick={() => nextStatus && onStatusChange?.(deliverable, nextStatus)}
className="mt-0.5 flex-shrink-0"
disabled={!onStatusChange || !nextStatus}
>
{isCompleted ? (
<CheckCircle className="h-5 w-5 text-success" />
) : isOverdue ? (
<Clock className="h-5 w-5 text-destructive" />
) : (
<Circle className="h-5 w-5 text-muted-foreground" />
)}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div>
<p className={cn(
'font-medium',
isCompleted && 'line-through text-muted-foreground'
)}>
{deliverable.title}
{deliverable.quantity > 1 && (
<span className="text-muted-foreground ml-1">×{deliverable.quantity}</span>
)}
</p>
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground">
<span>{PLATFORM_LABELS[deliverable.platform]}</span>
<span>·</span>
<span>{DELIVERABLE_TYPE_LABELS[deliverable.deliverable_type]}</span>
{deliverable.account_handle && (
<>
<span>·</span>
<span>{deliverable.account_handle}</span>
</>
)}
</div>
{deliverable.due_date && (
<p className={cn(
'text-sm mt-1',
isOverdue && !isCompleted ? 'text-destructive' : 'text-muted-foreground'
)}>
Deadline: {formatDate(deliverable.due_date)}
</p>
)}
</div>
<div className="flex flex-col items-end gap-1">
<Badge
variant="outline"
className={cn('text-xs', STATUS_STYLES[deliverable.status])}
>
{DELIVERABLE_STATUS_LABELS[deliverable.status]}
</Badge>
{isOverdue && !isCompleted && (
<Badge variant="destructive" className="text-xs">
Försenad
</Badge>
)}
</div>
</div>
{deliverable.description && (
<p className="text-sm text-muted-foreground mt-2">
{deliverable.description}
</p>
)}
{/* Timestamps */}
{(deliverable.submitted_at || deliverable.approved_at || deliverable.published_at) && (
<div className="flex flex-wrap gap-3 mt-2 text-xs text-muted-foreground">
{deliverable.submitted_at && (
<span>Inskickat: {formatDate(deliverable.submitted_at)}</span>
)}
{deliverable.approved_at && (
<span>Godkänt: {formatDate(deliverable.approved_at)}</span>
)}
{deliverable.published_at && (
<span>Publicerat: {formatDate(deliverable.published_at)}</span>
)}
</div>
)}
</div>
</div>
{/* Actions */}
{(onStatusChange || onEdit || onDelete) && (
<div className="flex justify-between items-center gap-1 mt-3 pt-3 border-t">
{/* Status progression buttons */}
<div className="flex gap-1">
{onStatusChange && nextStatus && (
<Button
variant="outline"
size="sm"
onClick={() => onStatusChange(deliverable, nextStatus)}
className="text-xs"
>
{nextStatus === 'in_progress' && <Clock className="h-3 w-3 mr-1" />}
{nextStatus === 'submitted' && <Send className="h-3 w-3 mr-1" />}
{nextStatus === 'approved' && <CheckCircle className="h-3 w-3 mr-1" />}
{nextStatus === 'published' && <ExternalLink className="h-3 w-3 mr-1" />}
{DELIVERABLE_STATUS_LABELS[nextStatus as keyof typeof DELIVERABLE_STATUS_LABELS]}
</Button>
)}
{onStatusChange && deliverable.status === 'submitted' && (
<Button
variant="outline"
size="sm"
onClick={() => onStatusChange(deliverable, 'revision')}
className="text-xs text-orange-600"
>
<RotateCcw className="h-3 w-3 mr-1" />
Revision
</Button>
)}
</div>
<div className="flex gap-1">
{onEdit && (
<Button variant="ghost" size="sm" onClick={() => onEdit(deliverable)}>
<Edit2 className="h-4 w-4" />
</Button>
)}
{onDelete && (
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(deliverable)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
)}
</div>
)
}
-251
View File
@@ -1,251 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
import type { Deliverable, DeliverableType, PlatformType, CreateDeliverableInput } from '@/types'
import { DELIVERABLE_TYPE_LABELS, PLATFORM_LABELS } from '@/types'
interface DeliverableFormProps {
open: boolean
onOpenChange: (open: boolean) => void
campaignId: string
initialData?: Partial<Deliverable>
onSuccess?: (deliverable: Deliverable) => void
}
export function DeliverableForm({
open,
onOpenChange,
campaignId,
initialData,
onSuccess
}: DeliverableFormProps) {
const { toast } = useToast()
const [isLoading, setIsLoading] = useState(false)
const [formData, setFormData] = useState<Omit<CreateDeliverableInput, 'campaign_id'>>({
title: '',
deliverable_type: 'video',
platform: 'instagram',
account_handle: '',
quantity: 1,
description: '',
due_date: '',
notes: '',
})
useEffect(() => {
if (open) {
setFormData({
title: initialData?.title || '',
deliverable_type: initialData?.deliverable_type || 'video',
platform: initialData?.platform || 'instagram',
account_handle: initialData?.account_handle || '',
quantity: initialData?.quantity || 1,
description: initialData?.description || '',
due_date: initialData?.due_date || '',
notes: initialData?.notes || '',
})
}
}, [open, initialData])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!formData.title) {
toast({ title: 'Titel krävs', variant: 'destructive' })
return
}
setIsLoading(true)
try {
const url = initialData?.id
? `/api/deliverables/${initialData.id}`
: `/api/campaigns/${campaignId}/deliverables`
const method = initialData?.id ? 'PATCH' : 'POST'
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...formData,
due_date: formData.due_date || null,
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to save deliverable')
}
const { data } = await response.json()
toast({
title: initialData?.id ? 'Leverabel uppdaterad' : 'Leverabel tillagd',
description: formData.title,
})
onOpenChange(false)
onSuccess?.(data)
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Något gick fel',
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
{initialData?.id ? 'Redigera leverabel' : 'Lägg till leverabel'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="title">Titel *</Label>
<Input
id="title"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="T.ex. Instagram Reel - Produktrecension"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="platform">Plattform *</Label>
<Select
value={formData.platform}
onValueChange={(v) => setFormData({ ...formData, platform: v as PlatformType })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(PLATFORM_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="deliverable_type">Typ *</Label>
<Select
value={formData.deliverable_type}
onValueChange={(v) => setFormData({ ...formData, deliverable_type: v as DeliverableType })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(DELIVERABLE_TYPE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="account_handle">Konto/Kanal</Label>
<Input
id="account_handle"
value={formData.account_handle || ''}
onChange={(e) => setFormData({ ...formData, account_handle: e.target.value })}
placeholder="@användarnamn"
/>
</div>
<div>
<Label htmlFor="quantity">Antal</Label>
<Input
id="quantity"
type="number"
min="1"
value={formData.quantity || 1}
onChange={(e) => setFormData({ ...formData, quantity: parseInt(e.target.value) || 1 })}
/>
</div>
</div>
<div>
<Label htmlFor="due_date">Deadline</Label>
<Input
id="due_date"
type="date"
value={formData.due_date || ''}
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
/>
<p className="text-xs text-muted-foreground mt-1">
En deadline skapas automatiskt i kalendern
</p>
</div>
<div>
<Label htmlFor="description">Beskrivning</Label>
<Textarea
id="description"
value={formData.description || ''}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Specifika instruktioner eller krav..."
rows={3}
/>
</div>
<div>
<Label htmlFor="notes">Interna anteckningar</Label>
<Textarea
id="notes"
value={formData.notes || ''}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
placeholder="Anteckningar..."
rows={2}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Avbryt
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Sparar...' : initialData?.id ? 'Spara' : 'Lägg till'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
-190
View File
@@ -1,190 +0,0 @@
'use client'
import { useState } from 'react'
import { Deliverable, DeliverableStatus, DELIVERABLE_STATUS_LABELS } from '@/types'
import { DeliverableCard } from './DeliverableCard'
import { DeliverableForm } from './DeliverableForm'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { Plus, ChevronDown, ChevronUp } from 'lucide-react'
import { cn } from '@/lib/utils'
interface DeliverableListProps {
campaignId: string
deliverables: Deliverable[]
onUpdate: () => void
}
export function DeliverableList({ campaignId, deliverables, onUpdate }: DeliverableListProps) {
const { toast } = useToast()
const [showCompleted, setShowCompleted] = useState(false)
const [formOpen, setFormOpen] = useState(false)
const [editingDeliverable, setEditingDeliverable] = useState<Deliverable | null>(null)
// Split deliverables by completion status
const activeDeliverables = deliverables.filter(d =>
!['approved', 'published'].includes(d.status)
)
const completedDeliverables = deliverables.filter(d =>
['approved', 'published'].includes(d.status)
)
const handleStatusChange = async (deliverable: Deliverable, newStatus: string) => {
try {
const response = await fetch(`/api/deliverables/${deliverable.id}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus }),
})
if (!response.ok) {
throw new Error('Failed to update status')
}
toast({
title: 'Status uppdaterad',
description: `${deliverable.title}: ${DELIVERABLE_STATUS_LABELS[newStatus as DeliverableStatus]}`,
})
onUpdate()
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte uppdatera status',
variant: 'destructive',
})
}
}
const handleDelete = async (deliverable: Deliverable) => {
if (!confirm(`Ta bort "${deliverable.title}"?`)) return
try {
const response = await fetch(`/api/deliverables/${deliverable.id}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Failed to delete')
}
toast({
title: 'Innehåll borttaget',
description: deliverable.title,
})
onUpdate()
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte ta bort innehåll',
variant: 'destructive',
})
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium">
Innehåll
<span className="text-muted-foreground ml-2">
({activeDeliverables.length} aktiva)
</span>
</h3>
<Button size="sm" onClick={() => setFormOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Lägg till
</Button>
</div>
{/* Active deliverables */}
{activeDeliverables.length > 0 ? (
<div className="space-y-3">
{activeDeliverables
.sort((a, b) => {
// Sort by due date, nulls last
if (!a.due_date && !b.due_date) return 0
if (!a.due_date) return 1
if (!b.due_date) return -1
return new Date(a.due_date).getTime() - new Date(b.due_date).getTime()
})
.map(deliverable => (
<DeliverableCard
key={deliverable.id}
deliverable={deliverable}
onStatusChange={handleStatusChange}
onEdit={(d) => {
setEditingDeliverable(d)
setFormOpen(true)
}}
onDelete={handleDelete}
/>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<p>Inget aktivt innehåll</p>
<Button
variant="outline"
size="sm"
className="mt-2"
onClick={() => setFormOpen(true)}
>
<Plus className="h-4 w-4 mr-1" />
Lägg till innehåll
</Button>
</div>
)}
{/* Completed deliverables */}
{completedDeliverables.length > 0 && (
<div className="pt-4 border-t">
<button
onClick={() => setShowCompleted(!showCompleted)}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
>
{showCompleted ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
Klara ({completedDeliverables.length})
</button>
{showCompleted && (
<div className="space-y-3 mt-3">
{completedDeliverables.map(deliverable => (
<DeliverableCard
key={deliverable.id}
deliverable={deliverable}
onEdit={(d) => {
setEditingDeliverable(d)
setFormOpen(true)
}}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
)}
{/* Form dialog */}
<DeliverableForm
open={formOpen}
onOpenChange={(open) => {
setFormOpen(open)
if (!open) setEditingDeliverable(null)
}}
campaignId={campaignId}
initialData={editingDeliverable || undefined}
onSuccess={() => {
setFormOpen(false)
setEditingDeliverable(null)
onUpdate()
}}
/>
</div>
)
}
-381
View File
@@ -1,381 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import type { Exclusivity, CreateExclusivityInput, ExclusivityConflict } from '@/types'
import { AlertTriangle, X, Plus } from 'lucide-react'
interface ExclusivityFormProps {
open: boolean
onOpenChange: (open: boolean) => void
campaignId: string
initialData?: Partial<Exclusivity>
onSuccess?: (exclusivity: Exclusivity) => void
}
// Common category suggestions
const CATEGORY_SUGGESTIONS = [
'Skönhet', 'Hudvård', 'Smink', 'Mode', 'Kläder', 'Accessoarer',
'Mat', 'Dryck', 'Kaffe', 'Energidryck', 'Snacks',
'Teknik', 'Mobil', 'Gaming', 'Elektronik',
'Träning', 'Gym', 'Kosttillskott', 'Sport',
'Resor', 'Hotell', 'Flyg',
'Bank', 'Finans', 'Försäkring',
'Bil', 'Bilar', 'Transport'
]
export function ExclusivityForm({
open,
onOpenChange,
campaignId,
initialData,
onSuccess
}: ExclusivityFormProps) {
const { toast } = useToast()
const [isLoading, setIsLoading] = useState(false)
const [isCheckingConflicts, setIsCheckingConflicts] = useState(false)
const [conflicts, setConflicts] = useState<ExclusivityConflict[]>([])
const [newCategory, setNewCategory] = useState('')
const [newBrand, setNewBrand] = useState('')
const [formData, setFormData] = useState<Omit<CreateExclusivityInput, 'campaign_id'>>({
categories: [],
excluded_brands: [],
start_date: '',
end_date: '',
notes: '',
})
useEffect(() => {
if (open) {
setFormData({
categories: initialData?.categories || [],
excluded_brands: initialData?.excluded_brands || [],
start_date: initialData?.start_date || '',
end_date: initialData?.end_date || '',
notes: initialData?.notes || '',
})
setConflicts([])
setNewCategory('')
setNewBrand('')
}
}, [open, initialData])
// Check for conflicts when dates or categories change
const checkConflicts = async () => {
if (!formData.categories.length || !formData.start_date || !formData.end_date) {
setConflicts([])
return
}
setIsCheckingConflicts(true)
try {
const params = new URLSearchParams({
categories: formData.categories.join(','),
start_date: formData.start_date,
end_date: formData.end_date,
exclude_campaign_id: campaignId,
})
const response = await fetch(`/api/exclusivities/conflicts?${params}`)
if (response.ok) {
const data = await response.json()
setConflicts(data.conflicts || [])
}
} catch (error) {
console.error('Failed to check conflicts:', error)
} finally {
setIsCheckingConflicts(false)
}
}
useEffect(() => {
const timer = setTimeout(checkConflicts, 500)
return () => clearTimeout(timer)
}, [formData.categories, formData.start_date, formData.end_date])
const addCategory = (category: string) => {
const trimmed = category.trim()
if (trimmed && !formData.categories.includes(trimmed)) {
setFormData({
...formData,
categories: [...formData.categories, trimmed]
})
}
setNewCategory('')
}
const removeCategory = (category: string) => {
setFormData({
...formData,
categories: formData.categories.filter(c => c !== category)
})
}
const addBrand = (brand: string) => {
const trimmed = brand.trim()
if (trimmed && !formData.excluded_brands?.includes(trimmed)) {
setFormData({
...formData,
excluded_brands: [...(formData.excluded_brands || []), trimmed]
})
}
setNewBrand('')
}
const removeBrand = (brand: string) => {
setFormData({
...formData,
excluded_brands: formData.excluded_brands?.filter(b => b !== brand) || []
})
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (formData.categories.length === 0) {
toast({ title: 'Minst en kategori krävs', variant: 'destructive' })
return
}
if (!formData.start_date || !formData.end_date) {
toast({ title: 'Start- och slutdatum krävs', variant: 'destructive' })
return
}
setIsLoading(true)
try {
const url = initialData?.id
? `/api/exclusivities/${initialData.id}`
: `/api/campaigns/${campaignId}/exclusivities`
const method = initialData?.id ? 'PATCH' : 'POST'
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to save exclusivity')
}
const result = await response.json()
// Show warning if there are conflicts
if (result.warning) {
toast({
title: 'Exklusivitet sparad med varning',
description: result.warning,
variant: 'default',
})
} else {
toast({
title: initialData?.id ? 'Exklusivitet uppdaterad' : 'Exklusivitet tillagd',
})
}
onOpenChange(false)
onSuccess?.(result.data)
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Något gick fel',
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{initialData?.id ? 'Redigera exklusivitet' : 'Lägg till exklusivitet'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Categories */}
<div>
<Label>Kategorier *</Label>
<div className="flex flex-wrap gap-2 mt-2 mb-2">
{formData.categories.map(category => (
<Badge key={category} variant="secondary" className="gap-1">
{category}
<button
type="button"
onClick={() => removeCategory(category)}
className="hover:text-destructive"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
<div className="flex gap-2">
<Input
value={newCategory}
onChange={(e) => setNewCategory(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addCategory(newCategory)
}
}}
placeholder="Lägg till kategori..."
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => addCategory(newCategory)}
>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="flex flex-wrap gap-1 mt-2">
{CATEGORY_SUGGESTIONS
.filter(s => !formData.categories.includes(s))
.slice(0, 8)
.map(suggestion => (
<button
key={suggestion}
type="button"
onClick={() => addCategory(suggestion)}
className="text-xs text-muted-foreground hover:text-foreground px-2 py-0.5 rounded border hover:border-primary"
>
+ {suggestion}
</button>
))}
</div>
</div>
{/* Excluded brands */}
<div>
<Label>Specifika varumärken som exkluderas (valfritt)</Label>
<div className="flex flex-wrap gap-2 mt-2 mb-2">
{formData.excluded_brands?.map(brand => (
<Badge key={brand} variant="outline" className="gap-1">
{brand}
<button
type="button"
onClick={() => removeBrand(brand)}
className="hover:text-destructive"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
<div className="flex gap-2">
<Input
value={newBrand}
onChange={(e) => setNewBrand(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addBrand(newBrand)
}
}}
placeholder="Lägg till varumärke..."
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => addBrand(newBrand)}
>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
{/* Date range */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="start_date">Startdatum *</Label>
<Input
id="start_date"
type="date"
value={formData.start_date}
onChange={(e) => setFormData({ ...formData, start_date: e.target.value })}
required
/>
</div>
<div>
<Label htmlFor="end_date">Slutdatum *</Label>
<Input
id="end_date"
type="date"
value={formData.end_date}
onChange={(e) => setFormData({ ...formData, end_date: e.target.value })}
required
/>
</div>
</div>
{/* Conflict warnings */}
{conflicts.length > 0 && (
<div className="p-3 bg-orange-50 border border-orange-200 rounded-lg">
<div className="flex items-start gap-2">
<AlertTriangle className="h-5 w-5 text-orange-600 flex-shrink-0" />
<div>
<p className="font-medium text-orange-800">
{conflicts.length} överlappande exklusivitet(er)
</p>
<ul className="text-sm text-orange-700 mt-1 space-y-1">
{conflicts.map((conflict, i) => (
<li key={i}>
<strong>{conflict.conflictingCampaign?.name || 'Okänd kampanj'}</strong>
: {conflict.overlappingCategories.join(', ')}
{' '}({conflict.overlapStart} - {conflict.overlapEnd})
</li>
))}
</ul>
</div>
</div>
</div>
)}
{/* Notes */}
<div>
<Label htmlFor="notes">Anteckningar</Label>
<Textarea
id="notes"
value={formData.notes || ''}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
placeholder="Detaljer om exklusiviteten..."
rows={2}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Avbryt
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Sparar...' : initialData?.id ? 'Spara' : 'Lägg till'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
-202
View File
@@ -1,202 +0,0 @@
'use client'
import { useState } from 'react'
import { Exclusivity } from '@/types'
import { ExclusivityForm } from './ExclusivityForm'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import { Plus, Trash2, Edit2, Calendar, AlertTriangle } from 'lucide-react'
import { cn } from '@/lib/utils'
interface ExclusivityListProps {
campaignId: string
exclusivities: Exclusivity[]
onUpdate: () => void
}
export function ExclusivityList({ campaignId, exclusivities, onUpdate }: ExclusivityListProps) {
const { toast } = useToast()
const [formOpen, setFormOpen] = useState(false)
const [editingExclusivity, setEditingExclusivity] = useState<Exclusivity | null>(null)
const today = new Date().toISOString().split('T')[0]
const handleDelete = async (exclusivity: Exclusivity) => {
if (!confirm('Ta bort denna exklusivitet?')) return
try {
const response = await fetch(`/api/exclusivities/${exclusivity.id}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Failed to delete')
}
toast({
title: 'Exklusivitet borttagen',
})
onUpdate()
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte ta bort exklusivitet',
variant: 'destructive',
})
}
}
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('sv-SE', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
const getStatus = (exclusivity: Exclusivity) => {
if (exclusivity.end_date < today) return 'expired'
if (exclusivity.start_date > today) return 'upcoming'
return 'active'
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium">
Exklusiviteter
<span className="text-muted-foreground ml-2">
({exclusivities.length})
</span>
</h3>
<Button size="sm" onClick={() => setFormOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Lägg till
</Button>
</div>
{exclusivities.length > 0 ? (
<div className="space-y-3">
{exclusivities
.sort((a, b) => new Date(a.start_date).getTime() - new Date(b.start_date).getTime())
.map(exclusivity => {
const status = getStatus(exclusivity)
return (
<div
key={exclusivity.id}
className={cn(
'p-4 rounded-lg border',
status === 'active' && 'border-orange-200 bg-orange-50',
status === 'upcoming' && 'border-blue-200 bg-blue-50',
status === 'expired' && 'border-gray-200 bg-gray-50 opacity-60'
)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
{status === 'active' && (
<Badge variant="outline" className="text-orange-700 border-orange-300 bg-orange-100">
<AlertTriangle className="h-3 w-3 mr-1" />
Aktiv
</Badge>
)}
{status === 'upcoming' && (
<Badge variant="outline" className="text-blue-700 border-blue-300 bg-blue-100">
Kommande
</Badge>
)}
{status === 'expired' && (
<Badge variant="outline" className="text-gray-500">
Avslutad
</Badge>
)}
</div>
<div className="flex flex-wrap gap-1.5 mb-2">
{exclusivity.categories.map(category => (
<Badge key={category} variant="secondary">
{category}
</Badge>
))}
</div>
{exclusivity.excluded_brands && exclusivity.excluded_brands.length > 0 && (
<div className="text-sm text-muted-foreground mb-2">
Exkluderade varumärken: {exclusivity.excluded_brands.join(', ')}
</div>
)}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar className="h-4 w-4" />
<span>
{formatDate(exclusivity.start_date)} - {formatDate(exclusivity.end_date)}
</span>
</div>
{exclusivity.notes && (
<p className="text-sm text-muted-foreground mt-2">
{exclusivity.notes}
</p>
)}
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingExclusivity(exclusivity)
setFormOpen(true)
}}
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(exclusivity)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
})}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<p>Inga exklusiviteter</p>
<Button
variant="outline"
size="sm"
className="mt-2"
onClick={() => setFormOpen(true)}
>
<Plus className="h-4 w-4 mr-1" />
Lägg till exklusivitet
</Button>
</div>
)}
{/* Form dialog */}
<ExclusivityForm
open={formOpen}
onOpenChange={(open) => {
setFormOpen(open)
if (!open) setEditingExclusivity(null)
}}
campaignId={campaignId}
initialData={editingExclusivity || undefined}
onSuccess={() => {
setFormOpen(false)
setEditingExclusivity(null)
onUpdate()
}}
/>
</div>
)
}
-14
View File
@@ -1,14 +0,0 @@
export { CampaignStatusBadge } from './CampaignStatusBadge'
export { CampaignCard } from './CampaignCard'
export { CampaignList } from './CampaignList'
export { CampaignForm } from './CampaignForm'
export { CampaignDetail } from './CampaignDetail'
export { DeliverableCard } from './DeliverableCard'
export { DeliverableForm } from './DeliverableForm'
export { DeliverableList } from './DeliverableList'
export { ExclusivityForm } from './ExclusivityForm'
export { ExclusivityList } from './ExclusivityList'
export { ContractUpload } from './ContractUpload'
export { ContractList } from './ContractList'
export { CampaignInvoiceSummary } from './CampaignInvoiceSummary'
export { CampaignsWidget } from './CampaignsWidget'
+5 -5
View File
@@ -86,20 +86,20 @@ export function ChatPanel({ className }: ChatPanelProps) {
<h4 className="font-medium mb-2">Hur kan jag hjälpa dig?</h4>
<p className="text-sm text-muted-foreground max-w-xs">
Jag kan svara 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 mina fakturor?
</SuggestionButton>
<SuggestionButton
onClick={() => sendMessage('Vad kan jag dra av som influencer?')}
onClick={() => sendMessage('Vad kan jag dra av som företagare?')}
disabled={isLoading}
>
Vad kan jag dra av som influencer?
Vad kan jag dra av som företagare?
</SuggestionButton>
<SuggestionButton
onClick={() => sendMessage('När måste jag momsregistrera mig?')}
@@ -1,100 +0,0 @@
'use client'
import { cn } from '@/lib/utils'
import type { ConfidenceLevel } from '@/types'
import { CheckCircle2, AlertCircle, HelpCircle, XCircle } from 'lucide-react'
interface ConfidenceIndicatorProps {
level: ConfidenceLevel
showLabel?: boolean
size?: 'sm' | 'md'
}
const config: Record<ConfidenceLevel, {
icon: typeof CheckCircle2
color: string
bgColor: string
label: string
}> = {
high: {
icon: CheckCircle2,
color: 'text-green-600',
bgColor: 'bg-green-100',
label: 'Hög säkerhet',
},
medium: {
icon: AlertCircle,
color: 'text-yellow-600',
bgColor: 'bg-yellow-100',
label: 'Medel säkerhet',
},
low: {
icon: HelpCircle,
color: 'text-orange-600',
bgColor: 'bg-orange-100',
label: 'Låg säkerhet',
},
missing: {
icon: XCircle,
color: 'text-gray-400',
bgColor: 'bg-gray-100',
label: 'Saknas',
},
}
export function ConfidenceIndicator({
level,
showLabel = false,
size = 'md',
}: ConfidenceIndicatorProps) {
const { icon: Icon, color, bgColor, label } = config[level]
const iconSize = size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4'
if (showLabel) {
return (
<span
className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
bgColor,
color
)}
>
<Icon className={iconSize} />
{label}
</span>
)
}
return (
<span title={label}>
<Icon className={cn(iconSize, color)} />
</span>
)
}
interface ConfidenceFieldProps {
label: string
confidence: ConfidenceLevel
children: React.ReactNode
className?: string
}
export function ConfidenceField({
label,
confidence,
children,
className,
}: ConfidenceFieldProps) {
return (
<div className={cn('space-y-1', className)}>
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-muted-foreground">
{label}
</label>
<ConfidenceIndicator level={confidence} size="sm" />
</div>
{children}
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -1,68 +0,0 @@
'use client'
import { cn } from '@/lib/utils'
import type { ExtractionStatus } from '@/types'
import { Loader2, CheckCircle2, XCircle, Clock, SkipForward } from 'lucide-react'
interface ExtractionStatusBadgeProps {
status: ExtractionStatus
className?: string
}
const statusConfig: Record<ExtractionStatus, {
icon: typeof Loader2
color: string
bgColor: string
label: string
animate?: boolean
}> = {
pending: {
icon: Clock,
color: 'text-gray-600',
bgColor: 'bg-gray-100',
label: 'Väntar',
},
processing: {
icon: Loader2,
color: 'text-blue-600',
bgColor: 'bg-blue-100',
label: 'Analyserar...',
animate: true,
},
completed: {
icon: CheckCircle2,
color: 'text-green-600',
bgColor: 'bg-green-100',
label: 'Klar',
},
failed: {
icon: XCircle,
color: 'text-red-600',
bgColor: 'bg-red-100',
label: 'Misslyckades',
},
skipped: {
icon: SkipForward,
color: 'text-gray-500',
bgColor: 'bg-gray-100',
label: 'Hoppade över',
},
}
export function ExtractionStatusBadge({ status, className }: ExtractionStatusBadgeProps) {
const { icon: Icon, color, bgColor, label, animate } = statusConfig[status]
return (
<span
className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium',
bgColor,
color,
className
)}
>
<Icon className={cn('h-3.5 w-3.5', animate && 'animate-spin')} />
{label}
</span>
)
}
+2 -100
View File
@@ -14,8 +14,6 @@ import {
import { getSchablonavdragSummary } from '@/lib/tax/schablonavdrag'
import FSkattWarningCard from '@/components/dashboard/FSkattWarningCard'
import { UpcomingDeadlinesWidget } from '@/components/calendar/UpcomingDeadlinesWidget'
import { CampaignsWidget } from '@/components/campaigns/CampaignsWidget'
import { TikTokStatsWidget } from '@/components/tiktok'
import NewUserChecklist from '@/components/onboarding/NewUserChecklist'
import {
TrendingUp,
@@ -23,15 +21,13 @@ import {
Receipt,
ArrowLeftRight,
ChevronDown,
Gift,
ArrowRight,
Megaphone,
Camera,
HelpCircle,
Users,
FileText,
} from 'lucide-react'
import type { CompanySettings, EntityType, MileageEntry, SchablonavdragSettings, GiftSummary, Deadline, Campaign, TikTokStatsSummary, ReceiptQueueSummary, OnboardingProgress } from '@/types'
import type { CompanySettings, EntityType, MileageEntry, SchablonavdragSettings, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
interface DashboardContentProps {
firstName?: string | null
@@ -48,56 +44,15 @@ interface DashboardContentProps {
overdueInvoicesCount: number
bankBalance: number | null
mileageEntries: MileageEntry[]
giftSummary: GiftSummary | null
deadlines: Deadline[]
campaigns: Campaign[]
receiptQueue: ReceiptQueueSummary | null
}
onboardingProgress?: OnboardingProgress
}
export default function DashboardContent({ firstName, settings, summary, onboardingProgress }: DashboardContentProps) {
const [tiktokStats, setTiktokStats] = useState<TikTokStatsSummary | null>(null)
const [isTiktokSyncing, setIsTiktokSyncing] = useState(false)
const [showAllAlerts, setShowAllAlerts] = useState(false)
useEffect(() => {
fetchTikTokStats()
}, [])
const fetchTikTokStats = async () => {
try {
const response = await fetch('/api/tiktok/stats')
const data = await response.json()
if (data.summary) {
setTiktokStats(data.summary)
}
} catch (error) {
// TikTok not connected or error - that's fine
}
}
const handleTikTokSync = async () => {
setIsTiktokSyncing(true)
try {
const accountsResponse = await fetch('/api/tiktok/accounts')
const accountsData = await accountsResponse.json()
const activeAccount = accountsData.accounts?.find((a: { status: string }) => a.status === 'active')
if (activeAccount) {
await fetch('/api/tiktok/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account_id: activeAccount.id, sync_type: 'full' }),
})
await fetchTikTokStats()
}
} catch (error) {
console.error('TikTok sync failed:', error)
}
setIsTiktokSyncing(false)
}
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const preliminaryTaxMonthly = settings?.preliminary_tax_monthly || 0
const currentMonth = new Date().getMonth() + 1
@@ -111,11 +66,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
currentMonth
)
const giftTaxableValue = summary.giftSummary?.taxable_value || 0
const giftDeductibleValue = summary.giftSummary?.deductible_value || 0
const netGiftTaxableIncome = giftTaxableValue - giftDeductibleValue
const totalTaxableIncome = summary.ytd.net + netGiftTaxableIncome
const totalTaxableIncome = summary.ytd.net
const taxEstimate =
entityType === 'enskild_firma'
@@ -242,36 +193,6 @@ export default function DashboardContent({ firstName, settings, summary, onboard
)
}
if (summary.giftSummary && summary.giftSummary.total_count > 0) {
alertItems.push(
<Link key="gifts" href="/gifts" className="group">
<Card className="h-full border-l-4 border-l-accent hover:border-primary/30 transition-colors">
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-accent/10">
<Gift className="h-4 w-4 text-accent" />
</div>
<div>
<p className="font-medium text-sm">Gåvor & Förmåner</p>
<p className="text-sm text-muted-foreground mt-0.5">
{summary.giftSummary.total_count} produkter
</p>
{summary.giftSummary.taxable_count > 0 && (
<Badge variant="destructive" className="mt-1.5">
{formatCurrency(summary.giftSummary.taxable_value)} skattepliktig
</Badge>
)}
</div>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground group-hover:translate-x-0.5 transition-all" />
</div>
</CardContent>
</Card>
</Link>
)
}
const MAX_VISIBLE_ALERTS = 3
const visibleAlerts = showAllAlerts ? alertItems : alertItems.slice(0, MAX_VISIBLE_ALERTS)
const hasMoreAlerts = alertItems.length > MAX_VISIBLE_ALERTS
@@ -280,7 +201,6 @@ export default function DashboardContent({ firstName, settings, summary, onboard
const quickActions = [
{ href: '/invoices/new', icon: Receipt, label: 'Ny faktura', desc: 'Skapa och skicka', accent: true },
{ href: '/receipts/scan', icon: Camera, label: 'Skanna kvitto', desc: 'Fotografera & spara' },
{ href: '/campaigns/new', icon: Megaphone, label: 'Nytt samarbete', desc: 'Spåra innehåll' },
{ href: '/customers', icon: Users, label: 'Ny kund', desc: 'Lägg till kunduppgifter' },
{ href: '/transactions', icon: ArrowLeftRight, label: 'Transaktioner', desc: 'Kategorisera' },
]
@@ -404,24 +324,6 @@ export default function DashboardContent({ firstName, settings, summary, onboard
/>
</section>
{/* TikTok stats widget */}
{tiktokStats && (
<section className="mb-8">
<TikTokStatsWidget
stats={tiktokStats}
onSync={handleTikTokSync}
isSyncing={isTiktokSyncing}
/>
</section>
)}
{/* Active campaigns */}
{summary.campaigns && summary.campaigns.length > 0 && (
<section className="mb-8">
<CampaignsWidget campaigns={summary.campaigns} />
</section>
)}
{/* Alerts section - with urgency indicators and limit */}
{alertItems.length > 0 && (
<section id="alerts-section" className="mb-16">
+13 -30
View File
@@ -16,18 +16,14 @@ import {
Settings,
LogOut,
Calculator,
Gift,
Upload,
Calendar,
Megaphone,
FileUp,
Camera,
Menu,
X,
HelpCircle,
ChevronDown,
ChevronUp,
Wallet,
} from 'lucide-react'
import type { EntityType } from '@/types'
@@ -48,19 +44,14 @@ interface NavItem {
const navItems: NavItem[] = [
{ href: '/', label: 'Översikt', icon: LayoutDashboard, group: 'main' },
{ href: '/calendar', label: 'Kalender', icon: Calendar, group: 'main' },
{ href: '/campaigns', label: 'Samarbeten', icon: Megaphone, group: 'main' },
{ href: '/campaigns/import', label: 'Importera avtal', icon: FileUp, group: 'main' },
{ href: '/analytics', label: 'Analytics', icon: BarChart3, group: 'main' },
{ href: '/shadow-ledger', label: 'Utbetalningar', icon: Wallet, group: 'finans', modes: ['light'] },
{ href: '/invoices', label: 'Fakturor', icon: Receipt, group: 'finans', modes: ['enskild_firma', 'aktiebolag'] },
{ href: '/customers', label: 'Kunder', icon: Users, group: 'finans', modes: ['enskild_firma', 'aktiebolag'] },
{ href: '/invoices', label: 'Fakturor', icon: Receipt, group: 'finans' },
{ href: '/customers', label: 'Kunder', icon: Users, group: 'finans' },
{ href: '/transactions', label: 'Transaktioner', icon: ArrowLeftRight, group: 'finans' },
{ href: '/receipts', label: 'Kvitton', icon: Camera, group: 'finans' },
{ href: '/gifts', label: 'Gåvor', icon: Gift, group: 'finans' },
{ href: '/deductions', label: 'Avdrag', icon: Calculator, group: 'finans', modes: ['enskild_firma', 'aktiebolag'] },
{ href: '/bookkeeping', label: 'Bokföring', icon: BookOpen, group: 'finans', modes: ['enskild_firma', 'aktiebolag'] },
{ href: '/import', label: 'Importera', icon: Upload, group: 'övrigt', modes: ['enskild_firma', 'aktiebolag'] },
{ href: '/reports', label: 'Rapporter', icon: BarChart3, group: 'övrigt', modes: ['enskild_firma', 'aktiebolag'] },
{ href: '/deductions', label: 'Avdrag', icon: Calculator, group: 'finans' },
{ href: '/bookkeeping', label: 'Bokföring', icon: BookOpen, group: 'finans' },
{ href: '/import', label: 'Importera', icon: Upload, group: 'övrigt' },
{ href: '/reports', label: 'Rapporter', icon: BarChart3, group: 'övrigt' },
{ href: '/help', label: 'Hjälp', icon: HelpCircle, group: 'övrigt' },
{ href: '/settings', label: 'Inställningar', icon: Settings, group: 'övrigt' },
]
@@ -101,20 +92,12 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
const finansItems = filteredItems.filter(i => i.group === 'finans')
const övrigtItems = filteredItems.filter(i => i.group === 'övrigt')
// Mobile nav items - light mode shows Utbetalningar instead of Fakturor
const mobileNavItems = entityType === 'light'
? [
{ href: '/', label: 'Översikt', icon: LayoutDashboard },
{ href: '/campaigns', label: 'Samarbeten', icon: Megaphone },
{ href: '/receipts/scan', label: 'Skanna', icon: Camera, isScan: true },
{ href: '/shadow-ledger', label: 'Utbetalningar', icon: Wallet },
]
: [
{ href: '/', label: 'Översikt', icon: LayoutDashboard },
{ href: '/campaigns', label: 'Samarbeten', icon: Megaphone },
{ href: '/receipts/scan', label: 'Skanna', icon: Camera, isScan: true },
{ href: '/invoices', label: 'Fakturor', icon: Receipt },
]
const mobileNavItems = [
{ href: '/', label: 'Översikt', icon: LayoutDashboard },
{ href: '/invoices', label: 'Fakturor', icon: Receipt },
{ href: '/receipts/scan', label: 'Skanna', icon: Camera, isScan: true },
{ href: '/transactions', label: 'Transaktioner', icon: ArrowLeftRight },
]
return (
<>
@@ -128,7 +111,7 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
{companyName}
</h1>
<p className="text-xs text-muted-foreground mt-0.5 tracking-wide uppercase">
{entityType === 'light' ? 'Egenanställd' : 'Ekonomi'}
Ekonomi
</p>
</div>
-79
View File
@@ -1,79 +0,0 @@
'use client'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { cn, formatCurrency } from '@/lib/utils'
import { Gift, ArrowRight, CheckCircle2 } from 'lucide-react'
interface GiftTaxDebtCardProps {
virtualTaxDebt: number
taxableGiftCount: number
effectiveRate: number
}
export default function GiftTaxDebtCard({
virtualTaxDebt,
taxableGiftCount,
effectiveRate,
}: GiftTaxDebtCardProps) {
const hasDebt = virtualTaxDebt > 0
return (
<Link href="/gifts" className="group block">
<Card className="h-full hover:border-primary/30 transition-colors">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Gift className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-lg">G\u00e5voskatt</CardTitle>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground group-hover:translate-x-0.5 transition-all" />
</div>
</CardHeader>
<CardContent>
{hasDebt ? (
<div className="space-y-3">
<div>
<p className="font-display text-2xl font-medium tabular-nums text-amber-600">
{formatCurrency(virtualTaxDebt)}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
Virtuell skatteskuld
</p>
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<div>
<span className="tabular-nums font-medium text-foreground">
{taxableGiftCount}
</span>{' '}
skattepliktiga g\u00e5vor
</div>
<span className="text-border">{'\u00b7'}</span>
<div>
<span className="tabular-nums font-medium text-foreground">
{(effectiveRate * 100).toFixed(1)}%
</span>{' '}
skattesats
</div>
</div>
</div>
) : (
<div className="flex items-center gap-3 py-2">
<CheckCircle2 className="h-5 w-5 text-emerald-500 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-emerald-700">
Ingen g\u00e5voskatt att betala
</p>
<p className="text-xs text-muted-foreground mt-0.5">
Du har inga skattepliktiga g\u00e5vor registrerade i \u00e5r
</p>
</div>
</div>
)}
</CardContent>
</Card>
</Link>
)
}
@@ -1,134 +0,0 @@
'use client'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import SafeToSpendGauge from '@/components/dashboard/SafeToSpendGauge'
import SGIShieldWidget from '@/components/dashboard/SGIShieldWidget'
import GiftTaxDebtCard from '@/components/dashboard/GiftTaxDebtCard'
import RecentPayoutsCard from '@/components/dashboard/RecentPayoutsCard'
import {
Banknote,
Camera,
Megaphone,
Gift,
} from 'lucide-react'
interface RecentEntry {
id: string
date: string
description: string | null
gross_amount: number
net_amount: number
service_fee: number
pension_deduction: number
social_fees: number
income_tax_withheld: number
platform_fee: number
type: string
provider: string | null
}
interface LightDashboardContentProps {
firstName: string | null
bankBalance: number | null
giftTaxDebt: number
taxableGiftCount: number
effectiveRate: number
daysSinceLastPayout: number | null
recentEntries: RecentEntry[]
hobbyReserve?: number
}
export default function LightDashboardContent({
firstName,
bankBalance,
giftTaxDebt,
taxableGiftCount,
effectiveRate,
daysSinceLastPayout,
recentEntries,
hobbyReserve = 0,
}: LightDashboardContentProps) {
const greeting = (() => {
const hour = new Date().getHours()
if (hour < 12) return 'Godmorgon'
if (hour < 18) return 'God eftermiddag'
return 'God kv\u00e4ll'
})()
const quickActions = [
{
href: '/shadow-ledger/new',
icon: Banknote,
label: 'Logga utbetalning',
},
{
href: '/receipts/scan',
icon: Camera,
label: 'Skanna kvitto',
},
{
href: '/campaigns/new',
icon: Megaphone,
label: 'Nytt samarbete',
},
{
href: '/gifts',
icon: Gift,
label: 'Ny g\u00e5va',
},
]
return (
<div className="stagger-enter">
{/* Greeting header */}
<header className="mb-10">
<h1 className="font-display text-4xl md:text-5xl font-medium tracking-tight">
{greeting}{firstName ? `, ${firstName}` : ''}!
</h1>
</header>
{/* SafeToSpendGauge - full width */}
<section className="mb-6">
<SafeToSpendGauge
bankBalance={bankBalance ?? 0}
giftTaxDebt={giftTaxDebt}
hobbyReserve={hobbyReserve}
/>
</section>
{/* SGIShieldWidget - full width */}
<section className="mb-6">
<SGIShieldWidget daysSinceLastPayout={daysSinceLastPayout} />
</section>
{/* GiftTaxDebtCard + RecentPayoutsCard side by side on md+ */}
<section className="grid md:grid-cols-2 gap-6 mb-10">
<GiftTaxDebtCard
virtualTaxDebt={giftTaxDebt}
taxableGiftCount={taxableGiftCount}
effectiveRate={effectiveRate}
/>
<RecentPayoutsCard entries={recentEntries} />
</section>
{/* Quick actions row */}
<section className="mb-12">
<h2 className="font-display text-xl font-medium mb-4">Snabb\u00e5tg\u00e4rder</h2>
<div className="flex flex-wrap gap-3">
{quickActions.map((action) => {
const Icon = action.icon
return (
<Link key={action.href} href={action.href}>
<Button variant="outline" size="default" className="gap-2">
<Icon className="h-4 w-4" />
{action.label}
</Button>
</Link>
)
})}
</div>
</section>
</div>
)
}
@@ -1,94 +0,0 @@
'use client'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { formatCurrency, formatDate } from '@/lib/utils'
import { ArrowRight, Banknote, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface RecentPayoutsCardProps {
entries: Array<{
id: string
date: string
description: string | null
gross_amount: number
net_amount: number
service_fee: number
pension_deduction: number
social_fees: number
income_tax_withheld: number
platform_fee: number
type: string
provider: string | null
}>
}
export default function RecentPayoutsCard({ entries }: RecentPayoutsCardProps) {
const displayEntries = entries.slice(0, 5)
const isEmpty = displayEntries.length === 0
return (
<Card className="h-full">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Banknote className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-lg">Senaste utbetalningar</CardTitle>
</div>
{!isEmpty && (
<Link
href="/shadow-ledger"
className="text-sm text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
>
Visa alla
<ArrowRight className="h-3.5 w-3.5" />
</Link>
)}
</div>
</CardHeader>
<CardContent>
{isEmpty ? (
<div className="text-center py-6">
<p className="text-sm text-muted-foreground mb-4">
Inga utbetalningar registrerade
</p>
<Link href="/shadow-ledger/new">
<Button variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" />
Logga utbetalning
</Button>
</Link>
</div>
) : (
<div className="space-y-3">
{displayEntries.map((entry) => (
<div
key={entry.id}
className="flex items-center justify-between py-2 border-b border-border/50 last:border-0"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">
{entry.description || entry.provider || 'Utbetalning'}
</p>
<p className="text-xs text-muted-foreground">
{formatDate(entry.date)}
</p>
</div>
<div className="text-right ml-4 flex-shrink-0">
<p className="text-sm tabular-nums font-medium">
{formatCurrency(entry.net_amount)}
</p>
{entry.gross_amount !== entry.net_amount && (
<p className="text-xs text-muted-foreground tabular-nums">
brutto {formatCurrency(entry.gross_amount)}
</p>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)
}
-107
View File
@@ -1,107 +0,0 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import { Shield, Info } from 'lucide-react'
interface SGIShieldWidgetProps {
daysSinceLastPayout: number | null
}
export default function SGIShieldWidget({
daysSinceLastPayout,
}: SGIShieldWidgetProps) {
const maxDays = 90
const days = daysSinceLastPayout ?? 0
const progress = daysSinceLastPayout !== null ? Math.min((days / maxDays) * 100, 100) : 0
// Determine status
let level: 'green' | 'yellow' | 'red'
let statusMessage: string | null = null
if (daysSinceLastPayout === null) {
level = 'green'
} else if (days < 30) {
level = 'green'
} else if (days < 75) {
level = 'yellow'
statusMessage = 'Varning: Logga uppdrag snart'
} else {
level = 'red'
statusMessage = 'Kritiskt: SGI riskerar nollst\u00e4llning'
}
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Shield
className={cn(
'h-5 w-5',
level === 'green' && 'text-emerald-500',
level === 'yellow' && 'text-amber-500',
level === 'red' && 'text-red-500'
)}
/>
<CardTitle className="text-lg">SGI-skydd</CardTitle>
</div>
{daysSinceLastPayout !== null && (
<span className="text-sm text-muted-foreground tabular-nums">
{days} dagar sedan senast
</span>
)}
</div>
</CardHeader>
<CardContent>
{daysSinceLastPayout === null ? (
<div className="flex items-start gap-3 py-2">
<Info className="h-4 w-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm text-muted-foreground">
Inga utbetalningar registrerade \u00e4nnu
</p>
<p className="text-xs text-muted-foreground mt-1">
SGI (sjukpenninggrundande inkomst) baseras p\u00e5 dina l\u00f6neutbetalningar.
Logga din f\u00f6rsta utbetalning f\u00f6r att b\u00f6rja sp\u00e5ra.
</p>
</div>
</div>
) : (
<>
{/* Progress bar */}
<div className="w-full h-3 rounded-full overflow-hidden bg-muted mb-2">
<div
className={cn(
'h-full rounded-full transition-all duration-500',
level === 'green' && 'bg-emerald-500',
level === 'yellow' && 'bg-amber-500',
level === 'red' && 'bg-red-500'
)}
style={{ width: `${progress}%` }}
/>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="tabular-nums">{days}/{maxDays}</span>
<span>N\u00e4sta varning: dag 30 {'\u00b7'} Kritisk: dag 75</span>
</div>
{/* Status message */}
{statusMessage && (
<div className="mt-3">
<Badge
variant={level === 'red' ? 'destructive' : 'warning'}
className="text-xs"
>
{statusMessage}
</Badge>
</div>
)}
</>
)}
</CardContent>
</Card>
)
}
-131
View File
@@ -1,131 +0,0 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { cn, formatCurrency } from '@/lib/utils'
import { Wallet } from 'lucide-react'
interface SafeToSpendGaugeProps {
bankBalance: number
giftTaxDebt: number
hobbyReserve: number
}
export default function SafeToSpendGauge({
bankBalance,
giftTaxDebt,
hobbyReserve,
}: SafeToSpendGaugeProps) {
const safeToSpend = Math.max(0, bankBalance - giftTaxDebt - hobbyReserve)
const total = bankBalance || 1 // Avoid division by zero
const safePercent = bankBalance > 0 ? (safeToSpend / total) * 100 : 0
const giftPercent = bankBalance > 0 ? (giftTaxDebt / total) * 100 : 0
const hobbyPercent = bankBalance > 0 ? (hobbyReserve / total) * 100 : 0
// Determine color level
let level: 'green' | 'yellow' | 'red'
if (safePercent > 70) {
level = 'green'
} else if (safePercent > 30) {
level = 'yellow'
} else {
level = 'red'
}
const noBankConnected = !bankBalance || bankBalance === 0
return (
<Card className={cn(noBankConnected && 'opacity-60')}>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Wallet className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-lg">Att spendera</CardTitle>
</div>
</CardHeader>
<CardContent>
{noBankConnected ? (
<div className="text-center py-4">
<p className="text-2xl font-display font-medium text-muted-foreground">
Ingen bank ansluten
</p>
<p className="text-sm text-muted-foreground mt-1">
Koppla din bank f\u00f6r att se ditt disponibla belopp
</p>
</div>
) : (
<>
<div className="text-center mb-6">
<p
className={cn(
'font-display text-4xl font-medium tabular-nums',
level === 'green' && 'text-emerald-600',
level === 'yellow' && 'text-amber-600',
level === 'red' && 'text-red-600'
)}
>
{formatCurrency(safeToSpend)}
</p>
<p className="text-sm text-muted-foreground mt-1">
av {formatCurrency(bankBalance)} p\u00e5 kontot
</p>
</div>
{/* Stacked bar */}
<div className="w-full h-4 rounded-full overflow-hidden flex bg-muted">
{safePercent > 0 && (
<div
className={cn(
'h-full transition-all duration-500',
level === 'green' && 'bg-emerald-500',
level === 'yellow' && 'bg-amber-500',
level === 'red' && 'bg-red-500'
)}
style={{ width: `${safePercent}%` }}
/>
)}
{giftPercent > 0 && (
<div
className="h-full bg-amber-400 transition-all duration-500"
style={{ width: `${giftPercent}%` }}
/>
)}
{hobbyPercent > 0 && (
<div
className="h-full bg-orange-400 transition-all duration-500"
style={{ width: `${hobbyPercent}%` }}
/>
)}
</div>
{/* Legend */}
<div className="flex flex-wrap gap-x-5 gap-y-1 mt-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<span
className={cn(
'inline-block h-2.5 w-2.5 rounded-full',
level === 'green' && 'bg-emerald-500',
level === 'yellow' && 'bg-amber-500',
level === 'red' && 'bg-red-500'
)}
/>
Disponibelt
</div>
{giftTaxDebt > 0 && (
<div className="flex items-center gap-1.5">
<span className="inline-block h-2.5 w-2.5 rounded-full bg-amber-400" />
G\u00e5voskatt ({formatCurrency(giftTaxDebt)})
</div>
)}
{hobbyReserve > 0 && (
<div className="flex items-center gap-1.5">
<span className="inline-block h-2.5 w-2.5 rounded-full bg-orange-400" />
Hobby ({formatCurrency(hobbyReserve)})
</div>
)}
</div>
</>
)}
</CardContent>
</Card>
)
}
+1 -1
View File
@@ -24,7 +24,7 @@ interface TourStep {
position?: 'top' | 'bottom' | 'center'
}
const TOUR_COMPLETED_KEY = 'influencer_dashboard_tour_completed'
const TOUR_COMPLETED_KEY = 'erp_dashboard_tour_completed'
const tourSteps: TourStep[] = [
{
+1 -1
View File
@@ -36,7 +36,7 @@ interface NewUserChecklistProps {
className?: string
}
const CHECKLIST_DISMISSED_KEY = 'influencer_checklist_dismissed'
const CHECKLIST_DISMISSED_KEY = 'erp_checklist_dismissed'
export default function NewUserChecklist({
hasCustomers,
+1 -7
View File
@@ -4,7 +4,7 @@ import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Loader2, ArrowRight, Building2, User, Users, Check } from 'lucide-react'
import { Loader2, ArrowRight, Building2, User, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { EntityType } from '@/types'
@@ -33,12 +33,6 @@ const entityOptions: {
description: 'Du har ett registrerat AB med organisationsnummer',
icon: Building2,
},
{
value: 'light',
label: 'Egenanställd',
description: 'Du arbetar via egenanställningsföretag som Frilans Finans, Cool Company eller Gigapay',
icon: Users,
},
]
export default function Step1EntityType({ initialData, onNext, isSaving }: Step1Props) {
@@ -78,7 +78,7 @@ export default function Step2CompanyDetails({
</Label>
<Input
id="company_name"
placeholder={isAB ? 'AB Företaget' : 'Alices Influencer-verksamhet'}
placeholder={isAB ? 'AB Företaget' : 'Alices Konsultverksamhet'}
{...register('company_name')}
/>
{errors.company_name && (
@@ -1,100 +0,0 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Loader2, ArrowRight, ArrowLeft } from 'lucide-react'
interface Step2LightProps {
initialData: { company_name?: string }
onNext: (data: { company_name: string }) => void
onBack: () => void
isSaving: boolean
}
export default function Step2LightPersonalInfo({
initialData,
onNext,
onBack,
isSaving,
}: Step2LightProps) {
const [name, setName] = useState(initialData.company_name || '')
const [error, setError] = useState<string | null>(null)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const trimmed = name.trim()
if (!trimmed) {
setError('Namn krävs')
return
}
setError(null)
onNext({ company_name: trimmed })
}
return (
<div className="space-y-6">
<div className="text-center">
<h1 className="text-3xl font-bold tracking-tight">Dina uppgifter</h1>
<p className="text-muted-foreground mt-2">
Ange ditt namn som det visas i appen
</p>
</div>
<Card className="max-w-lg mx-auto">
<CardHeader>
<CardTitle>Personuppgifter</CardTitle>
<CardDescription>
Ditt namn används i navigeringen och för att identifiera dig.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="company_name">Fullständigt namn *</Label>
<Input
id="company_name"
placeholder="Anna Andersson"
value={name}
onChange={(e) => {
setName(e.target.value)
if (error) setError(null)
}}
/>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
<div className="flex justify-between pt-4">
<Button
type="button"
variant="ghost"
onClick={onBack}
disabled={isSaving}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
<>
Fortsätt
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}
@@ -1,526 +0,0 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Loader2, ArrowRight, ArrowLeft, Search, Check } from 'lucide-react'
import { createClient } from '@/lib/supabase/client'
import type { MunicipalityRate, UmbrellaProviderDefault } from '@/types'
interface Step3LightProps {
initialData: {
municipality_code?: string
municipal_tax_rate?: number
church_tax?: boolean
church_tax_rate?: number
church_parish_code?: string
umbrella_provider?: string
umbrella_fee_percent?: number
umbrella_pension_percent?: number
umbrella_fee_custom?: boolean
}
onNext: (data: Record<string, unknown>) => void
onBack: () => void
isSaving: boolean
}
export default function Step3LightTaxProfile({
initialData,
onNext,
onBack,
isSaving,
}: Step3LightProps) {
const supabase = createClient()
// Municipality state
const [municipalitySearch, setMunicipalitySearch] = useState('')
const [municipalityResults, setMunicipalityResults] = useState<MunicipalityRate[]>([])
const [selectedMunicipality, setSelectedMunicipality] = useState<MunicipalityRate | null>(null)
const [showMunicipalityDropdown, setShowMunicipalityDropdown] = useState(false)
const [municipalityLoading, setMunicipalityLoading] = useState(false)
// Church tax state
const [churchTax, setChurchTax] = useState(initialData.church_tax ?? false)
const [parishes, setParishes] = useState<MunicipalityRate[]>([])
const [selectedParish, setSelectedParish] = useState<MunicipalityRate | null>(null)
const [parishLoading, setParishLoading] = useState(false)
// Umbrella provider state
const [umbrellaProviders, setUmbrellaProviders] = useState<UmbrellaProviderDefault[]>([])
const [selectedProvider, setSelectedProvider] = useState(initialData.umbrella_provider || '')
const [feePercent, setFeePercent] = useState<number | null>(initialData.umbrella_fee_percent ?? null)
const [pensionPercent, setPensionPercent] = useState<number | null>(initialData.umbrella_pension_percent ?? null)
const [feeCustom, setFeeCustom] = useState(initialData.umbrella_fee_custom ?? false)
const [showCustomFees, setShowCustomFees] = useState(initialData.umbrella_fee_custom ?? false)
// Form state
const [municipalityCode, setMunicipalityCode] = useState(initialData.municipality_code || '')
const [municipalTaxRate, setMunicipalTaxRate] = useState<number | null>(initialData.municipal_tax_rate ?? null)
const [churchTaxRate, setChurchTaxRate] = useState<number | null>(initialData.church_tax_rate ?? null)
const [churchParishCode, setChurchParishCode] = useState(initialData.church_parish_code || '')
// Debounced municipality search
const searchMunicipalities = useCallback(async (query: string) => {
if (query.length < 2) {
setMunicipalityResults([])
return
}
setMunicipalityLoading(true)
try {
const { data, error } = await supabase
.from('municipality_tax_rates')
.select('*')
.ilike('municipality_name', `%${query}%`)
.is('parish_code', null)
.order('municipality_name')
.limit(10)
if (!error && data) {
setMunicipalityResults(data)
}
} catch {
// silently fail
} finally {
setMunicipalityLoading(false)
}
}, [supabase])
// Debounce the municipality search
useEffect(() => {
const timer = setTimeout(() => {
searchMunicipalities(municipalitySearch)
}, 300)
return () => clearTimeout(timer)
}, [municipalitySearch, searchMunicipalities])
// Load initial municipality if we have a code
useEffect(() => {
if (initialData.municipality_code && !selectedMunicipality) {
const loadMunicipality = async () => {
const { data } = await supabase
.from('municipality_tax_rates')
.select('*')
.eq('municipality_code', initialData.municipality_code!)
.is('parish_code', null)
.limit(1)
.single()
if (data) {
setSelectedMunicipality(data)
setMunicipalitySearch(data.municipality_name)
}
}
loadMunicipality()
}
}, [initialData.municipality_code, selectedMunicipality, supabase])
// Load initial parish if we have a parish code
useEffect(() => {
if (initialData.church_parish_code && !selectedParish && initialData.municipality_code) {
const loadParish = async () => {
const { data } = await supabase
.from('municipality_tax_rates')
.select('*')
.eq('parish_code', initialData.church_parish_code!)
.eq('municipality_code', initialData.municipality_code!)
.limit(1)
.single()
if (data) {
setSelectedParish(data)
}
}
loadParish()
}
}, [initialData.church_parish_code, initialData.municipality_code, selectedParish, supabase])
// Fetch parishes when municipality is selected and church tax is on
useEffect(() => {
if (churchTax && municipalityCode) {
const fetchParishes = async () => {
setParishLoading(true)
try {
const { data, error } = await supabase
.from('municipality_tax_rates')
.select('*')
.eq('municipality_code', municipalityCode)
.not('parish_code', 'is', null)
.order('parish_name')
if (!error && data) {
setParishes(data)
}
} catch {
// silently fail
} finally {
setParishLoading(false)
}
}
fetchParishes()
} else {
setParishes([])
}
}, [churchTax, municipalityCode, supabase])
// Fetch umbrella providers on mount
useEffect(() => {
const fetchProviders = async () => {
const { data, error } = await supabase
.from('umbrella_provider_defaults')
.select('*')
.order('display_name')
if (!error && data) {
setUmbrellaProviders(data)
}
}
fetchProviders()
}, [supabase])
// Handle municipality selection
const handleSelectMunicipality = (municipality: MunicipalityRate) => {
setSelectedMunicipality(municipality)
setMunicipalitySearch(municipality.municipality_name)
setMunicipalityCode(municipality.municipality_code)
setMunicipalTaxRate(municipality.total_rate / 100)
setShowMunicipalityDropdown(false)
// Reset parish selection when municipality changes
setSelectedParish(null)
setChurchTaxRate(null)
setChurchParishCode('')
}
// Handle parish selection
const handleSelectParish = (parishCode: string) => {
const parish = parishes.find((p) => p.parish_code === parishCode)
if (parish) {
setSelectedParish(parish)
setChurchTaxRate(parish.church_rate ? parish.church_rate / 100 : null)
setChurchParishCode(parish.parish_code || '')
}
}
// Handle provider selection
const handleSelectProvider = (providerName: string) => {
setSelectedProvider(providerName)
if (providerName === 'Annan') {
setFeePercent(null)
setPensionPercent(null)
setShowCustomFees(true)
setFeeCustom(true)
return
}
const provider = umbrellaProviders.find((p) => p.display_name === providerName)
if (provider) {
setFeePercent(provider.default_fee_percent)
setPensionPercent(provider.pension_percent)
if (!showCustomFees) {
setFeeCustom(false)
}
}
}
// Handle form submit
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onNext({
municipality_code: municipalityCode,
municipal_tax_rate: municipalTaxRate,
church_tax: churchTax,
church_tax_rate: churchTax ? churchTaxRate : null,
church_parish_code: churchTax ? churchParishCode : null,
umbrella_provider: selectedProvider || null,
umbrella_fee_percent: feePercent,
umbrella_pension_percent: pensionPercent,
umbrella_fee_custom: feeCustom,
})
}
return (
<div className="space-y-6">
<div className="text-center">
<h1 className="text-3xl font-bold tracking-tight">Skatteprofil</h1>
<p className="text-muted-foreground mt-2">
Ange din kommun och egenanställningsföretag för korrekt skatteberäkning
</p>
</div>
<Card className="max-w-lg mx-auto">
<CardHeader>
<CardTitle>Kommun</CardTitle>
<CardDescription>
Din kommunalskattesats används för att uppskatta skatt 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>
)
}

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